Instagram Clone : React Native - part 3 [ FEED]

정관우·2021년 10월 5일
1
post-thumbnail

Tab Navigator

Create Tabs

여러 탭을 만든다. 스택과 마찬가지로 Tabs.Navigator 안에 필요한 Tabs.Screen 넣는다. Screen 전체에 일괄적으로 옵션을 적용하기 위해선 NavigatorscreenOptions를 , 개별적으로 optionsScreen의 props로 넣는다. 여러 옵션들은 다음 공식 문서에서 확인할 수 있다.

TabIcon

탭에 아이콘을 넣기 위해선 tabBarIcon 이라는 옵션을 사용한다. 컴포넌트를 리턴하는 함수를 받는다. 함수의 매개 변수로 다음과 같은 값을 받는다.

  1. focused : 탭이 선택되었을 때, true를 반환
  2. color : 아이콘의 색상. tabBarActiveTintColor와 같다.
  3. size : 크기 (number를 받음)

expo vector-icon 목록

@expo/vector-icons directory

함수가 반복적으로 사용되기 때문에, 재사용 가능한 컴포넌트로 만들면 좋다.

// TabIcon.tsx
...
export default function TabIcon({ focused, iconName, color }: TabIconProps) {
  return (
    <Ionicons
      name={focused ? iconName : `${iconName}-outline`}
      color={color}
      size={focused ? 24 : 20}
    />
  );
}

