리액트 네이티브 sns앱 만들기

네비게이션 컴포넌트

import React from 'react';
// .... ~

const LoginNavigator = createStackNavigator({
  Login,
  Signup,
  PasswordReset,
});

const MyFeedTab = createStackNavigator({
  MyFeed,
});

const FeedsTab = createStackNavigator({
  Feeds,
  FeedListOnly,
});

const UploadTab = createStackNavigator({
  Upload,
});

const ProfileTab = createStackNavigator({
  Profile,
});

const MainTabs = createBottomTabNavigator({
  MyFeed: {
    screen: MyFeedTab,
    navigationOptions: {
      tabBarIcon: ({focused}: {focused: boolean}) => (
        <Image
          source={
            focused
              ? require('~/Assets/Images/Tabs/ic_home.png')
              : require('~/Assets/Images/Tabs/ic_home_outline.png')
          }
        />
      ),
      tabBarOptions: {
        showLabel: false,
      },
    },
  },
  //  ... ~
    },
  },
});

const MainNavigator = createDrawerNavigator(
  {
    MainTabs,
  },
  {
    drawerPosition: 'right',
    drawerType: 'slide',
    contentComponent: Drawer,
  },
);

const AppNavigator = createSwitchNavigator(
  {
    CheckLogin,
    LoginNavigator,
    MainNavigator,
  },
  {
    initialRouteName: 'CheckLogin',
  },
);

export default createAppContainer(AppNavigator);

이번 앱에서는 스위치, 스택네비게이션 이외에도 탭, 드로워 네비게이션을 활용한다. 탭 네비게이션에 아이콘을 표시하기 위해 리액트 네이티비의 Image컴포넌트를 활용한다.
화면에 표시될 컴포넌트 중 네비게이션 헤더가 필요한 컴포넌트는 기본적으로 스텍네비게이션을 추가해 주어야 한다.
MainTabs에서 탭의 라벨을 제거하기 위해 tabBarOptions의 showLabel을 false로 설정 하였으며, 탭이 선택되었는지 여부에 따라 다른 이미지를 표시하도록 설정하였다.
드로워 네비게이션은 주로 메뉴에서 많이 사용되는 네비게이션이다. droawerPosition 설정에 따라 위치를 정하고, 메뉴는 화면 위가 아닌 화면 전체를 이동시키면서 표시하기 위해 drawerType 을 slide로 설정했따.

App 컴포넌트

import React from 'react'
import { StatusBar } from 'react-native'
import Navigatoor from '~/Screen/Navigator'
import { RandomUserDataProvider } from '~/Context/RandomUserData'

interface Props {}

const App = ({}: Props) => {
  return (
    <RandomUserDataProvider cache={true}>
      <StatusBar barStyle="default" />
      <Navigatoor />
    </RandomUserDataProvider>
  )
}

export default App

상태바의 색을 조정하기 위해 StatusBar를 사용하였다. 네비게이터도 표시되도록 설정하고, 앱 내에서 전역적으로 사용할 데이터를 사용하기 위해 RandomUserData 컨텍스트를 만들고 적용할 예정이다.

컨텍스트

interface Props {
  cache?: boolean
  children: JSX.Element | Array<JSX.Element>
}

interface IRandomUserData {
  getMyFeed: (number?: number) => Array<IFeed>
}

const RandomUserDataContext = createContext<IRandomUserData>({
  getMyFeed: (number: number = 10) => {
    return []
  },
})

