React Native/React Native_study

[리액트 네이티브를 다루는 기술 #21] 9장 Firebase로 사진 공유 앱 만들기2 (p .511 ~ 528)

bocoder
728x90
반응형

@ List

** 9.3.4 Firestore에 포스트 등록하기

* 9.4 포스트 목록 조회하기

** 9.4.1 PostCard 커포넌트 만들기

** 9.4.2 Firestore로 포스트 목록 조회하기

** 9.4.3 FeedScreen에서 getPost 호출 후 FlatList로 보여주기

** 9.4.4 페이지네이션 및 시간순 정렬하기

 

@ Note

1. FeedScreen 외부에 renderItem 선언

 - FlatList에서 보여지는 컴포넌트의 개수가 많을 경우, 컴토넌트 내부에서 renderItem을 렌더링할 때마다 매번 renderItem을 생성함

 - 외부에 renderItem 을 한 번만 만들고 재사용하는 것이 성능 면에서 좋음

 - 다른 방법으로 useMemo 사용 가능

 

2. Firesestore().collection() 활용

 - 데이터 정렬 및 제한

  > orderBy() : 데이터 정렬 순서 지정

  > limit() : 검색된 문서 수 제한

* reference : https://firebase.google.com/docs/firestore/query-data/order-limit-data?hl=ko 

 - 쿼리 커서로 데이터 페이지화

  > startAfter() : 파라미터로 넣은 정보 이후의 정보를 불러옴

  > startAt() : 파라미터로 넣은 정보를 포함한 이후의 정보를 불러옴

  > endBefore() : 파라미터로 넣은 정보 이전의 정보를 불러봄

 * reference : https://firebase.google.com/docs/firestore/query-data/query-cursors?hl=ko 

 

3. 비동기 처리 시, then() 참고 사항

 useEffect(() => {
    // res 생략 시
    getPosts().then(setPosts);

    // res 생략 하지 않을 시
    getPosts().then(res => {
      setPosts(res);
    });
  }, []);

 

4. posts 의 response 데이터 확인

export async function getPosts() {
 ...

  const posts = snapshot.docs.map(doc => ({
    id: doc.id, 
    ...doc.data(),
  }));
  
  return posts;
}

 

@ Result

iOS / android - post 업로드 테스트
0123
iOS / android - firestore 업로드 확인 

 

01
iOS / android - post 여러개 등록 및 최신순 정렬

 

iOS / android - 스크롤에 따른 과거 post 불러오기

 

iOS / android - 새로고침에 따른 최신 post 불러오기

 

@ Git

 - https://github.com/eunbok-bocoder/PublicGalleryBocoder/commits/main

 

# Source tree

 

# components > PostCard.js

import React, {useMemo} from 'react';
import {View, StyleSheet, Text, Image, Pressable} from 'react-native';

function PostCard({user, photoURL, description, createdAt, id}) {
  const date = useMemo(
    () => (createdAt ? new Date(createdAt._seconds * 1000) : new Date()),
    [createdAt],
  );

  const onOpenProfile = () => {
    // TODO: 사용자 프로필 화면 열기
  };

  return (
    <View style={styles.block}>
      <View style={[styles.head, styles.paddingBlock]}>
        <Pressable style={styles.profile} onPress={onOpenProfile}>
          <Image
            source={
              user.photoURL
                ? {
                    uri: user.photoURL,
                  }
                : require('../assets/user.png')
            }
            resizeMode="cover"
            style={styles.avatar}
          />
          <Text style={styles.displayName}>{user.displayName}</Text>
        </Pressable>
      </View>
      <Image
        source={{uri: photoURL}}
        style={styles.image}
        resizeMethod="resize"
        resizeMode="cover"
      />
      <View style={styles.paddingBlock}>
        <Text style={styles.description}>{description}</Text>
        <Text date={date} style={styles.date}>
          {date.toLocaleDateString()}
        </Text>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  block: {
    paddingTop: 16,
    paddingBottom: 16,
  },
  avatar: {
    width: 32,
    height: 32,
    borderRadius: 16,
  },
  paddingBlock: {
    paddingHorizontal: 16,
  },
  head: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    marginBottom: 16,
  },
  profile: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  displayName: {
    lineHeight: 16,
    fontSize: 16,
    marginLeft: 8,
    fontWeight: 'bold',
  },
  image: {
    backgroundColor: '#bdbdbd',
    width: '100%',
    aspectRatio: 1,
    marginBottom: 16,
  },
  description: {
    fontSize: 16,
    lineHeight: 24,
    marginBottom: 8,
  },
  date: {
    color: '#757575',
    fontSize: 12,
    lineHeight: 18,
  },
});

