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

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