React Native/React Native_study

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

bocoder
728x90
반응형

@ List

* 9.6 포스트 수정 및 삭제 기능 구현하기

** 9.6.1 재사용할 수 있는 모달 만들기

** 9.6.2 사용자에게 수정 및 삭제 물어보기

** 9.6.3 포스트 삭제 기능 구현하기

** 9.6.4 포스트 설명 수정 기능 구현하기

* 9.7 EventEmitter로 다른 화면 간 흐름 제어하기

** 9.7.1 EventEmitter3 설치 및 적용하기

** 9.7.2 포스트 작성 후 업데이하기

** 9.7.3 포스트 삭제 후 목록에서 제거하기

** 9.7.4 리팩토링 하기

** 9.7.5 포스트 수정 후 업데이트하기

* 9.8 설정 화면 만들기

* 9.9 Firesotre 보안 설정하기

* 9.10 Splash 화면 만들기

** 9.10.1 안드로이드에 Splash 화면 적용하기

** 9.10.2 iOS에 Splash 화면 적용하기

** 9.10.3 원하는 시점에 Splash 화면 숨기기

* 9.11 정리

 

@ Note

1. Pressable hitSlop 기능

 - 컴포넌트가 차지하는 영역은 그대로 유지하고 터치할 수 있는 영역만 조절함

- reference : https://reactnative.dev/docs/0.63/touchablewithoutfeedback#hitslop

hitSlop에 따른 터치 영역 변화

 

2. 배열을 rendering 할 때는 고유한 값을 key Props로 지정해야 함

 - reference : https://ko.reactjs.org/docs/reconciliation.html#recursing-on-children

 

3. EventEmitter 활용한 이벤트 관리

// 라이브러리 설치
$ yarn add eventmitter3

 - 리액트 네이티브 프로젝트 안의 모든 컴포넌트 및 Hook 에서 인스턴스에 접근하려면 2가지 방법 존재

  > 간단 : 자바스크립트 코드 자체에서 export 하는 방안 (본 프로젝트에서는 해당 방법 사용)

  > 정석 : Context르 만들어서 인스턴스를 하위 컴포넌트로 내려주는 것 (인스턴스가 기능별로 여러 개 필요할 때 보통 사용함)

import EventEmitter3 from 'eventemitter3';

const events = new EventEmitter3();

export default events;

 - 이벤트 발생

import events from '../lib/events';
...
events.emit('refresh');

 - 이벤트에 콜백 함수 등록 및 해지

import events from '../lib/events';
...
  useEffect(() => {
    events.addListener('refresh', onRefresh);
    return () => {
      events.removeListener('refresh', onRefresh);
    };
  }, [onRefresh]);

 

4. Firebase 보안 설정

 

5. Splash 화면 라이브러리

// 라이브러리 설치
$ yarn add react-native-splash-screen

 - Android 적용 : MainActivity.java 수정, launch_screen.xml 추가

  > reference : https://developer.android.com/guide/topics/ui/declaring-layout

package com.publicgallerybocoder;

import com.facebook.react.ReactActivity;

// for react-native-splash-screen
import android.os.Bundle;
import org.devio.rn.splashscreen.SplashScreen;

public class MainActivity extends ReactActivity {

  ...
  
  // for react-native-splash-screen
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    SplashScreen.show(this); // here
    super.onCreate(savedInstanceState);
  }
}

- iOS 적용 : AppDelegate.m 수정, Image.xcassets > Image Set 추가, LaunchScreen.storyboard > Image View 추가 및 설정

 

6. 에러 디버깅

 - iOS 시뮬레이터에서 카메라 촬영 기능 사용 불가 에러

  > 에러 return 에 대한 오류 alert 추가

  const onPickImage = res => {
  	...
    
    if (res.errorCode) {
      Alert.alert('사용 불가', '해당 기능은 현재 사용 불가능 합니다.');
      return;
    }
  };

 -  Firestore 삭제된 데이터 기준으로 쿼리 요청에 대한 오류

