React Native/React Native_study

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

bocoder
728x90
반응형

@ List

* 9.1 탭 화면 구현하기

* 9.2 로그인 상태 유지하기

* 9.3 포스트 작성 기능 구현하기

** 9.3.1 탭 중앙에 버튼 만들기

** 9.3.2 업로드할 사진 선택 또는 카메라 촬영하기

** 9.3.3 포스트 작성 화면 만들기

 

@ Note

1. 사용자 정보 인증

 - 앱 종료 시에도 사용자의 로그인 상태를 유지하기 위해 일반적으로 AsyncStorage에 인증 정보를 저장하는 방법 사용함

 - 하지만 Firebase로 인증을 구현했기 때문에, Firebase 의 auth().onAuthStateChanged 함수를 통해 인증 관련 작업 가능

 

2. 업로드할 사진 선택창 구현

 - iOS 는 사용자에게 선택지를 줄 때 주로 ActionSheetIOS를 사용하여 UI 구현함

 - android 는 모달을 통해 구현하지만, @expo/react-native-action-sheet 를 사용해서 iOS 와 같은 UI 구현 가능

 

3. TextInput 컴포넌트를 키보드가 가릴 경우

 - KeyboardAvoidingView를 사용하여 구현할 수 있지만, 레이아웃이 복잡할 경우 Animated를 사용해 직접 처리하는 것이 자연스러움

 - 이벤트를 등록하고 해제하는 작업을 컴포넌트의 useEffect에서 해주고, 사라질 떄 이벤트를 해제해야 하므로 deps 배열은 비워둠

 - delay 값을 50ms 설정한 이유는, 키보드가 나타나는 시간과 겹치지 않게 하기 위함

 

4. StyleSheet 활용

 - marginRight 값을 -8 과 같은 음수로 설정 시, 우측에 8dp 만큼 여백이 생기는 게 아니라, 8dp 만큼 컴포넌트가 이동하게 됨

 

5. useCallback 사용

 - onSubmit 함수는 useEffect 내부에서 내비게이션 설정으로 headerRight 를 설정하는 과정에서 사용되므로, useCallback을 사용함

 - 이 상황에서 필수는 아니지만, 사용하지 않으면 키보드가 열리고 닫힐 때마다 useEffect가 무의미하게 실행되어 리소스가 낭비됨

* reference : https://www.daleseo.com/react-hooks-use-callback/

 

@ Result

01234
iOS / android - 탭 중앙 카메라 버튼 및 선택창 구현

 

iOS / android - InputText 및 우측 상단 전송 아이콘 구현

 

iOS / android - Animated 를 활용하여 keyboard 창에 따른 화면 제어

 

@ Git

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

 

# Source tree

* screens > _MainTab.js : 기존 메인 화면 참고용으로 주석 처리

 

# components > CameraButton.js

import React, {useState} from 'react';
import {
  View,
  Pressable,
  StyleSheet,
  Platform,
  ActionSheetIOS,
} 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';

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.didCancle || !res) {
      return;
    }
    console.log(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}
      />
    </>
  );
}

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 > IconRightButton.js

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

function IconRightButton({name, color, onPress}) {
  return (
    <View style={styles.block}>
      <Pressable
        style={({pressed}) => [
          styles.circle,
          Platform.OS === 'ios' &&
            pressed && {
              opacity: 0.3,
            },
        ]}
        onPress={onPress}
        android_ripple={{color: '#eee'}}>
        <Icon name={name} color={color} size={24} />
      </Pressable>
    </View>
  );
}

IconRightButton.defaultProps = {
  color: '#6200ee',
};

const styles = StyleSheet.create({
  block: {
    marginRight: -8,
    borderRadius: 24,
    overflow: 'hidden',
  },
  circle: {
    height: 48,
    width: 48,
    alignItems: 'center',
    justifyContent: 'center',
  },
});

export default IconRightButton;

 

# components > UploadModeModal.js

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