export default PostCard;

 

# index.js

/**
 * @format
 */

import {AppRegistry} from 'react-native';
import App from './App';
import {name as appName} from './app.json';
import 'react-native-get-random-values';

AppRegistry.registerComponent(appName, () => App);

 

# lib > posts.js

import firestore from '@react-native-firebase/firestore';

const postsCollection = firestore().collection('posts');

// 포스트 생성
export function createPost({user, photoURL, description}) {
  return postsCollection.add({
    user,
    photoURL,
    description,
    createdAt: firestore.FieldValue.serverTimestamp(),
  });
}

export const PAGE_SIZE = 3;

// 포스트 불러오기
export async function getPosts() {
  const snapshot = await postsCollection
    .orderBy('createdAt', 'desc') // 시간순 내림차순 정렬
    .limit(PAGE_SIZE) // 페이지 수 제한
    .get();

  const posts = snapshot.docs.map(doc => ({
    id: doc.id, // key 추가
    ...doc.data(),
  }));

  return posts;
}

// 이전 포스트 불러오기
export async function getOlderPosts(id) {
  const cursorDoc = await postsCollection.doc(id).get();
  const snapshot = await postsCollection
    .orderBy('createdAt', 'desc')
    .startAfter(cursorDoc) //  특정 포스트의 id 값 이전에 작성한 포스트를 불러옴
    .limit(PAGE_SIZE)
    .get();

  const posts = snapshot.docs.map(doc => ({
    id: doc.id,
    ...doc.data(),
  }));

  return posts;
}

// 최신 포스트 불러오기
export async function getNewerPosts(id) {
  const cursorDoc = await postsCollection.doc(id).get();
  const snapshot = await postsCollection
    .orderBy('createdAt', 'desc')
    .endBefore(cursorDoc) //  특정 포스트의 id 값 이후에 작성한 포스트를 불러옴
    .limit(PAGE_SIZE)
    .get();

  const posts = snapshot.docs.map(doc => ({
    id: doc.id,
    ...doc.data(),
  }));

  return posts;
}

 

# screens > FeedScreen.js

import React, {useEffect, useState} from 'react';
import {
  ActivityIndicator,
  FlatList,
  RefreshContol,
  RefreshControl,
  StyleSheet,
} from 'react-native';
import PostCard from '../components/PostCard';
import {getNewerPosts, getOlderPosts, getPosts, PAGE_SIZE} from '../lib/posts';

function FeedScreen() {
  const [posts, setPosts] = useState(null);
  // 마지막 포스트까지 조회했음을 명시하는 상태
  const [noMorePost, setNoMorePost] = useState(false);
  // refreshing 이 true 일때만 새로고침 표시
  const [refreshing, setRefreshing] = useState(false);

  useEffect(() => {
    // 컴포넌트가 처음 마운트될 때 포스트 목록을 조회한 후 'posts' 상태에 담기
    getPosts().then(setPosts);
    // 동일한 표현
    // getPosts().then(res => {
    //   setPosts(res);
    // });
  }, []);

  const onLoadMore = async () => {
    if (noMorePost || !posts || posts.length < PAGE_SIZE) {
      return;
    }
    const lastPost = posts[posts.length - 1];
    const olderPosts = await getOlderPosts(lastPost.id);
    if (olderPosts.length < PAGE_SIZE) {
      setNoMorePost(true);
    }
    setPosts(posts.concat(olderPosts));
  };

  const onRefresh = async () => {
    refreshing;
    if (!posts || posts.length === 0 || refreshing) {
      return;
    }
    const firstPost = posts[0];
    setRefreshing(true);
    const newerPosts = await getNewerPosts(firstPost.id);
    setRefreshing(false);
    if (newerPosts.length === 0) {
      return;
    }
    setPosts(newerPosts.concat(posts));
  };

  return (
    <FlatList
      data={posts}
      renderItem={renderItem}
      keyExtractor={item => item.id}
      contentContainerStyle={styles.container}
      onEndReached={onLoadMore}
      onEndReachedThreshold={0.75} // 출발지(0) ~ 끝지점(1), 설정값에 도달 시 onEndReached 실행
      ListFooterComponent={
        // 모든 항목의 맨 아래 구현할 값
        !noMorePost && (
          <ActivityIndicator style={styles.spinner} size={32} color="6200ee" />
        )
      }
      refreshControl={
        (console.log('[LOG] refresh'),
        (<RefreshControl onRefresh={onRefresh} refreshing={refreshing} />))
      }
    />
  );
}

