@ 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
@ 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;
'React Native > React Native_study' 카테고리의 다른 글
[리액트 네이티브를 다루는 기술 #22] 9장 Firebase로 사진 공유 앱 만들기2 (p .529 ~ 558) (0) | 2022.03.29 |
---|---|
[리액트 네이티브를 다루는 기술 #21] 9장 Firebase로 사진 공유 앱 만들기2 (p .511 ~ 528) (0) | 2022.03.23 |
[리액트 네이티브를 다루는 기술 #19] 8장 Firebase로 사진 공유 앱 만들기1 (p .466 ~ 478) (0) | 2022.03.16 |
[리액트 네이티브를 다루는 기술 #18] 8장 Firebase로 사진 공유 앱 만들기1 (p .453 ~ 465) (0) | 2022.02.18 |
[리액트 네이티브를 다루는 기술 #17] 8장 Firebase로 사진 공유 앱 만들기1 (p .445 ~ 452) (0) | 2022.02.15 |