function UploadModeModal({
  visible,
  onClose,
  onLaunchCamera,
  onLaunchImageLibrary,
}) {
  return (
    <Modal
      visible={visible}
      transparent={true}
      animationType="fade"
      onRequestClose={onClose}>
      <Pressable style={styles.background} onPress={onClose}>
        <View style={styles.whiteBox}>
          <Pressable
            style={styles.actionButton}
            android_ripple={{color: '#eee'}}
            onPress={() => {
              onLaunchCamera();
              onClose();
            }}>
            <Icon
              name="camera-alt"
              color="#757575"
              size={24}
              style={styles.icon}
            />
            <Text style={styles.actionText}>카메라로 촬영하기</Text>
          </Pressable>
          <Pressable
            style={styles.actionButton}
            android_ripple={{color: '#eee'}}
            onPress={() => {
              onLaunchImageLibrary();
              onClose();
            }}>
            <Icon name="photo" color="#757575" size={24} style={styles.icon} />
            <Text style={styles.actionText}>사진 선택하기</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 UploadModeModal;

 

# screens > FeedScreen.js

import React from 'react';
import {View} from 'react-native';

function FeedScreen() {
  return <View />;
}

export default FeedScreen;

 

# screens > HomeStack.js

import React from 'react';
import {createNativeStackNavigator} from '@react-navigation/native-stack';
import FeedScreen from './FeedScreen';

const Stack = createNativeStackNavigator();

function HomeStack() {
  return (
    <Stack.Navigator>
      <Stack.Screen name="Feed" component={FeedScreen} />
    </Stack.Navigator>
  );
}

export default HomeStack;

 

# screens > MainTab.js

import React from 'react';
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import HomeStack from './HomeStack';
import MyProfileStack from './MyProfileStack';
import Icon from 'react-native-vector-icons/MaterialIcons';
import CameraButton from '../components/CameraButton';
import {StyleSheet, View} from 'react-native';

const Tab = createBottomTabNavigator();

function MainTab() {
  return (
    <>
      <View style={styles.block}>
        <Tab.Navigator
          screenOptions={{
            headerShown: false,
            tabBarShowLabel: false,
            tabBarActiveTintColor: '#6200ee',
          }}>
          <Tab.Screen
            name="HomeStack"
            component={HomeStack}
            options={{
              tabBarIcon: ({color}) => (
                <Icon name="home" size={24} color={color} />
              ),
            }}
          />
          <Tab.Screen
            name="MyProfileStack"
            component={MyProfileStack}
            options={{
              tabBarIcon: ({color}) => (
                <Icon name="person" size={24} color={color} />
              ),
            }}
          />
        </Tab.Navigator>
      </View>
      <CameraButton />
    </>
  );
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
    zIndex: 0,
  },
});

export default MainTab;

 

# screens > MyProfileScreen.js

import React from 'react';
import {View} from 'react-native';

function MyProfileScreen() {
  return <View />;
}

export default MyProfileScreen;

 

# screens > MyProfileStack.js

import React from 'react';
import {createNativeStackNavigator} from '@react-navigation/native-stack';
import MyProfileScreen from './MyProfileScreen';

const Stack = createNativeStackNavigator();

function MyProfileStack() {
  return (
    <Stack.Navigator>
      <Stack.Screen name="Feed" component={MyProfileScreen} />
    </Stack.Navigator>
  );
}

export default MyProfileStack;

 

# 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';

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) {
        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="SignIn"
            component={SignInScreen}
            options={{headerShown: false}}
          />
          <Stack.Screen
            name="Welcome"
            component={WelcomeScreen}
            options={{headerShown: false}}
          />
        </>
      )}
    </Stack.Navigator>
  );
}

export default RootStack;

 

# screens > UploadScreen.js

import React, {useEffect, useRef, useState, useCallback} from 'react';
import {
  StyleSheet,
  TextInput,
  View,
  Animated,
  Keyboard,
  useWindowDimensions,
} from 'react-native';
import {useNavigation, useRoute} from '@react-navigation/native';
import IconRightButton from '../components/IconRightButton';

function UploadScreen() {
  const route = useRoute();
  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 onSubmit = useCallback(() => {
    // TODO: 포스트 작성 로직 구현
  }, []);

  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 (
    <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>
  );
}

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

export default UploadScreen;

 

# screens > _MainTab.js (기존 메인 화면 참고용으로 주석 처리)

// import React from 'react';
// import {Image, StyleSheet, Text, View} from 'react-native';
// import {useUserContext} from '../contexts/UserContext';

// function MainTab() {
//   const {user} = useUserContext();
//   return (
//     <View style={styles.block}>
//       {user.photoURL && (
//         <Image
//           source={{uri: user.photoURL}}
//           style={{width: 128, height: 128, marginBottom: 16}}
//           resizeMode="cover"
//         />
//       )}
//       <Text style={styles.text}>Hello, {user.displayName}</Text>
//     </View>
//   );
// }

// const styles = StyleSheet.create({
//   block: {
//     flex: 1,
//     alignItems: 'center',
//     justifyContent: 'center',
//   },
//   text: {
//     fontSize: 24,
//   },
// });

// export default MainTab;

 

 

728x90
반응형