const RandomUserDataProvider = ({ cache, children }: Props) => {
  const [userList, setUserList] = useState<Array<IUserProfile>>([])
  const [descriptionList, setDescriptionList] = useState<Array<string>>([])
  const [imageList, setImageList] = useState<Array<string>>([])

  const getCacheData = async (key: string) => {
    const cacheData = await AsyncStorage.getItem(key)
    if (cache === false || cacheData === null) {
      return undefined
    }
    const cacheList = JSON.parse(cacheData)

    if (cacheList.length !== 25) {
      return undefined
    }

    return cacheList
  }
  const setCachedData = (key: string, data: Array<any>) => {
    AsyncStorage.setItem(key, JSON.stringify(data))
  }

  const setUsers = async () => {
    const cachedData = await getCacheData('UserList')
    if (cachedData) {
      setUserList(cachedData)
      return
    }

    try {
      console.log('dasdasdasdasdasdasd')
      const response = await fetch('https://uinames.com/api/?amount=25&ext')
      const data = await response.json()
      setUserList(data)
      setCachedData('UserList', data)
    } catch (err) {
      console.log(err)
    }
  }

  const setDescriptions = async () => {
    const cachedData = await getCacheData('DescriptionList')

    if (cachedData) {
      setDescriptionList(cachedData)
      return
    }

    try {
      const response = await fetch(
        'https://opinionated-quotes-api.gigalixirapp.com/v1/quotes?rand=t&n=25'
      )
      const data = await response.json()
      let text = []
      for (const index in data.quotes) {
        text.push(data.quotes[index].quote)
      }
      setDescriptionList(text)
      setCachedData('DescriptionList', text)
    } catch (err) {
      console.log(err)
    }
  }

  const setImages = async () => {
    const cachedData = await getCacheData('ImageList')
    console.log(cachedData, '캐시데이타')
    if (cachedData) {
      console.log(cachedData, '캐시데이타')
      if (Image.queryCache) {
        Image.queryCache(cachedData)
        cachedData.map((data: string) => {
          Image.prefetch(data)
        })
      }
      setImageList(cachedData)
      return
    }

    setTimeout(async () => {
      try {
        const response = await fetch('https://source.unsplash.com/random/')
        const data = response.url
        if (imageList.indexOf(data) >= 0) {
          setImages()
          return
        }
        setImageList([...imageList, data])
      } catch (err) {
        console.log(err)
      }
    }, 400)
  }
  useEffect(() => {
    setUsers()
    setDescriptions()
  }, [])

  useEffect(() => {
    if (imageList.length !== 25) {
      setImages()
    } else {
      setCachedData('ImageList', imageList)
    }
  }, [imageList])

  const getImages = (): Array<string> => {
    let images: Array<string> = []
    const count = Math.floor(Math.random() * 4)

    for (let i = 0; i <= count; i++) {
      images.push(imageList[Math.floor(Math.random() * 24)])
    }

    return images
  }

  // ...~
}

export { RandomUserDataProvider, RandomUserDataContext }

전역 데이터는 화면에 표시될 이미지, 사용자 정보 등을 API를 통해 가져오고 랜덤으로 표시되도록 설정한다. 또한 API가 분당 호출 제한이 있어서, 가져온 데이터를 캐시하도록 만든다.

로그인 컴포넌트

import React from 'react'
import AsyncStorage from '@react-native-community/async-storage'
import { NavigationScreenProp, NavigationState } from 'react-navigation'
import styled from 'styled-components/native'

import Input from '~/Components/Input'
import Button from '~/Components/Button'

interface Props {
  navigation: NavigationScreenProp<NavigationState>
}

const Login = ({ navigation }: Props) => {
  return (
    <Container>
      <FormContainer>
        <Logo>SNS App</Logo>
        <Input style={{ marginBotton: 16 }} placeholder="이메일" />
        <Input
          style={{ marginBotton: 16 }}
          placeholder="비밀번호"
          secureTextEntry={true}
        />
        <PasswordReset onPress={() => navigation.navigate('PasswordReset')}>
          비밀번호 재설정
        </PasswordReset>
        <Button
          label="로그인"
          style={{ marginBotton: 24 }}
          onPress={() => {
            AsyncStorage.setItem('key', 'JWT_KEY')
            navigation.navigate('MainNavigator')
          }}
        />
        <SignupText>
          계정이 없는가요?{''}
          <SignupLink onPress={() => navigation.navigate('Signup')}>
            가입하기.
          </SignupLink>
        </SignupText>
      </FormContainer>
      <Footer>
        <Copyright>SNSApp from dev-jaeyoung</Copyright>
      </Footer>
    </Container>
  )
}

Login.navigationOptions = {
  header: null,
}

export default Login

로그인 컴포넌트는 크게 정보를 입력하는 폼과 하단에 푸터로 나뉜다. 로그인 화면에서 다른 여려화면으로 전환 가능하도록 구성하였다.