// LoggedInNav.tsx
...
function LoggedInNav() {
  return (
    <Tabs.Navigator ... />
      <Tabs.Screen
	...
        options={{
          tabBarIcon: ({ focused, color, size }) => (
	    // 컴포넌트화 전
            <Ionicons name={focused ? "home" : "home-outline"} 
	     color={color} size={focused ? 24 : 20} />
          ),
        }}
      />
      <Tabs.Screen
	...
        options={{
          tabBarIcon: ({ focused, color, size }) => (
	  // 컴포넌트화 후
            <TabIcon focused={focused} color={color} iconName="search" />
          ),
        }}
      />
      ...

Stack & Tabs

Stacks for each Tab

인스타그램 앱의 구성을 생각하면 각 탭 안에 여러 스택이 있는 구조다. 각 탭을 누를 시, 가장 먼저 올라오는 스택이 해당 탭의 페이지고 페이지 안에서 사진이나 프로필을 클릭하면 또 다른 스택으로 이동하게 된다.

이 구조를 코드로 만드려면 탭(Tabs.Screen) 안에 스택 컴포넌트(SharedStackNav)를 리턴하는 함수를 children으로 보내야한다.

// LoggedInNav.tsx
...
function LoggedInNav() {
  return (
    <Tabs.Navigator ...>
      <Tabs.Screen
        name="RootFeed"
	...
      >
        {() => <StackNavFactory screenName="Feed" />}
      </Tabs.Screen>
	...

SharedStackNav 컴포넌트 안에서 props로 전달 받은 screenName 에 따라, 해당되는 스택 (탭의 첫 화면)을 리턴해준다. ProfilePhoto 모든 탭에서 공용(shared)으로 사용되는 스택이다.

// SharedStackNav.tsx
...
const getFirstScreen = (screenName: string) => {
  if (screenName === "Feed") {
    return <Stack.Screen name="Feed" component={Feed} />;
  } else if (screenName === "Search") {
    return <Stack.Screen name="Search" component={Search} />;
  } else if (screenName === "Notifications") {
    return <Stack.Screen name="Notifications" component={Notifications} />;
  } else if (screenName === "Me") {
    return <Stack.Screen name="Me" component={Me} />;
  } else {
    return null;
  }
};

function SharedStackNav({ screenName }: SharedStackNavProps) {
  return (
    <Stack.Navigator ...>
      {getFirstScreen(screenName)} // ** 탭의 첫 번째 화면   
      <Stack.Screen name="Profile" component={Profile} />
      <Stack.Screen name="Photo" component={Photo} />
    </Stack.Navigator>
  );
} 

Error : Found screens with the same name nested inside one another. Check:
부모와 자식 컴포넌트의 name이 같을 때 생기는 에러, 다르게 바꿔주자.

Screen Title to Image

Feed 스크린의 타이틀을 인스타그램 로고로 바꾼다. headerTitle 이라는 옵션으로 가능하다. String이나 React.Node가 들어간다. Image와 함께 사이즈를 header에 맞춘다.

// SharedStackNav.tsx
...
const getFirstScreen = (screenName: string) => {
  if (screenName === "Feed") {
    return (
      <Stack.Screen
        ...
        options={{
          headerMode: "screen",
          headerTitle: () => (
            <Image
              style={{ maxHeight: 40, maxWidth: 120 }}
              resizeMode="contain"
              source={require("../assets/logo.png")}
            />
          ),
        }}
      />
    );
  ...
};
...

Apollo Auth

setContext in Apollo Client

Web에서와 같이 모든 요청 헤더에 토큰이 담길 수 있도록 한다.

FEED (Header - setContext in Apollo Client 참고)

다만, 차이점이 있다면 웹에서는 토큰을 로컬 스토리지에서 가져와 헤더에 담았지만 네이티브에서는 Reactive Variables 변수에 담긴 토큰을 가져온다.

// Apollo.ts
...
const AuthLink = setContext((_, { headers }) => {
  return {
    headers: {
      ...headers,
      token: tokenVar(), // 현재 tokenVar에 저장된 토큰을 불러옴
    },
  };
});

FlatList

ScrollView vs FlatList

Scroll View

Native에서 View 자체로만 스크롤 할 수 없다. ScrollView 라는 컴포넌트 안에 화면보다 큰 View가 있으면 스크롤이 가능하다.

// 상하 스크롤
...
return (
  <View ...>
    <ScrollView>
      <View style={{ height: 20000, flex: 1, backgroundColor: "blue" }}>
        <Text>I'm super big</Text>
      </View>
    </ScrollView>
  </View>
);

// 좌우 스크롤
...
return (
  <View ...>
    <ScrollView horizontal>
      <View style={{ width: 20000, flex: 1, backgroundColor: "blue" }}>
        <Text>I'm super big</Text>
      </View>
    </ScrollView>
  </View>
 );
}

FlatList

하지만, 앱의 성능을 고려하면 엄청나게 많은 데이터를 ScrollView 로 한꺼번에 렌더시키면 좋지 않다. 이럴 때는 컴포넌트가 화면 상에 있으면 렌더링하고 없으면 렌더링 하지 않는 lazy loading이 필요하다. FlatList 컴포넌트로 이 기능을 쉽게 구현할 수 있다.

FlatList는 3가지 props를 요구한다.

  1. data : 렌더링할 데이터 배열
  2. keyExtractor : 각 요소들을 구분할 고유 식별자 (= key, string 타입)
  3. renderItem : 요소들을 렌더하는 함수
function Feed({ navigation }: FeedProps) {
  const { data, loading } = useQuery(FEED_QUERY);
  const renderPhoto: ListRenderItem<seeFeed_seeFeed || null> = 
	({ item: photo }) => (
    <View style={{ flex: 1 }}>
      <Text style={{ color: "white" }}>{photo.caption}</Text>
    </View>
  );
  return (
    <ScreenLayout loading={loading}>
      <FlatList
        data={data?.seeFeed}
        // string
        keyExtractor={(photo) => "" + photo.id}
        renderItem={renderPhoto}
      />
    </ScreenLayout>
  );
}

Photo

Rendering Photos

UseWindowDimensions

네이티브에서 이미지를 렌더하기 위해, 필수적으로 widthheight 값을 지정해주어야 한다. 너비와 높이를 임의로 지정하면 자칫 이미지의 비율이 이상해질 수도 있다. 인스타그램에서 너비는 화면의 너비와 같게 만든다. UseWindowDimensions 메서드로 화면 너비와 높이를 구할 수 있다.

Image.getSize

이미지 파일의 실제 사이즈를 가져올 수 있다. 3가지 인자를 받는다.

  1. 이미지 파일
  2. 이미지 사이즈를 가져오는데 성공했을 때, 실행시키는 함수 (width, height가 주어짐)
  3. 실패했을 때, 실행시키는 함수

Render

위 두 메서드를 이용하여 사진을 크기를 보기 좋게 렌더시킨다. 너비는 화면의 너비, 높이는 실제 이미지의 높이를 구한 다음 너무 클 경우를 대비하여 3으로 나누어준다.

// Photo.tsx
...
function Photo(...) {
  const { width, height } = useWindowDimensions();
  const [imageHeight, setImageHeight] = useState(height - 450);

  useEffect(() => {
    Image.getSize(file, (width, height) => {
      setImageHeight(height / 3);
    });
  }, [file]);

  return (
    <Container>
      ...
      <File
        resizeMode="cover"
        style={{ width, height: imageHeight }}
        source={{ uri: file }}
      />
      ...
    </Container>
  );
}

Photo 컴포넌트에서 프로필, 댓글, 좋아요 등 눌렀을 때 해당 스크린으로 이동할 수 있게 만든다. 하지만, Photo는 스크린 위에 있는 컴포넌트이기 때문에, navigation prop을 갖고 있지 않다. 두 가지 방법이 있다.

  1. navigation prop을 Feed에서 Photo로 내려주기
  2. useNavigation 사용하기

const navigation = useNavigation<NavigationProp<RootStackParamList>>();

Pull to Refresh

Pull to Refresh

FlatList 컴포넌트의 refreshing, onRefresh props와 useQueryrefetch 를 사용한다.

  1. refreshing : 새로고침 가능 (boolean 타입)
  2. onRefresh : 새로고침 시 실행되는 함수 (function 타입)

refetch 는 query를 다시 호출하는 함수다. refreshing 을 상태로 만들어 refetch 후 다시 새로고침을 종료하는 함수를 만들어 당겨서 새로고침을 구현한다.

// Feed.tsx
...
function Feed(...) {
  const { ..., refetch } = useQuery(FEED_QUERY);

  const refresh = async () => {
    setRefreshing(true);
    await refetch();
    setRefreshing(false);
  };

  const [refreshing, setRefreshing] = useState(false);
  return (
    <ScreenLayout loading={loading}>
      <FlatList
        refreshing={refreshing} 
        onRefresh={refresh} 
        showsVerticalScrollIndicator={false} // 스크롤바 숨기기
      />
      ...
    </ScreenLayout>
  );
}

Infinite Scrolling

Infinite Scrolling

클라이언트에서 스크롤이 끝에 도달했을 때 쯤, 서버에 seeFeed 요청을 하여 계속해서 데이터를 받아온다. 이미 화면에 띄워진 데이터의 인덱스는 offset 이라는 값으로 저장하여, 인덱스 이후의 데이터를 2개씩만 받아온다.

그럼 2개 중 마지막 사진의 끝에 도달할 때 쯤, 다시 offset을 서버에 전달하여 다음 2개의 사진을 다시 받아온다. 이 과정을 보여줄 사진이 더 이상 없을 때 까지 무한 반복하는 기능이 Infinite Scrolling이다.

seeFeed query 수정

offset이라는 값을 받아 2개의 사진을 차례대로 보내는 코드를 작성한다.

// SeeFeed.resolvers.ts
...
const resolvers: Resolvers = {
  Query: {
    seeFeed: protectedResolver((_, { offset }, { loggedInUser }) =>
      // photo를 찾을 때, 팔로워 목록에 내 이름이 있는 유저들의 photo를 찾음
      client.photo.findMany({
        take: 2, // 찾을 photo의 개수
        skip: offset, // 나머지 하나는 offset으로 표시 안함
	...

// SeeFeed.typeDefs.ts
...
  type Query {
    seeFeed(offset: Int!): [Photo] // offset variables 추가
  }
...

fetchMore

클라이언트에서 특정 스크롤 위치에서 계속하여 seeFeed 요청을 보낸다. FlatListonEndReached, onEndReacedThresholduseQueryfetchMore 을 이용하여 구현한다. 다음과 같은 기능을 한다.

  1. onEndReached : 스크롤이 어느 위치에 도달했을 때, 실행되는 함수
  2. onEndReacedThreshold : onEndReached가 실행되는 위치 ( 0 = 스크롤의 끝, 0 이상의 값)
  3. fetchMore : 기존 fetch를 유지한 채 더 많은 데이터를 fetch (pagination 구현에 쓰임)
// Feed.tsx
...
export const FEED_QUERY = gql`
  query seeFeed($offset: Int!) {
    seeFeed(offset: $offset) {
    ...
  }
    ...
`;

function Feed(...) {
  const { ..., fetchMore } = useQuery<
    seeFeed,
    seeFeedVariables
  >(FEED_QUERY, {
    variables: { offset : 0 }, // 초기 값 : 0번째 사진부터
  }); 
  ...
  return (
    <ScreenLayout loading={loading}>
      <FlatList
        onEndReached={() =>
          fetchMore({
            variables: {
	      // 지금까지 받아온 피드의 수 만큼 스킵
              offset: data?.seeFeed?.length, 
            },
          })
        } 
        onEndReachedThreshold={0.02}
        ...
      />
    </ScreenLayout>
  );
}

이제 서버로부터 새로운 피드를 계속해서 받아오지만 화면 상으로는 아무런 변화가 없다. Component나 state의 변화가 전혀 없기 때문에, 아무것도 렌더링 되지 않는 것이다.

typePolicies 설정

Apollo cache에 타입을 설정해준다. Apollo가 query를 전달인자(offset)에 따라 독립된 공간에 저장하고 있기 때문에 리스트가 렌더링 되지 않고 있다.

FlatList : [seeFeed.offset : 0] // 실제 렌더링 되는 리스트 
[seeFeed.offset : 2] // 계속해서 피드를 새로 받고있지만 업데이트 되지 않음
[seeFeed.offset : 4]
[seeFeed.offset : 6]

typePolices를 설정하여 seeFeed에 한해 query를 전달인자에 따라 구별시키는 걸 막아준다. 새로운 피드가 들어오면 기존 피드와 결합되는 식으로 fetchMore 가 이루어지도록 만든다.

// apollo.ts
...
const client = new ApolloClient({
 ...
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
	   // seeFeed에 한하여,
          seeFeed: {
	    // 전달인자에 따라 cache를 구별하지 않는다.
            keyArgs: false, 
	    // 새로운 데이터를 어떻게 처리해야하는지 알려줌 
	    // 기존 데이터나 유입 데이터 둘 중 하나만 존재할 수 있으니 빈 배열이 기본 값
            merge: (existing = [], incoming = []) => [...existing, ...incoming],
          },
	  ...

offsetLimitPagination

위 과정을 네이티브 내장 함수 offsetLimitPagination으로도 쉽게 설정 가능하다.

// apollo.ts
...
typePolicies: {
  Query: {
    fields: {
      seeFeed: offsetLimitPagination(),
      ...

Cache Persist

Cache Persist

서버와 연결이 끊기더라도, Cache에 저장된 데이터로 제한된 영역 안에서 앱 사용이 가능하다. Cache를 persistCache 를 이용하여 AsyncStorage에 저장한 후, AppLoading 시 불러오면 된다.

Export Cache

Cache를 preload 시에 불러올 수 있도록 전역으로 export 해준다.

// apollo.ts
export const cache = new InMemoryCache({ ... });

const client = new ApolloClient({
  link: AuthLink.concat(httpLink),
  cache,
});

Persist Cache

preload 시, 저장해놓은 cache를 불러온다. 그리고 또 새로운 cache는 AsyncStorage에 저장될 수 있도록 설정해준다. ApolloProvider가 초기화 되기 전에, cache를 미리 불러와야한다.

// App.tsx

const preload = async () => {
	...
    await persistCache({
      cache,
      storage: new AsyncStorageWrapper(AsyncStorage),
    });
    return ...
  };

  if (loading) {
    return (
      <AppLoading
        startAsync={preload}
	...
      />
    );
  }

  return (
    <ApolloProvider client={client}>
	...
    </ApolloProvider>
  );
}
profile
작지만 꾸준하게 성장하는 개발자🌳

0개의 댓글