휴대폰 A와 B에서 Firestore 에 쿼리를 날려 데이터를 받아오는데,
가장 최신 포스트를 작성한 A가 해당 데이터를 삭제했을 시,
B가 FeedScreen을 refresh 하게되면 getNewerPosts()를 호출해서,
endBefore로 가장 최신 포스트 (A가 삭제했던 포스트)를 기준으로 이후 데이터를 받아오게 되는데,
A가 해당 포스터를 삭제한 이후 시점이다보니 기준 포스터가 사라져서 error가 발생

  > onRefresh 함수에 Firestore와 local 데이터를 비교하는 구문을 넣어서 해결

  const onRefresh = useCallback(async () => {
    ...
    
    let dbPosts = await getPosts();
    let localPosts = posts;
    console.log('[LOG] dbPosts[0].id : ', dbPosts[0].id);
    console.log('[Log] localPosts[0].id : ', localPosts[0].id);
    if (dbPosts !== localPosts) {
      setPosts(dbPosts);
      setRefreshing(false);
      return;
    }
    
    ...
  }, [posts, userId, refreshing]);

 

@ Result

iOS / android - 로그아웃 기능 구현

 

iOS / android - 포스트 작성 후 업데이트

 

iOS / android - 포스트 삭제 기능 구현

 

0123
iOS / android - 수정 기능 구현

 

iOS / android - Splash 화면

 

iOS / android - Splash 화면 구현

 

@ Git

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

 

# Source tree

 

# android > app > src > main > java > com > ... > MainActivity.java

package com.publicgallerybocoder;

import com.facebook.react.ReactActivity;

// for react-native-splash-screen
import android.os.Bundle;
import org.devio.rn.splashscreen.SplashScreen;

public class MainActivity extends ReactActivity {
  ...
  
  // for react-native-splash-screen
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    SplashScreen.show(this); // here
    super.onCreate(savedInstanceState);
  }
}

 

# components > ActionSheetModal.js

import React from 'react';
import {StyleSheet, Modal, View, Pressable, Text} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';

function ActionSheetModal({visible, onClose, actions}) {
  return (
    <Modal
      visible={visible}
      transparent={true}
      animationType="fade"
      onRequestClose={onClose}>
      <Pressable style={styles.background} onPress={onClose}>
        <View style={styles.whiteBox}>
          {/* TODO: Props로 받아온 actions 배열 사용 */}
          {actions.map(action => (
            <Pressable
              style={styles.actionButton}
              android_ripple={{color: '#eee'}}
              onPress={() => {
                action.onPress();
                onClose();
              }}
              key={action.text}>
              <Icon
                name={action.icon}
                color="#757575"
                size={24}
                style={styles.actionText}
              />
              <Text>{action.text}</Text>
            </Pressable>
          ))}
        </View>
      </Pressable>
    </Modal>
  );
}