const renderItem = ({item}) => (
  <PostCard
    createdAt={item.createdAt}
    description={item.description}
    id={item.id}
    user={item.user}
    photoURL={item.photoURL}
  />
);

const styles = StyleSheet.create({
  container: {
    paddingBottom: 48,
  },
  spinner: {
    height: 64,
  },
});

export default FeedScreen;

 

# screens > UploadScreen.js

import React, {useEffect, useRef, useState, useCallback} from 'react';
import {
  StyleSheet,
  TextInput,
  View,
  Animated,
  Keyboard,
  useWindowDimensions,
  Platform,
  KeyboardAvoidingView,
} from 'react-native';
import {useNavigation, useRoute} from '@react-navigation/native';
import IconRightButton from '../components/IconRightButton';
import storage from '@react-native-firebase/storage';
import {useUserContext} from '../contexts/UserContext';
import {v4} from 'uuid';
import {createPost} from '../lib/posts';

function UploadScreen() {
  const route = useRoute();
  if (route) console.log('[route] : ' + JSON.stringify(route));
  const {res} = route.params || {};
  const {width} = useWindowDimensions();
  const animation = useRef(new Animated.Value(width)).current;
  const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
  const [description, setDescription] = useState('');
  const navigation = useNavigation();

  const {user} = useUserContext();
  const onSubmit = useCallback(async () => {
    // TODO: 포스트 작성 로직 구현
    navigation.pop();
    const asset = res.assets[0];

    const extension = asset.fileName.split('.').pop();
    const reference = storage().ref(`/photo/${user.id}/${v4()}.${extension}`);
    if (Platform.OS === 'android') {
      await reference.putString(asset.base64, 'base64', {
        contentType: asset.type,
      });
    } else {
      await reference.putFile(asset.uri);
    }
    const photoURL = await reference.getDownloadURL();
    await createPost({description, photoURL, user});

    // TODO : 포스트 목록 새로고침
  }, [res, user, description, navigation]);

  useEffect(() => {
    const didShow = Keyboard.addListener('keyboardDidShow', () =>
      setIsKeyboardOpen(true),
    );
    const didHide = Keyboard.addListener('keyboardDidHide', () =>
      setIsKeyboardOpen(false),
    );

    return () => {
      didShow.remove();
      didHide.remove();
    };
  }, []); // 컴포넌트가 화면에 나타날 때 이벤트를 등로갛고, 사라질 때 이벤트를 해제해야 하므로 deps 배열은 비워둠

  useEffect(() => {
    Animated.timing(animation, {
      toValue: isKeyboardOpen ? 0 : width,
      useNativeDriver: false,
      duration: 150,
      delay: 100,
    }).start();
  }, [isKeyboardOpen, width, animation]);

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => <IconRightButton onPress={onSubmit} name="send" />,
    });
  }, [navigation, onSubmit]);

  return (
    <KeyboardAvoidingView
      behavior={Platform.select({ios: 'height'})}
      style={styles.block}
      keyboardVerticalOffset={Platform.select({
        ios: 180, // iOS 에서 텍스트 입력 시, 'enter'를 많이 입력하면 텍스트가 화면 밖에 보이는 현상 방지
      })}>
      <View style={styles.block}>
        <Animated.Image
          source={{uri: res.assets[0]?.uri}}
          style={[styles.image, {height: animation}]}
          resizeMode="cover"
        />
        <TextInput
          style={styles.input}
          multiline={true}
          placeholder="이 사진에 대한 설명을 입력하세요..."
          textAlignVertical="top"
          value={description}
          onChangeText={setDescription}
        />
      </View>
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  block: {flex: 1},
  image: {width: '100%'},
  input: {
    paddingHorizontal: 16,
    paddingTop: 16,
    paddingBottom: 16,
    flex: 1,
    fontSize: 16,
  },
});

export default UploadScreen;

 

728x90
반응형