Instagram Clone : React Native - part 4 [ LIKES, SEARCH & PHOTO]

Likes

Photo-id 전달

좋아요를 누른 유저의 목록을 받는 query (seePhotoLikes)를 요청하기 위해 Like 스크린으로 사진의 id를 전달해야한다. navigation.navigate을 이용하여 전달한다.

seePhotoLikes query

전달 받은 id로 query 요청을 한다. User 목록은 재사용 가능하니, fragments로 만들어준다.

Skip in useQuery

photo의 id가 없이 Likes 스크린으로 이동할 경우도 있다. 그럴 경우, query 요청을 하지 않도록 한다.

useQuery의 옵션인 skip에 route로 전달받은 photoId가 없으면 요청을 스킵하도록 한다.

// Likes.tsx
...
function Likes({ route }: LikesProps) {
  const { ... } = useQuery<...>(LIKES_QUERY, {
    variables: { id: route?.params?.photoId! },
    skip: !route?.params?.photoId, // 파라미터 없이 likes 스크린으로 이동하면 스킵
  });
...

FlatList 만들기

Instagram Clone Front-End | FEED - FlatList 참고

마찬가지로, 받아온 User 데이터로 FlatList를 만든다.

ItemSeperatorComponent

FlatList는 ItemSeperatorComponent라는 props로 리스트의 separator 기능을 제공한다. props 안에 seperator로 사용할 스타일 컴포넌트를 함수형으로 넣는다. 컴포넌트는 처음과 마지막 요소에 위와 아래에 구분선이 없이 렌더된다.

// Likes.tsx
...
<FlatList
        ItemSeparatorComponent={() => (
          <View
            style={{
              width: "100%",
              height: 1,
              backgroundColor: "rgba(255, 255, 255, 0.2)",
            }}
          ></View>
        )}
...

Serialize

query 변경으로 인해 schema가 바뀌게 되면 기존에 저장되어 있던 cache랑 충돌하여 에러가 발생한다. 이를 방지하기 위해, persistCache에서 serialize의 값을 false로 지정한다.

// App.tsx
export default function App() {
  ...
  const preload = async () => {
  ...
    await persistCache({
      cache,
      storage: new AsyncStorageWrapper(AsyncStorage),
      serialize: false,
    });

    return preloadAssets();
  };

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

Header Domination

Navigation.setOptions

Navigation으로 이동하면서 Navigator의 옵션을 바꿔준다. route로 전달받은 username을 Header에 띄운다.

// Profile.tsx
...
function Profile({ navigation, route }: ProfileProps) {
  useEffect(() => {
		// route로 전달받은 username이 존재할 경우,
    if (route?.params?.username) {
			// username을 Header로 띄운다.
      navigation.setOptions({ title: route.params.username });
    }
  }, []);
...

useMe Hook

Instagram Clone Front-End | FEED - useUser Hook 참고

Web에서 로그인 유저의 정보를 가져오는 Hook을 그대로 똑같이 사용한다. 이 Hook으로 내 프로필 탭에서 로그인 유저의 정보를 가져와서 렌더시킨다.

// Me.tsx
...
function Me({ navigation }: MeProps) {
  const { data } = useMe();
  useEffect(() => {
    navigation.setOptions({ title: data?.me?.username });
  }, []);
...

Search

Header에 검색창 만들기

마찬가지로, navigation.setOptions을 사용하여 헤더에 검색창을 만든다. useEffect로 컴포넌트가

마운트 될 시 실행시킨다.

DissMissKeyboard 컴포넌트화

Instagram Clone Front-End | AUTHENTICATION - dissMissKeyboard Function 참고
검색창 바깥을 눌렀을 때, 키보드가 사라지게 한다. Keyboard.dismiss 를 사용하여 여러 곳에 재사용 가능한 컴포넌트를 만든다.

// DismissKeyboard.tsx
...
function DismissKeyboard({ children }: { children: React.ReactNode }) {
  const dissmissKeyboard = () => {
    Keyboard.dismiss();
  };
  return (
    <TouchableWithoutFeedback
      style={{ height: "100%" }}
      onPress={() => dissmissKeyboard()}
      disabled={Platform.OS === "web"}
    >
      {children}
    </TouchableWithoutFeedback>
  );
}

useLazyQuery

Header에 생긴 인풋 창으로 searchPhotos 요청을 보낸다. useQuery는 컴포넌트가 마운트 될 시, 자동으로 실행되기 때문에 useLazyQuery라는 Hook을 사용한다. useLazyQuery는 mutation과 같이 요청을 발동시키는 함수가 제공되고 나머지 옵션도 같다.

React-Hook-Form

본격적으로 검색창에 검색 기능을 적용시킨다. 이전과 마찬가지로, 네이티브에서는 register (+ 유효성 검사)와 이벤트를 직접 등록해주어야 한다. 인풋 박스를 submit 했을 때 (onSubmitEditing), handleSubmit으로 onValid 함수를 실행시킨다. submit을 바로 쿼리와 연결시키면, 인풋 입력마다 요청이 발생한다.

검색이 유효성 검사를 통과할 경우 (onValid), 쿼리를 요청한다.

Search Result UI

검색 결과의 경우의 수에 따라, 그에 맞는 메시지를 렌더시켜야 한다. 경우의 수는 다음과 같다.

  1. 요청 중일 경우
  2. 아직 검색하지 않았을 경우
  3. 검색한 후 결과를 받아왔을 경우
  4. 검색했는데 결과가 없을 경우
// Search.tsx
...
return (
    <DismissKeyboard>
      <View style={{ flex: 1, backgroundColor: "black" }}>
				// 1. 요청 중일 경우
        {loading && (
          <MessageContainer>
            <ActivityIndicator size="large" />
            <MessageText>Searching...</MessageText>
          </MessageContainer>
        )}
				// 2. 아직 검색하지 않았을 경우
        {!called && (
          <MessageContainer>
            <MessageText>Search by keyword</MessageText>
          </MessageContainer>
        )}
        {data?.searchPhotos !== undefined &&
        data?.searchPhotos?.length === 0 ? (
				// 3. 검색했는데 결과가 없을 경우
          <MessageContainer>
            <MessageText>Could not find anything</MessageText>
          </MessageContainer>
        ) : (
				// 4. 검색한 후 결과를 받아왔을 경우
          <FlatList
            numColumns={numColumns}
            data={data?.searchPhotos}
            keyExtractor={(photo) => "" + photo.id}
            renderItem={renderPhoto}
          />
        )}
      </View>
    </DismissKeyboard>
  );
...

Photo Grid

검색 결과 나온 사진들을 4열로 정렬한다. FlatList의 numColumns로 열을 지정하여 정렬할 수 있다. 화면 여백 없이 4등분 하기 위해서, useWindowDimensions으로 화면의 너비를 구한 후, numColumn에 내려준 수만큼 나누어 주면 된다.

// Search.tsx
...
function Search({ navigation }: SearchProps) {
  const numColumns = 4;
  ...
  const renderPhoto: ListRenderItem<searchPhotos_searchPhotos> = ({
    item: photo,
  }) => (
    <TouchableOpacity>
      <Image
        source={{ uri: photo.file }}
        style={{ width: width / numColumns, height: 100 }}
      />
    </TouchableOpacity>
  );

  return (
      ...
          <FlatList
            numColumns={numColumns}
	    ...
          />
        )}
      ...
  );
}

export default Search;

Photo Screen

Search to Photo

검색된 photo와 photo 스크린을 연결한다. 마찬가지로 사진을 눌렀을 때, photo 스크린으로 넘어가면서 route를 통해 photo-id를 전달받을 수 있게한다. 전달받은 photo-id로 seePhoto 쿼리 요청을 한다. Photo 컴포넌트를 이미 작성해놓았기 때문에 응답으로 받은 데이터를 그대로 Photo 컴포넌트로 내려주면 된다.

Scroll View Refresh

View가 화면을 넘어갈 수도 있기 때문에, ScrollView를 이용한다. 당겨서 새로고침을 구현하는데, FlatList와 약간의 차이가 있다. ScrollView는 RefreshControl 이라는 컴포넌트 안에 refreshing (새로고침 사용 여부)와 onRefresh (새로고침 시 실행할 함수)를 넣어준다.

그리고 스크롤 할 내용이 담긴 View와 뒷 배경이 각각 contentContainerStylestyle로 스타일링이 구분된다.

// screens/Photo.tsx
...
const SEE_PHOTO = gql`
  query seePhoto($id: Int!) {
    seePhoto(id: $id) {
    ...
`;

function PhotoScreen({ route, navigation }: PhotoProps) {
  const [refreshing, setRefreshing] = useState(false);
  const { data, loading, refetch } = useQuery(SEE_PHOTO, {
    variables: { id: route.params?.photoId },
  });
  ...
  return (
    <ScreenLayout loading={loading}>
      <ScrollView
        showsVerticalScrollIndicator={false}
        refreshControl={
          <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
        }
        style={{ backgroundColor: "black" }}
        contentContainerStyle={{ ... }}
      >
        <Photo {...data?.seePhoto} />
      </ScrollView>
    </ScreenLayout>
  );
}

좋은 웹페이지 즐겨찾기