const styles = StyleSheet.create({
  background: {
    backgroundColor: 'rgba(0,0,0,0.6)',
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  whiteBox: {
    width: 300,
    backgroundColor: 'white',
    borderRadius: 4,
    elevation: 2,
  },
  actionButton: {
    padding: 16,
    flexDirection: 'row',
    alignItems: 'center',
  },
  icon: {
    marginRight: 8,
  },
  text: {
    fonSize: 16,
  },
});

export default ActionSheetModal;

 

# components > CameraButton.js

import React, {useState} from 'react';
import {
  View,
  Pressable,
  StyleSheet,
  Platform,
  ActionSheetIOS,
  Alert,
} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import Icon from 'react-native-vector-icons/MaterialIcons';
import UploadModeModal from './UploadModeModal';
import {launchImageLibrary, launchCamera} from 'react-native-image-picker';
import {useNavigation} from '@react-navigation/native';
import ActionSheetModal from './ActionSheetModal';

const TABBAR_HEIGHT = 49;

const imagePickerOption = {
  mediaType: 'photo',
  maxWidth: 768,
  maxHeight: 768,
  includeBase64: Platform.OS === 'android',
};

function CameraButton() {
  const insets = useSafeAreaInsets();
  const [modalVisible, setModalVisible] = useState(false);
  const navigation = useNavigation();

  const bottom = Platform.select({
    android: TABBAR_HEIGHT / 2,
    ios: TABBAR_HEIGHT / 2 + insets.bottom - 4,
  });

  const onPickImage = res => {
    if (res.didCancel || !res) {
      return;
    }
    if (res.errorCode) {
      Alert.alert('사용 불가', '해당 기능은 현재 사용 불가능 합니다.');
      return;
    }
    // console.log('[LOG] CameraButton res : ' + JSON.stringify(res));
    navigation.push('Upload', {res});
  };

  const onLaunchCamera = () => {
    launchCamera(imagePickerOption, onPickImage);
  };

  const onLaunchImageLibrary = () => {
    launchImageLibrary(imagePickerOption, onPickImage);
  };

  const onPress = () => {
    if (Platform.OS === 'android') {
      setModalVisible(true);
      return;
    }

    ActionSheetIOS.showActionSheetWithOptions(
      {
        options: ['카메라로 촬영하기', '사진 선택하기', '취소'],
        cancelButtonIndex: 2,
      },
      buttonIndex => {
        if (buttonIndex === 0) {
          // console.log('카메라 촬영');
          onLaunchCamera();
        } else if (buttonIndex === 1) {
          // console.log('사진 선택');
          onLaunchImageLibrary();
        }
      },
    );
  };

  return (
    <>
      <View style={[styles.wrapper, {bottom}]}>
        <Pressable
          android_ripple={{
            color: '#ffffff',
          }}
          style={styles.circle}
          onPress={onPress}>
          <Icon name="camera-alt" color="white" size={24} />
        </Pressable>
      </View>
      {/* <UploadModeModal
        visible={modalVisible}
        onClose={() => setModalVisible(false)}
        onLaunchCamera={onLaunchCamera}
        onLaunchImageLibrary={onLaunchImageLibrary}
      /> */}
      <ActionSheetModal
        visible={modalVisible}
        onClose={() => setModalVisible(false)}
        actions={[
          {
            icon: 'camera-alt',
            text: '카메라로 촬영하기',
            onPress: onLaunchCamera,
          },
          {
            icon: 'photo',
            text: '사진 선택하기',
            onPress: onLaunchImageLibrary,
          },
        ]}
      />
    </>
  );
}

const styles = StyleSheet.create({
  wrapper: {
    zIndex: 5,
    borderRadius: 27,
    height: 54,
    width: 54,
    position: 'absolute',
    left: '50%',
    transform: [
      {
        translateX: -27,
      },
    ],
    ...Platform.select({
      ios: {
        shadowColor: '#4d4d4d',
        shadowOffset: {width: 0, height: 4},
        shadowOpacity: 0.3,
        shadowRadius: 4,
      },
      android: {
        elevation: 5,
        overflow: 'hidden',
      },
    }),
  },
  circle: {
    backgroundColor: '#6200ee',
    borderRadius: 27,
    height: 54,
    width: 54,
    alignItems: 'center',
    justifyContent: 'center',
  },
});

export default CameraButton;

 

# components > PostCard.js

import React, {useMemo} from 'react';
import {View, StyleSheet, Text, Image, Pressable} from 'react-native';
import Avatar from './Avatar';
import {useNavigation, useNavigationState} from '@react-navigation/native';
import {useUserContext} from '../contexts/UserContext';
import Icon from 'react-native-vector-icons/MaterialIcons';
import ActionSheetModal from './ActionSheetModal';
import usePostActions from '../hooks/usePostActions';

function PostCard({user, photoURL, description, createdAt, id}) {
  const date = useMemo(
    () => (createdAt ? new Date(createdAt._seconds * 1000) : new Date()),
    [createdAt],
  );
  const navigation = useNavigation();
  const routeNames = useNavigationState(state => state.routeNames);
  // console.log(routeNames); // ["Feed", "Profile", "Post"] or ["MyProfile", "Post"]

  const {user: me} = useUserContext();
  const isMyPost = me.id === user.id; // PostCard의 Props로 받아온 user.id와 로그인 정보의 user.id 비교

  const onOpenProfile = () => {
    // MyProfile이 존재하는지 확인
    if (routeNames.find(routeName => routeName === 'MyProfile')) {
      navigation.navigate('MyProfile');
    } else {
      navigation.navigate('Profile', {
        userId: user.id,
        displayName: user.displayName,
      });
    }
  };

  const {isSelecting, onPressMore, onClose, actions} = usePostActions({
    id,
    description,
  });

  return (
    <>
      <View style={styles.block}>
        <View style={[styles.head, styles.paddingBlock]}>
          <Pressable style={styles.profile} onPress={onOpenProfile}>
            <Avatar source={user.photoURL && {uri: user.photoURL}} />
            <Text style={styles.displayName}>{user.displayName}</Text>
          </Pressable>
          {isMyPost && (
            <Pressable hitSlop={8} onPress={onPressMore}>
              <Icon name="more-vert" size={20} />
            </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>
      <ActionSheetModal
        visible={isSelecting}
        actions={actions}
        onClose={onClose}
      />
    </>
  );
}

const styles = StyleSheet.create({
  block: {
    paddingTop: 16,
    paddingBottom: 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;

 

# components > Profile.js

import React, {useEffect, useState} from 'react';
import {
  ActivityIndicator,
  FlatList,
  StyleSheet,
  Text,
  View,
  RefreshControl,
} from 'react-native';
import {getUser} from '../lib/users';
import Avatar from './Avatar';
import PostGridItem from './PostGridItem';
import usePosts from '../hooks/usePosts';
import {useUserContext} from '../contexts/UserContext';
import events from '../lib/events';

function Profile({userId}) {
  const [user, setUser] = useState(null);
  const {posts, noMorePost, refreshing, onLoadMore, onRefresh} =
    usePosts(userId);

  const {user: me} = useUserContext();

  const isMyProfile = me.id === userId;

  useEffect(() => {
    getUser(userId).then(setUser);
  }, [userId]);

  // refactoring
  // useEffect(() => {
  //   // 자신의 프로필을 보고 있을 때만 새 포스트 작성 후 새로고침합니다.
  //   if (!isMyProfile) {
  //     return;
  //   }
  //   events.addListener('refresh', onRefresh);
  //   events.addListener('removePost', removePost);

  //   return () => {
  //     events.removeListener('refresh', onRefresh);
  //     events.removeListener('removePost', removePost);
  //   };
  // }, [isMyProfile, onRefresh, removePost]);

  if (!user || !posts) {
    return (
      <ActivityIndicator style={styles.spinner} size={32} color="#6200ee" />
    );
  }

  return (
    <FlatList
      style={styles.block}
      data={posts}
      renderItem={renderItem}
      numColumns={3}
      keyExtractor={item => item.id}
      ListHeaderComponent={
        <View style={styles.userInfo}>
          <Avatar source={user.photoURL && {uri: user.photoURL}} size={128} />
          <Text styles={styles.username}>{user.displayName}</Text>
        </View>
      }
      onEndReached={onLoadMore}
      onEndReachedThreshold={0.25}
      ListFooterComponent={
        !noMorePost && (
          <ActivityIndicator
            style={styles.bottomSpinner}
            size={32}
            color="#6200ee"
          />
        )
      }
      refreshControl={
        <RefreshControl onRefresh={onRefresh} refreshing={refreshing} />
      }
    />
  );
}

const renderItem = ({item}) => <PostGridItem post={item} />;

const styles = StyleSheet.create({
  spinner: {
    flex: 1,
    justifyContent: 'center',
  },
  block: {
    flex: 1,
  },
  userInfo: {
    paddingTop: 80,
    paddingBottom: 64,
    alignItems: 'center',
  },
  username: {
    matginTop: 8,
    fontSize: 24,
    color: '#424242',
  },
  bottomSpineer: {
    height: 128,
  },
});

export default Profile;

 

# hooks > usePostActions.js

import {useState} from 'react';
import {ActionSheetIOS, Platform} from 'react-native';
import {removePost} from '../lib/posts';
import {useNavigation, useRoute} from '@react-navigation/native';
import events from '../lib/events';

export default function usePostAction({id, description}) {
  const [isSelecting, setIsSelecting] = useState(false);
  const navigation = useNavigation();
  const route = useRoute();

  const edit = () => {
    console.log('TODO: edit');
    navigation.navigate('Modify', {
      id,
      description,
    });
  };

  const remove = async () => {
    console.log('TODO: remove');
    await removePost(id);

    // 현재 단일 포스트 조회 화면이라면 뒤로기
    if (route.name === 'Post') {
      navigation.pop();
    }

    // TODO: 홈 및 프로필 화면의 목록 업데이트
    events.emit('removePost', id);
  };

  const onPressMore = () => {
    console.log('[LOG] onPressMore : clicked');

    if (Platform.OS === 'android') {
      setIsSelecting(true);
    } else {
      ActionSheetIOS.showActionSheetWithOptions(
        {
          options: ['설명 수정', '게시물 삭제', '취소'],
          destructiveButtonIndex: 1,
          cancelButtonIndex: 2,
        },
        buttonIndex => {
          if (buttonIndex === 0) {
            edit();
          } else if (buttonIndex === 1) {
            remove();
          }
        },
      );
    }
  };

  const actions = [
    {
      icon: 'edit',
      text: '설명 수정',
      onPress: edit,
    },
    {
      icon: 'delete',
      text: '게시물 삭제',
      onPress: remove,
    },
  ];

  const onClose = () => {
    setIsSelecting(false);
  };

  return {
    isSelecting,
    onPressMore,
    onClose,
    actions,
  };
}

 

# hooks > usePosts.js

import {useEffect, useState, useCallback} from 'react';
import {getNewerPosts, getOlderPosts, getPosts, PAGE_SIZE} from '../lib/posts';

// refactoring
import {useUserContext} from '../contexts/UserContext';
import usePostsEventEffect from './usePostsEventEffect';
import {getEnforcing} from 'react-native/Libraries/TurboModule/TurboModuleRegistry';

//userId 는 게시물의 정보, user.id는 로그인한 사용자의 정보
export default function usePosts(userId) {
  const [posts, setPosts] = useState(null);
  const [noMorePost, setNoMorePost] = useState(false);
  const [refreshing, setRefreshing] = useState(false);
  const {user} = useUserContext();

  const onLoadMore = async () => {
    if (noMorePost || !posts || posts.length < PAGE_SIZE) {
      return;
    }

    const lastPost = posts[posts.length - 1];
    const olderPosts = await getOlderPosts(lastPost.id, userId);

    if (olderPosts.length < PAGE_SIZE) {
      setNoMorePost(true);
    }
    setPosts(posts.concat(olderPosts));
  };

  const onRefresh = useCallback(async () => {
    if (!posts || posts.length === 0 || refreshing) {
      return;
    }
    const firstPost = posts[0];
    setRefreshing(true);

    // firestore에 기존 최상단 포스트가 삭제 되었을 때, =======================
    let dbPosts = await getPosts();
    let localPosts = posts;
    console.log('[LOG] dbPosts[0].id : ', dbPosts[0].id);
    console.log('[Log] localPosts[0].id : ', localPosts[0].id);
    if (dbPosts !== localPosts) {
      setPosts(dbPosts);
      setRefreshing(false);
      return;
    }
    // ===============================================================

    const newerPosts = await getNewerPosts(firstPost.id, userId);
    setRefreshing(false);
    if (newerPosts.length === 0) {
      return;
    }
    setPosts(newerPosts.concat(posts));
  }, [posts, userId, refreshing]);

  useEffect(() => {
    getPosts({userId}).then(_posts => {
      setPosts(_posts);
      if (_posts.length < PAGE_SIZE) {
        setNoMorePost(true);
      }
    });
  }, [userId]);

  const removePost = useCallback(
    postId => {
      setPosts(posts.filter(post => post.id !== postId));
      console.log('[LOG] usePosts > removePost > post.id :', postId);
    },
    [posts],
  );

  // 게시물 내용 수정
  const updatePost = useCallback(
    ({postId, description}) => {
      // id가 일치하는 포스트를 찾아서 description 변경
      const nextPosts = posts.map(post =>
        post.id === postId
          ? {
              ...post,
              description,
            }
          : post,
      );
      setPosts(nextPosts);
    },
    [posts],
  );

  usePostsEventEffect({
    refresh: onRefresh,
    removePost,
    enabled: !userId || userId === user.id,
    updatePost,
  });

  // return {posts, noMorePost, refreshing, onLoadMore, onRefresh, removePost}; // refactoring
  return {posts, noMorePost, refreshing, onLoadMore, onRefresh};
}

 

# hooks > usePostsEventEffect.js

// for refactoring
import {useEffect} from 'react';
import events from '../lib/events';

export default function usePostsEventEffect({
  refresh,
  removePost,
  updatePost,
  enabled,
}) {
  useEffect(() => {
    if (!enabled) {
      return;
    }
    events.addListener('refresh', refresh);
    events.addListener('removePost', removePost);
    events.addListener('updatePost', updatePost);

    return () => {
      events.removeListener('refresh', refresh);
      events.removeListener('removePost', removePost);
      events.removeListener('updatePost', updatePost);
    };
  }, [refresh, removePost, updatePost, enabled]);
}

 

# ios > ... > AppDelegate.m

...
#import <RNSplashScreen.h>

...

  if ([FIRApp defaultApp] == nil) {
  ...
  
  [RNSplashScreen show];
  return YES;
}

...

 

# lib > events.js

import EventEmitter3 from 'eventemitter3';

const events = new EventEmitter3();

export default events;

 

# 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 = 12;

// 포스트 불러오기
export async function getPosts({userId, mode, id} = {}) {
  let query = postsCollection.orderBy('createdAt', 'desc').limit(PAGE_SIZE);

  if (userId) {
    query = query.where('user.id', '==', userId);
  }

  if (id) {
    const cursorDoc = await postsCollection.doc(id).get();
    query =
      mode === 'older'
        ? query.startAfter(cursorDoc) // 이전 포스트 풀러오기 (특정 포스트의 id 값 이전에 작성한 포스트를 불러옴)
        : query.endBefore(cursorDoc); // 최신 포스트 불러오기 (특정 포스트의 id 값 이후에 작성한 포스트를 불러옴)
  }

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

// 이전 포스트 불러오기
export async function getOlderPosts(id, userId) {
  return getPosts({
    id,
    mode: 'older',
    userId,
  });
}

// 최신 포스트 불러오기
export async function getNewerPosts(id, userId) {
  return getPosts({
    id,
    mode: 'newer',
    userId,
  });
}

// 포스트 삭제
export function removePost(id) {
  return postsCollection.doc(id).delete();
}

// 포스트 수정
export function updatePost({id, description}) {
  return postsCollection.doc(id).update({
    description,
  });
}

 

# package.json

{
  "name": "PublicGalleryBocoder",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "android": "react-native run-android",
    "ios": "react-native run-ios",
    "start": "react-native start",
    "test": "jest",
    "lint": "eslint ."
  },
  "dependencies": {
    "@react-native-firebase/app": "^14.2.2",
    "@react-native-firebase/auth": "^14.2.2",
    "@react-native-firebase/firestore": "^14.2.2",
    "@react-native-firebase/storage": "^14.2.2",
    "@react-navigation/bottom-tabs": "^6.0.9",
    "@react-navigation/native": "^6.0.6",
    "@react-navigation/native-stack": "^6.2.5",
    "eventemitter3": "^4.0.7",
    "react": "17.0.2",
    "react-native": "0.66.4",
    "react-native-get-random-values": "^1.7.2",
    "react-native-image-picker": "^4.7.3",
    "react-native-safe-area-context": "^3.3.2",
    "react-native-screens": "^3.10.2",
    "react-native-splash-screen": "^3.3.0",
    "react-native-vector-icons": "^9.0.0",
    "uuid": "^8.3.2"
  },
  "devDependencies": {
    "@babel/core": "^7.16.7",
    "@babel/runtime": "^7.16.7",
    "@react-native-community/eslint-config": "^3.0.1",
    "babel-jest": "^27.4.6",
    "eslint": "^8.7.0",
    "jest": "^27.4.7",
    "metro-react-native-babel-preset": "^0.66.2",
    "react-test-renderer": "17.0.2"
  },
  "jest": {
    "preset": "react-native"
  }
}

 

# screens > FeedScreen.js

import React, {useEffect} from 'react';
import {
  ActivityIndicator,
  FlatList,
  RefreshControl,
  StyleSheet,
} from 'react-native';
import PostCard from '../components/PostCard';
import usePosts from '../hooks/usePosts';
import events from '../lib/events';
import SplashScreen from 'react-native-splash-screen';

function FeedScreen() {
  const {posts, noMorePost, refreshing, onLoadMore, onRefresh} = usePosts();

  const postsReady = posts !== null;
  useEffect(() => {
    if (postsReady) {
      SplashScreen.hide();
    }
  }, [postsReady]);

  // refactoring;
  // useEffect(() => {
  //   events.addListener('refresh', onRefresh);
  //   events.addListener('removePost', removePost);
  //   return () => {
  //     events.removeListener('refresh', onRefresh);
  //     events.removeListener('removePost', removePost);
  //   };
  // }, [onRefresh, removePost]);

  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={
        <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 > ModifyScreen.js

import {useNavigation, useRoute} from '@react-navigation/native';
import React, {useState, useEffect, useCallback} from 'react';
import {
  StyleSheet,
  TextInput,
  Platform,
  KeyboardAvoidingView,
} from 'react-native';
import IconRightButton from '../components/IconRightButton';
import {updatePost} from '../lib/posts';
import events from '../lib/events';

function ModifyScreen() {
  const navigation = useNavigation();
  const {params} = useRoute();
  // 라우트 파라미터의 description을 초기값으로 사용
  const [description, setDescription] = useState(params.description);
  console.log('Modify Screen params : ' + JSON.stringify(params));

  const onSubmit = useCallback(async () => {
    // TODO: 포스트 수정
    await updatePost({
      id: params.id,
      description,
    });

    // TODO: 포스트 및 포스트 목록 업데이트
    events.emit('updatePost', {
      postId: params.id,
      description,
    });

    navigation.pop();
  }, [navigation, params.id, description]);

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

  return (
    <KeyboardAvoidingView
      behavior={Platform.select({ios: 'height'})}
      style={styles.block}
      keyboardVerticalOffset={Platform.select({
        ios: 88,
      })}>
      <TextInput
        style={styles.input}
        multiline={true}
        placeholder="이 사진에 대한 설명을 입력하세요..."
        textAlignVertical="top"
        value={description}
        onChangeText={setDescription}
      />
    </KeyboardAvoidingView>
  );
}

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

export default ModifyScreen;

 

# screens > MyProfileScreen.js

import {useNavigation} from '@react-navigation/native';
import React from 'react';
import {useEffect} from 'react';

import Profile from '../components/Profile';
import {useUserContext} from '../contexts/UserContext';
import IconRightButton from '../components/IconRightButton';

function MyProfileScreen() {
  const {user} = useUserContext();
  const navigation = useNavigation();

  useEffect(() => {
    navigation.setOptions(
      {
        title: user.displayName,
        headerRight: () => (
          <IconRightButton
            name="settings"
            onPress={() => navigation.push('Setting')}
          />
        ),
      },
      [navigation, user],
    );
  }, [navigation, user]);
  return <Profile userId={user.id} />;
}

export default MyProfileScreen;

 

# screens > PostScreen.js

import {useNavigation, useRoute} from '@react-navigation/native';
import React from 'react';
import {useEffect} from 'react';
import {ScrollView, StyleSheet} from 'react-native';
import PostCard from '../components/PostCard';
import events from '../lib/events';

function PostScreen() {
  const route = useRoute();
  const navigation = useNavigation();
  const {post} = route.params;

  useEffect(() => {
    const handler = ({description}) => {
      navigation.setParams({post: {...post, description}});
    };
    events.addListener('updatePost', handler);
    return () => {
      events.removeListener('updatePost', handler);
    };
  }, [post, navigation]);

  return (
    <ScrollView contentContainerStyle={StyleSheet.contentContainer}>
      <PostCard
        user={post.user}
        photoURL={post.photoURL}
        description={post.description}
        createdAt={post.createdAt}
        id={post.id}
      />
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  block: {flex: 1},
  contentContainer: {
    paddingBottom: 40,
  },
});

export default PostScreen;

 

# screens > RootStack.js

import React, {useEffect} from 'react';
import {createNativeStackNavigator} from '@react-navigation/native-stack';
import SignInScreen from './SignInScreen';
import WelcomeScreen from './WelcomeScreen';
import {useUserContext} from '../contexts/UserContext';
import MainTab from './MainTab';
import {getUser} from '../lib/users';
import {subscribeAuth} from '../lib/auth';
import UploadScreen from './UploadScreen';
import ModifyScreen from '../screens/ModifyScreen';
import SettingScreen from './SettingScreen';
import SplashScreen from 'react-native-splash-screen';

const Stack = createNativeStackNavigator();

function RootStack() {
  const {user, setUser} = useUserContext();

  useEffect(() => {
    // 컴포넌트 첫 로딩 시 로그인 상태를 확인하고 UserContext에 적용

    const unsubscribe = subscribeAuth(async currentUser => {
      // 여기에 등록한 함수는 사용자 정보가 바뀔 때마다 호출되는데
      // 처음 호출될 때 바로 unsubcrib해 한 번 호출된 후에는 더 이상 호출되지 않게 설정
      unsubscribe(); // 재귀호출, subscribeAuth 에서 현재 상태를 파라미터로 받아옴

      console.log('[LOG] currentUser : ', currentUser);

      if (!currentUser) {
        SplashScreen.hide();
        return;
      }
      const profile = await getUser(currentUser.uid);
      if (!profile) {
        return;
      }
      setUser(profile);
    });
  }, [setUser]);

  return (
    <Stack.Navigator>
      {user ? (
        <>
          <Stack.Screen
            name="MainTab"
            component={MainTab}
            options={{headerShown: false}}
          />
          <Stack.Screen
            name="Upload"
            component={UploadScreen}
            options={{title: '새 게시물', headerBackTitle: '뒤로가기'}}
          />
          <Stack.Screen
            name="Modify"
            component={ModifyScreen}
            options={{title: '설명 수정', headerBackTitle: '뒤로가기'}}
          />
          <Stack.Screen
            name="Setting"
            component={SettingScreen}
            options={{title: '설정', headerBackTitle: '뒤로가기'}}
          />
        </>
      ) : (
        <>
          <Stack.Screen
            name="SignIn"
            component={SignInScreen}
            options={{headerShown: false}}
          />
          <Stack.Screen
            name="Welcome"
            component={WelcomeScreen}
            options={{headerShown: false}}
          />
        </>
      )}
    </Stack.Navigator>
  );
}

export default RootStack;

 

# screens > SettingScreen.js

import React from 'react';
import {StyleSheet, View, Text, Pressable, Platform} from 'react-native';
import {useUserContext} from '../contexts/UserContext';
import {signOut} from '../lib/auth';

function SettingScreen() {
  const {setUser} = useUserContext();

  const onLogout = async () => {
    await signOut();
    setUser(null);
  };

  return (
    <View style={styles.block}>
      <Pressable
        onPress={onLogout}
        style={({pressed}) => [
          styles.item,
          pressed && Platform.select({ios: {opacity: 0.5}}),
        ]}
        android_ripple={{
          color: '#eee',
        }}>
        <Text>로그아웃</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
    paddingTop: 32,
  },
  item: {
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: '#eeeeee',
    backgroundColor: 'white',
    paddingVertical: 16,
    paddingHorizontal: 12,
  },
  itemText: {
    fontSize: 16,
  },
});

export default SettingScreen;

 

# 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';
import events from '../lib/events';

function UploadScreen() {
  const route = useRoute();
  // if (route) console.log('[LOG] UploadScreen 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});

    events.emit('refresh');

    // 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
반응형