MyFeed 컴포넌트

import React, { useContext, useState, useEffect } from 'react'
import { FlatList } from 'react-native'
import {
  NavigationScreenProp,
  NavigationState,
  NavigationProp,
} from 'react-navigation'
import { RandomUserDataContext } from '~/Context/RandomUserData'
import IconButton from '~/Components/IconButton'
import Feed from '~/Components/Feed'
import StoryList from './StoryList'

interface Props {
  navigation: NavigationProp<NavigationState>
}

const MyFeed = ({ navigation }: Props) => {
  const { getMyFeed } = useContext(RandomUserDataContext)
  const [feedList, setFeedList] = useState<Array<IFeed>>([])
  const [storyList, setStoryList] = useState<Array<IFeed>>([])
  const [loading, setLoading] = useState<boolean>(false)

  useEffect(() => {
    setFeedList(getMyFeed())
    setStoryList(getMyFeed())
  }, [])

  return (
    <FlatList
      data={feedList}
      keyExtractor={(item, index) => {
        return `myfeed-${index}`
      }}
      showsVerticalScrollIndicator={false}
      onRefresh={() => {
        setLoading(true)
        setTimeout(() => {
          setFeedList(getMyFeed())
          setStoryList(getMyFeed())
          setLoading(false)
        }, 2000)
      }}
      onEndReached={() => {
        setFeedList([...feedList, ...getMyFeed()])
      }}
      onEndReachedThreshold={0.5}
      refreshing={loading}
      ListHeaderComponent={<StoryList storyList={storyList} />}
      renderItem={({ item, index }) => (
        <Feed
          id={index}
          name={item.name}
          photo={item.photo}
          description={item.description}
          images={item.images}
        />
      )}
    />
  )
}

MyFeed.navigationOptions = {
  title: 'SNS App',
  headerLeft: <IconButton iconName="camera" />,
  headerRight: (
    <>
      <IconButton iconName="live" />
      <IconButton iconName="send" />
    </>
  ),
}

export default MyFeed

화면에 헤더에 표시될 스토리리스트와 피드 리스트를 상태관리하여 만들어 사용했다. MyFeed컴포넌트가 화면에 표시되면 useContext의 getMyFeed함수를 사용하여 데이터를 가져와 state값을 설정하였다.
FlatList의 onRefresh를 사용하여 당겨서 새로고침을 구현하고 이때 헤더의 스토리리스트와 피드리스트를 다시 가져와 화면을 갱신한다. onEndReached를 사용하여 스크롤이 최하단일때 데이터를 다시 가져와 추가한다.

FeedBody 컴포넌트

interface Props {
  id: number
  images: Array<string>
}

const FeedBody = ({ id, images }: Props) => {
  const [indicatorIndex, setIndicatorIndex] = useState<number>(0)
  const imageLength = images.length

  return (
    <Container>
      <ScrollView
        horizontal={true}
        pagingEnabled={true}
        showsHorizontalScrollIndicator={false}
        scrollEnabled={imageLength > 1}
        onScroll={(event: NativeSyntheticEvent<NativeScrollEvent>) => {
          setIndicatorIndex(
            event.nativeEvent.contentOffset.x / Dimensions.get('window').width
          )
        }}
      >
        {images.map((image, index) => (
          <ImageContainer key={`FeedImage-${index}`}>
            <Image
              source={{ uri: image as string }}
              style={{ width: Dimensions.get('window').width, height: 400 }}
            />
          </ImageContainer>
        ))}
      </ScrollView>
      <FeedMenuContainer>
        <MenuContainer>
          <IconButton iconName="favorite" />
          <IconButton iconName="comment" />
          <IconButton iconName="send" />
        </MenuContainer>
        <MenuContainer>
          <FeedImageIndicatorContainer>
            {imageLength > 1 &&
              images.map((image, index) => (
                <FeedImageIndicator
                  key={`FeedImageIndicator-${index}`}
                  style={{
                    backgroundColor:
                      indicatorIndex >= index && indicatorIndex < index + 1
                        ? '#3796Ef'
                        : '#d3d3d3',
                  }}
                />
              ))}
          </FeedImageIndicatorContainer>
        </MenuContainer>
        <MenuContainer style={{ justifyContent: 'flex-end' }}>
          <IconButton iconName="bookmark" />
        </MenuContainer>
      </FeedMenuContainer>
    </Container>
  )
}

export default FeedBody

이미지 리스트를 페이지 형식으로 스크롤하기 위해 pagingEnabled를 true로 설정하였다. 또한 직접 제작한 인디케이터를 사용하기 위해, showsHorizontalScrollIndicator를 false를 설정함으로써 ScrollView가 기본적으로 제공하는 인디케이터를 비활성화 하였다.
사용자에 의해 스크롤 이벤트가 발생하면(onScroll) 그렇게 구한 스크롤의 위치를 useState를 사용하여 생성한 setIndicatorIndex 함수를 통해 설정하였다. IndicatorIndex를 사용하여 화면에 표시된 인디케이터의 색상을 변경하여 사용자에게 현재 이미지의 위치가 이미지 리스트에서 어디에 해당하는지 알 수 있다.

Notification 컴포넌트

import React, { useContext, useState, useEffect, createRef } from 'react'
import {
  Dimensions,
  NativeSyntheticEvent,
  NativeScrollEvent,
  ScrollView,
} from 'react-native'
import styled from 'styled-components/native'
import { RandomUserDataContext } from '~/Context/RandomUserData'
import Tab from '~/Screen/Tab'
import NotificationList from './NotificationList'

const ProfileTabContainer = styled.View`
  flex-direction: row;
  background-color: #feffff;
`

const Label = styled.Text`
  color: #929292;
  text-align: center;
`

const TabContainer = styled.SafeAreaView`
  width: 100%;
  height: ${Dimensions.get('window').height}px;
`

interface Props {}

const Notification = ({}: Props) => {
  const { getMyFeed } = useContext(RandomUserDataContext)
  const [followingList, setFollowingList] = useState<Array<IFeed>>([])
  const [myNotifications, setMyNotifications] = useState<Array<IFeed>>([])
  const [tabIndex, setTabIndex] = useState<number>(1)
  const width = Dimensions.get('window').width
  const tabs = ['팔로잉', '내 소식']
  const refScrollView = createRef<ScrollView>()

  useEffect(() => {
    setFollowingList(getMyFeed(24))
    setMyNotifications(getMyFeed(24))
  }, [])

  return (
    <TabContainer>
      <ProfileTabContainer>
        {tabs.map((label: string, index: number) => (
          <Tab
            key={`tabs-${index}`}
            selected={tabIndex === index}
            label={label}
            onPress={() => {
              setTabIndex(index)
              const node = refScrollView.current
              if (node) {
                node.scrollTo({ x: width * index, y: 0, animated: true })
              }
            }}
          />
        ))}
      </ProfileTabContainer>
      <ScrollView
        ref={refScrollView}
        horizontal={true}
        showsHorizontalScrollIndicator={false}
        pagingEnabled={true}
        stickyHeaderIndices={[0]}
        onScroll={(event: NativeSyntheticEvent<NativeScrollEvent>) => {
          const index = event.nativeEvent.contentOffset.x / width
          setTabIndex(index)
        }}
        contentOffset={{ x: width, y: 0 }}
      >
        <NotificationList
          id={0}
          width={width}
          data={followingList}
          onEndReached={() => {
            setFollowingList([...followingList, ...getMyFeed(24)])
          }}
        />
        <NotificationList
          id={1}
          width={width}
          data={myNotifications}
          onEndReached={() => {
            setMyNotifications([...myNotifications, ...getMyFeed(24)])
          }}
        />
      </ScrollView>
    </TabContainer>
  )
}

export default Notification

Notification 컴포넌트는 Tab컴포넌트를 활용하여 탭으로 구성되어 있으며 state로 현재 선택되어 있는 탭을 구분할 수 있도록 설정하였다. 다른 컴포넌트들과는 다르게 createRef를 사용하였으며, createRef는 리액트에서 컴포넌트를 컴포넌트 외부에서 직접 컨트롤하여 컴포넌트의 이벤트 또는 함수를 다룰 때 사용한다. 이번 예제에서 탭을 선택하였을 때, ScrollView를 직접 컨트롤하여 해당하는 화면을 표시하도록 설정하였다.

Profile 컴포넌트

import React, { useState, useContext, useEffect } from 'react'
import {
  NativeScrollEvent,
  Image,
  Dimensions,
  NativeSyntheticEvent,
  ScrollView,
  ImageSourcePropType,
} from 'react-native'
import { NavigationScreenProp, NavigationState } from 'react-navigation'
import styled from 'styled-components/native'
import { RandomUserDataContext } from '~/Context/RandomUserData'
import IconButton from '~/Components/IconButton'
import Tab from '~/Screen/Tab'
import ProfileHeader from './ProfileHeader'
import ProfileBody from './ProfileBody'

interface Props {
  navigation: NavigationScreenProp<NavigationState>
}

const Profile = ({ navigation }: Props) => {
  const { getMyFeed } = useContext(RandomUserDataContext)
  const [feedList, setFeedList] = useState<Array<IFeed>>([])
  const imageWidth = Dimensions.get('window').width / 3
  const tabs = [
    require('~/Assets/Images/ic_grid_image_focus.png'),
    require('~/Assets/Images/ic_tab_image.png'),
  ]

  useEffect(() => {
    setFeedList(getMyFeed(24))
  }, [])

  const isBottom = ({
    layoutMeasurement,
    contentOffset,
    contentSize,
  }: NativeScrollEvent) => {
    return layoutMeasurement.height + contentOffset.y >= contentSize.height
  }

  return (
    <ScrollView
      stickyHeaderIndices={[2]}
      onScroll={(event: NativeSyntheticEvent<NativeScrollEvent>) => {
        if (isBottom(event.nativeEvent)) {
          setFeedList([...feedList, ...getMyFeed(24)])
        }
      }}
    >
      <ProfileHeader
        image="http://api.randomuser.me/portraits/women/68.jpg"
        posts={3431}
        follower={6530}
        following={217}
      />
      <ProfileBody
        name="Sara Lambert"
        description="On Friday, April 14, being Good-Friday, i repaired to him in the\nmorning, according to my usual custom on that day, and breakfasted\nwith him"
      />
      <ProfileTabContainer>
        {tabs.map((image: ImageSourcePropType, index: number) => (
          <Tab
            key={`tab-${index}`}
            selected={index === 0}
            imageSource={image}
          />
        ))}
        <FeedContainer>
          {feedList.map((feed: IFeed, index: number) => (
            <ImageContainer
              key={`feed-list-${index}`}
              style={{
                paddingLeft: index % 3 === 0 ? 0 : 1,
                paddingRight: index % 3 === 2 ? 0 : 1,
                width: imageWidth,
              }}
            >
              <Image
                source={{ uri: feed.images[0] }}
                style={{ width: imageWidth, height: imageWidth }}
              />
            </ImageContainer>
          ))}
        </FeedContainer>
      </ProfileTabContainer>
    </ScrollView>
  )
}

interface INaviProps {
  navigation: NavigationScreenProp<NavigationState>
}

Profile.navigationOptions = ({ navigation }: INaviProps) => {
  return {
    title: 'profile',
    headerRight: <IconButton iconName="menu" onPress={navigation.openDrawer} />,
  }
}

export default Profile

화면을 스크롤하다 보면, 중간에 있는 탭이 상단에 고정되는데 ScrollView의 stickyHeaderIndices를 사용하면 된다. 에러가 조금있어서 체크를 해봐야 할것같다. stickyHeaderIndices는 상단에 고정하고 싶은 리스트 아이템의 인덱스를 리스트 형태로 지정해 주면 해당 아이템이 상단 부분에 도착하였을 때 고정되도록 설정할 수 있다. ScrollView 컴포넌트는 FlatList 컴포넌트와 다르게 onEndReached를 Props로 가지고 있지 않다. 따라서 무한 스크롤을 onScroll 이벤트를 활용하여 구현했다.


Written by@jaeyoung-son
배운것을 기록하는 공간입니다.

GitHub