9.5 메인화면
대부분의 애플리케이션에서 사용자의 데이터 혹은 서비스의 데이터를 이용하려면 데이터에 접 근할 수 있는 유효한 사용자라는 것을 증명해야 합니다. 인 증 후에는 서비스를 이용할 수 있는 화면이 렌더링되고, 로그아웃 등으로 인증 상태를 해제하 면 다시 인증을 위한 화면으로 이동합니다.
이제 인증 후에 렌더링 되어야 하는 화면 들을 만들어봅시다.
여기서 사용할 화면 중 인증 후에 렌더링되어야 하는 화면은 총 4가지인데, 채널과 관련된 3개 의 화면과 사용자의 정보를 보여주는 프로필 화면이 있습니다. 채널과 관련된 화면은 스택 내 비게이션을 이용하여 구성하고, 채널 관련 화면과 프로필 화면은 탭 내비게이션을 이용해 화면 을 이동할 수 있도록 구성하겠습니다.
1) 내비게이션
먼저 인증에 성공하면 AuthStack 내비게이션 대신 렌더링할 MainStack 내비게이션의 화면 들을 만들어 보겠습니다.
MainStack 내비게이션은 채널 목록 화면과 프로필 화면으로 구성된 MainTab 내비게이션을 첫 번째 화면= 가지며 그 외에 채널 생성 화면과 채널 화면으로 구 성됩니다.
-mainStack 내비게이션
먼저 screens에 채널 화면으로 이동하는 버튼을 가진 채널 생성화면ChannelCreation과 채널 화면Channel을 만듭니다. screens/index에서 모두 연결하고, navigations 폴더에 MainStack.js 파일을 생성하여 MainStack내비게이션을 만듭니다.
마지막으로 navigations 폴 더의 index.js 파일을 수정해서 MainStack 내비게이션이 잘 동작하는지 확인합니다.
import React from 'react';
import styled from 'styled-components/native';
import { Text, Button } from 'react-native';
const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.background};
`;
const Channelcreation = ({ navigation }) => {
return (
<Container>
<Text style={{ fontSize: 24 }}>Channel Creation</Text>
<Button title="Channel" onPress={() => navigation.navigate('Channel')} />
</Container>
);
};
export default Channelcreation;
import React from 'react';
import styled from 'styled-components/native';
import { Text } from 'react-native';
const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.background};
`;
const Channel = () => {
return (
<Container>
<Text style={{ fontSize: 24 }}>Channel</Text>
</Container>
);
};
export default Channel;
import Login from './Login';
import Signup from './Signup';
import Channel from './Channel';
import ChannelCreation from './Channelcreation';
export {Login, Signup, Channel, ChannelCreation};
import React, { useContext } from 'react';
import { ThemeContext } from 'styled-components/native';
import { createStackNavigator } from '@react-navigation/stack';
import { Channel, ChannelCreation } from '../screens';
const Stack = createStackNavigator();
const MainStack = () => {
const theme = useContext(ThemeContext);
return (
<Stack.Navigator
screenOptions={{
headerTitleAlign: 'center',
headerTintColor: theme.headerTintColor,
cardStyle: { backgroundColor: theme.background },
headerBackTitleVisible: false,
}}
>
<Stack.Screen name="Channel Creation" component={ChannelCreation} />
<Stack.Screen name="Channel" component={Channel} />
</Stack.Navigator>
);
};
export default MainStack;
헤더의 타이틀은 중앙으로 정렬하고, 버튼의 타이틀은 렌더링되지 않도록 설정했습니다. headerTintColor는 AuthStack 내비게이션과 동일하게 설정했습니다.
import React, { useContext } from 'react';
import AuthStack from './AuthStack';
import { Spinner } from '../components';
import { ProgressContext } from '../contexts';
import MainStack from './MainStack';
const Navigation = () => {
const { inProgress } = useContext(ProgressContext);
return (
<>
<MainStack/>
{inProgress && <Spinner />}
</>
);
};
export default Navigation;
-mainTab 내비게이션
MainStack 내비게이션에서 MainTab을 탭 화면으로 사용하고, 다른 화면으로 이동할 수 있도록 설정합니다.
import React from 'react';
import styled from 'styled-components/native';
import { Text, Button } from 'react-native';
const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.background};
`;
const ChannelList = ({ navigation }) => {
return (
<Container>
<Text style={{ fontSize: 24 }}>Channel List</Text>
<Button
title="Channel Creation"
onPress={() => navigation.navigate('Channel Creation')}
/>
</Container>
);
}
export default ChannelList;
import React from 'react';
import styled from 'styled-components/native';
import { Text } from 'react-native';
const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.background};
`;
const Profile = () => {
return (
<Container>
<Text style={{ fontSize: 24 }}>Profile</Text>
</Container>
);
}
export default Profile;
import Login from './Login';
import Signup from './Signup';
import Channel from './Channel';
import ChannelCreation from './ChannelCreation';
import ChannelList from './ChannelList';
import Profile from './Profile';
export {Login, Signup, Channel, ChannelCreation, ChannelList, Profile};
import React, { useContext } from 'react';
import { ThemeContext } from 'styled-components/native';
import { createStackNavigator } from '@react-navigation/stack';
import { Channel, ChannelCreation } from '../screens';
import MainTab from './MainTab';
const Stack = createStackNavigator();
const MainStack = () => {
const theme = useContext(ThemeContext);
return (
<Stack.Navigator
initialRouteName="Main"
screenOptions={{
headerTitleAlign: 'center',
headerTintColor: theme.headerTintColor,
cardStyle: { backgroundColor: theme.background },
headerBackTitleVisible: false,
}}
>
<Stack.Screen name="Main" component={MainTab} />
<Stack.Screen name="Channel Creation" component={ChannelCreation} />
<Stack.Screen name="Channel" component={Channel} />
</Stack.Navigator>
);
};
export default MainStack;
import React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Profile, ChannelList } from '../screens';
const Tab = createBottomTabNavigator();
const MainTab = () => {
return (
<Tab.Navigator>
<Tab.Screen name="Channel List" component={ChannelList} />
<Tab.Screen name="Profile" component={Profile} />
</Tab.Navigator>
);
};
export default MainTab;
2) 인증과 화면 전환
인증 상태에 따라 MainStack 내비게이션과 AuthStack 내비게이션을 렌더링해봅시다.
애플리케이션이 시작되면 AuthStack 내비게이션이 렌더링되고, 로그인 혹은 회원가입을 통해 인증에 성공하면 MainStack 내비게이션이 렌더링되어야 합니 다. 인증 후 로그아웃을 통해 인증 상태가 사라지면 다시 AuthStack 내비게이션이 렌더링되어 야합니다.
인증상태에 대한 UserContext를 만들어 전역적으로 상태를 관리해봅시다.
-인증상태
UserContext를 만들고 사용자의 이메일과 uid를 가진 user 객체와 user 객체를 수정할 수 있는 dispatch 함수를 value로 전달하는 UserProvider 컴포넌트를 만들고 index에 등록한후, App.js에서 UserProvider를 불러 적용합니다.
UserProvider는 반드시 최상위에서 모든 값을 감싸야 합니다.
import React, { useState, createContext } from 'react';
const UserContext = createContext({
user: { email: null, uid: null },
dispatch: () => {},
});
const UserProvider = ({ children }) => {
const [user, setUser] = useState({});
const dispatch = ({ email, uid }) => {
setUser({ email, uid });
};
const value = { user, dispatch };
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
};
export { UserContext, UserProvider };
import { ProgressContext, ProgressProvider } from './Progress';
import { UserContext, UserProvider } from './User';
export { ProgressContext, ProgressProvider, UserContext, UserProvider };
import { ProgressProvider, UserProvider } from './contexts';
//...
return isReady ? (
<ThemeProvider theme={theme}>
<UserProvider>
<ProgressProvider>
<StatusBar barStyle="dark-content" />
<Navigation />
</ProgressProvider>
</UserProvider>
</ThemeProvider>
) : (
-인증에 따라 다른stack 불러오기
이제 UserContext의 user 상태에 따라 인증 여부가 정해집니다. navigations 폴더의 index.js 파일을 수정하면 인증 여부에 따라 MainStack 내비게이션 혹은 AuthStack 내비게이션이 렌더링됩니다.
import React, { useContext } from 'react';
import AuthStack from './AuthStack';
import { Spinner } from '../components';
import { ProgressContext, UserContext } from '../contexts';
import MainStack from './MainStack';
const Navigation = () => {
const { inProgress } = useContext(ProgressContext);
const { user } = useContext(UserContext);
return (
<>
{user?.uid && user?.email ? <MainStack /> : <AuthStack />}
{inProgress && <Spinner />}
</>
);
};
export default Navigation;
-인증상태 변경하기
로그인하거나 회원가입 화면에서 사용자 생성에 성공했을 때 반환되는 사용자 정보 를 이용해서 UserContext의 user 상태가 변경되도록 dispatch 힘수를 호출했습니다.
이렇게 렌더링되는 내비게이션 전체를 변경하면, 스와이프 혹은 기기에 있는 뒤로 가기 버튼을 클릭해도 이전 내비게이션으로 돌아가지 않습니다
import React, {useState,useRef, useEffect, useContext} from 'react';
import { ProgressContext, UserContext } from '../contexts';
import styled from 'styled-components/native';
import {Text} from 'react-native';
import {Image, Input, Button} from '../components/index';
import {images} from '../utils/images';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import { validateEmail, removeWhitespace} from '../utils/common';
import {useSafeAreaInsets} from "react-native-safe-area-context";
import {Alert} from 'react-native';
import {signin} from '../utils/firebase';
const Container = styled.View`
flex: 1;
justify-content: center;
align-items: center;
background-color: ${({theme}) => theme.background};
padding: 0 20px;
padding-top: ${({ insets: { top }}) => top}px;
padding-bottom: ${({insets : { bottom } }) => bottom}px;
`;
const ErrorText = styled.Text`
align-items: flex-start;
width: 100%;
height: 20px;
line-height: 20px;
color: ${({theme}) => theme.errorText};
`;
const Login = ({navigation}) => {
const { dispatch } = useContext(UserContext);
const { spinner } = useContext(ProgressContext);
const insets = useSafeAreaInsets();
const [email, setEmail] =useState('');
const [password, setPassword] = useState('');
const passwordRef = useRef();
const [errorMessage, setErrorMessage] = useState('');
const [disabled, setDisabled] = useState(true);
useEffect(()=>{
setDisabled(!(email && password && !errorMessage));
}, [email, password, errorMessage]);
const _handleEmailChange = email => {
const changedEmail = removeWhitespace(email);
setEmail(changedEmail);
setErrorMessage(
validateEmail(changedEmail) ? '' : 'Please verify your email'
);
};
const _handlePasswordChange = password => {
setPassword(removeWhitespace(password));
};
const _handleLoginButtonPress = async() => {
try{
spinner.start();
const user = await signin({email, password});
dispatch(user);
}catch(e){
Alert.alert('login error', e.message);
}finally{
spinner.stop();
}
};
return (
<KeyboardAwareScrollView
contentContainerStyle={{ flex: 1}}
extraScrollHeight={20}
>
<Container insets={insets}>
<Image url={images.logo} imageStyle={{ borderRadius: 8 }} />
<Input
label="Email"
value={email}
onChangeText={_handleEmailChange}
onSubmitEditing={() => {passwordRef.current.focus()}}
placeholder="Email"
returnKeyType="next"
/>
<Input
ref={passwordRef}
label="Password"
value={password}
onChangeText={_handlePasswordChange}
onSubmitEditing={_handleLoginButtonPress}
placeholder="Password"
returnKeyType="done"
isPassword
/>
<ErrorText>{errorMessage}</ErrorText>
<Button title="Login" onPress={_handleLoginButtonPress} disabled={disabled}/>
<Button
title="Sign up with email"
onPress={() => navigation.navigate('Signup')}
isFilled={false}
/>
</Container>
</KeyboardAwareScrollView>
)
}
export default Login
import React, { useState, useRef, useEffect, useContext } from 'react';
import { ProgressContext,UserContext } from '../contexts'
import styled from 'styled-components/native';
import { Image, Input, Button } from '../components';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import { validateEmail, removeWhitespace } from '../utils/common';
import {images} from '../utils/images';
import {Alert} from 'react-native';
import { signup } from '../utils/firebase';
const Container = styled.View`
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.background};
padding: 40px 20px;
`;
const ErrorText = styled.Text`
align-items: flex-start;
width: 100%;
height: 20px;
margin-bottom: 10px;
line-height: 20px;
color: ${({ theme }) => theme.errorText};
`;
const Signup = () => {
const { dispatch } = useContext(UserContext);
const { spinner } = useContext(ProgressContext);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [disabled, setDisabled] = useState(true);
const [photoUrl, setPhotoUrl] = useState(images.photo);
const emailRef = useRef();
const passwordRef = useRef();
const passwordConfirmRef = useRef();
const didMountRef = useRef();
useEffect(() => {
if (didMountRef.current) {
let _errorMessage = '';
if (!name) {
_errorMessage = 'Please enter your name.';
} else if (!validateEmail(email)) {
_errorMessage = 'Please verify your email.';
} else if (password.length < 6) {
_errorMessage = 'The password must contain at least 6 characters.';
} else if (password !== passwordConfirm) {
_errorMessage = 'Passwords need to match.';
} else {
_errorMessage = '';
}
setErrorMessage(_errorMessage);
} else {
didMountRef.current = true;
}
}, [name, email, password, passwordConfirm]);
useEffect(() => {
setDisabled(!(name && email && password && passwordConfirm && !errorMessage));
}, [name, email, password, passwordConfirm, errorMessage]);
const _handleSignupButtonPress = async() => {
try {
spinner.start();
const user = await signup({ email, password, name, photoUrl });
console.log(user);
dispatch(user);
} catch (e) {
Alert.alert('Signup Error', e.message);
} finally{
spinner.stop();
}
};
return (
<KeyboardAwareScrollView extraScrollHeight={20}>
<Container>
<Image rounded url={photoUrl} showButton
onChangeImage={url => setPhotoUrl(url)}
/>
<Input
label="Name"
value={name}
onChangeText={text => setName(text)}
onSubmitEditing={() => {
setName(name.trim());
emailRef.current.focus();
}}
onBlur={() => setName(name.trim())}
placeholder="Name"
returnKeyType="next"
/>
<Input
ref={emailRef}
label="Email"
value={email}
onChangeText={text => setEmail(removeWhitespace(text))}
onSubmitEditing={() => passwordRef.current.focus()}
placeholder="Email"
returnKeyType="next"
/>
<Input
ref={passwordRef}
label="Password"
value={password}
onChangeText={text => setPassword(removeWhitespace(text))}
onSubmitEditing={() => passwordConfirmRef.current.focus()}
placeholder="Password"
returnKeyType="done"
isPassword
/>
<Input
ref={passwordConfirmRef}
label="Password Confirm"
value={passwordConfirm}
onChangeText={text => setPasswordConfirm(removeWhitespace(text))}
onSubmitEditing={_handleSignupButtonPress}
placeholder="Password Confirm"
returnKeyType="done"
isPassword
/>
<ErrorText>{errorMessage}</ErrorText>
<Button title="Signup" onPress={_handleSignupButtonPress} disabled={disabled} />
</Container>
</KeyboardAwareScrollView>
);
};
export default Signup;
-로그아웃
firebase에 로그아웃 함수를 만들고, 프로필 화면에서 만들어 놓은 Button 컴포넌트로 로그아웃버튼을 만들어 이를 통해 로그아웃 함수가 동작하게 합니다.
logout 함수가 완료되면 UserContext의 dispatch 함수를 이용해 user의 상태를 변경하고 AuthStack 내비게이션이 렌더링됩니다.
사용자 인증을 했을 때와 마찬가지로 로그아웃을 통해 인증을 해제한 후에는 스와이프나 뒤로 가기 버튼을 통해 다 시 이전 내비게이션 화면으로 돌아갈 수 없습니다.
export const logout = async () => {
return await Auth.signOut();
};
import React, { useContext } from 'react';
import styled from 'styled-components/native';
import { Button } from '../components';
import { logout } from '../utils/firebase';
import { UserContext } from '../contexts';
const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.background};
`;
const Profile = () => {
const { dispatch } = useContext(UserContext);
const _handleLogoutButtonPress = async () => { // 함수 이름 수정
try {
await logout();
} catch (e) {
console.log('[Profile] logout: ', e.message);
} finally {
dispatch({}); // 로그아웃 후 사용자 상태 초기화
}
};
return (
<Container>
<Button title="Logout" onPress={_handleLogoutButtonPress} />
</Container>
);
};
export default Profile;
3) 프로필
MainTab에서 탭 버튼은 아이콘을 추가하고, 프로필 화면에서는 사용자의 사진을 변경할 수 있는 기능을 추가하겠습니다.
사용자의 사진은 현재 접속한 사용자의 사진이 렌더링 되도록 getCurrentUser 함수를 통해 받아온 user의 photoUrl을 사용하였고, getCurrentUser 함수가 반환한 내용으로 사용자 의 이름과 이메일을 Input 컴포넌트로 렌더링하고, 로그아웃 버튼의 스타일을 수정했습니다. 마지막으로, 사용자가 사진 변경이나 로그아웃을 할 때 필요한 작업이 완료될 때까지 Spinner 컴포넌트가 렌더링되 도록 작성했습니다
프로필 화면에서는 사용자의 이름이나 이메일을 수정하는 기능을 제공하지 않으므로 Input 컴 포넌 트에서 입력이 불가능하도록 했습니다.
import React, { useContext, useEffect } from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Profile, ChannelList } from '../screens';
import { MaterialIcons } from '@expo/vector-icons';
import { ThemeContext } from 'styled-components/native';
import { getFocusedRouteNameFromRoute } from '@react-navigation/native';
const Tab = createBottomTabNavigator();
const TabBarIcon = ({ focused, name }) => {
const theme = useContext(ThemeContext);
return (
<MaterialIcons
name={name}
size={26}
color={focused ? theme.tabActiveColor : theme.tabInactiveColor}
/>
);
};
const MainTab = ({ navigation, route }) => {
const theme = useContext(ThemeContext);
useEffect(() => {
const title = getFocusedRouteNameFromRoute(route) ?? 'Channels';
navigation.setOptions({
headerTitle: title,
headerRight: () =>
title === 'Channels' && (
<MaterialIcons
name="add"
size={26}
style={{ margin: 10 }}
onPress={() => navigation.navigate('Channel Creation')}
/>
),
});
}, [route]);
return (
<Tab.Navigator
tabBarOptions={{
activeTintColor: theme.tabActiveColor,
inactiveTintColor: theme.tabInactiveColor,
}}
>
<Tab.Screen
name="Channels"
component={ChannelList}
options={{
tabBarIcon: ({ focused }) =>
TabBarIcon({
focused,
name: focused ? 'chat-bubble' : 'chat-bubble-outline',
}),
}}
/>
<Tab.Screen
name="Profile"
component={Profile}
options={{
tabBarIcon: ({ focused }) =>
TabBarIcon({
focused,
name: focused ? 'person' : 'person-outline',
}),
}}
/>
</Tab.Navigator>
);
};
export default MainTab;
import React, { useContext, useState } from 'react';
import styled, { ThemeContext } from 'styled-components/native';
import { Button, Image, Input } from '../components';
import { UserContext, ProgressContext } from '../contexts';
import { Alert } from 'react-native';
import { getCurrentUser, updateUserInfo, signout } from '../utils/firebase';
const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.background};
justify-content: center;
align-items: center;
padding: 0 20px;
`;
const Profile = () => {
const { dispatch } = useContext(UserContext);
const { spinner } = useContext(ProgressContext);
const theme = useContext(ThemeContext);
const user = getCurrentUser();
const [photoUrl, setPhotoUrl] = useState(user.photoUrl);
const _handleLogoutButtonPress = async () => {
try {
spinner.start();
await signout();
} catch (e) {
console.log('[Profile] logout: ', e.message);
} finally {
dispatch({});
spinner.stop();
}
};
const _handlePhotoChange = async url => {
try {
spinner.start();
const photoUrl = await updateUserInfo(url);
setPhotoUrl(photoUrl);
} catch (e) {
Alert.alert('Photo Error', e.message);
} finally {
spinner.stop();
}
};
return (
<Container>
<Image
url={photoUrl}
onChangeImage={_handlePhotoChange}
showButton
rounded
/>
<Input label="Name" value={user.name} disabled />
<Input label="Email" value={user.email} disabled />
<Button
title="logout"
onPress={_handleLogoutButtonPress}
containerStyle={{ marginTop: 30, backgroundColor: theme.buttonLogout }}
/>
</Container>
);
};
export default Profile;
import React, { useState, forwardRef } from 'react';
import styled from 'styled-components/native';
import PropTypes from 'prop-types';
const Container = styled.View`
flex-direction: column;
width: 100%;
margin: 10px 0;
`;
const Label = styled.Text`
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
color: ${({ theme, isFocused }) => (isFocused ? theme.text : theme.label)};
`;
const StyledTextInput = styled.TextInput.attrs(({ theme }) => ({
placeholderTextColor: theme.inputPlaceholder,
}))`
background-color: ${({ theme, editable }) =>
editable ? theme.background : theme.inputDisabledBackground};
color: ${({ theme }) => theme.text};
padding: 20px 10px;
font-size: 16px;
border: 1px solid
${({ theme, isFocused }) => (isFocused ? theme.text : theme.inputBorder)};
border-radius: 4px;
`;
const Input = forwardRef(
(
{
label,
value,
onChangeText,
onSubmitEditing,
onBlur,
placeholder,
isPassword,
returnKeyType,
maxLength,
disabled,
},
ref
) => {
const [isFocused, setIsFocused] = useState(false);
return (
<Container>
<Label isFocused={isFocused}>{label}</Label>
<StyledTextInput
ref={ref}
isFocused={isFocused}
value={value}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
onFocus={() => setIsFocused(true)}
onBlur={() => {
setIsFocused(false);
onBlur();
}}
placeholder={placeholder}
secureTextEntry={isPassword}
returnKeyType={returnKeyType}
maxLength={maxLength}
autoCapitalize="none"
autoCorrect={false}
textContentType="none" // iOS only
underlineColorAndroid="transparent" // Android only
editable={!disabled}
/>
</Container>
);
}
);
Input.defaultProps = {
onBlur: () => {},
onChangeText: () => {},
onSubmitEditing: () => {},
};
Input.propTypes = {
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
onChangeText: PropTypes.func,
onSubmitEditing: PropTypes.func,
onBlur: PropTypes.func,
placeholder: PropTypes.string,
isPassword: PropTypes.bool,
returnKeyType: PropTypes.oneOf(['done', 'next']),
maxLength: PropTypes.number,
disabled: PropTypes.bool,
};
export default Input;
-내비게이션에 따른 헤더 변경
내비게이션이 화면으로 지정되었을 때 헤더의 타이틀을 변경하는 방법에 대해 알아보겠습니다.
MainTab 내비게이션은 MainStack 내비게이션의 화면으로 사용되었기 때문에 다른 화면들 과 마찬가지로 props를 통해 navigation과 route를 전달받습니다.
일반적인 화면과 달리 MainTab 내비게이션처럼 Navigator 컴포넌트가 화면0-루 사용되는 경우 route에 state가 추가적으로 포함되어 전달됩니다.
index는 현재 렌더링되는 화면의 인덱스이며 Screen 컴포넌트가 사용된 순서대로 0부터 지 정됩니다. MainTab 내비게이션의 경우 첫 번째 하위 컴포넌트인 채널 목록 화면이 0, 프로필 화면이 1이 됩니다.
import React, { useEffect, useContext } from 'react';
import { useNavigation, useRoute } from '@react-navigation/native';
import { ThemeContext } from './path-to-your-theme-context'; // 필요한 경로로 import 경로 수정
const MainTab = () => {
const theme = useContext(ThemeContext);
const navigation = useNavigation();
const route = useRoute();
useEffect(() => {
// route.state와 route.state.routeNames가 정의되어 있는지 확인
const titles = route.state?.routeNames || [];
const index = route.state?.index ?? 0; // index가 정의되지 않은 경우 0으로 기본값 설정
navigation.setOptions({ headerTitle: titles[index] });
}, [route, navigation]);
return (
<Tab.Navigator>
<Tab.Screen name="Channels" />
{/* 필요한 경우 다른 Tab.Screen 컴포넌트 추가 */}
</Tab.Navigator>
);
};
4. 채널 생성화면, 채널 메시지
파이 어 베 이스에서 제공하는 파이 어스토어는 NoSQL 문서 중심 의 데이터베이스로 SQL 데이터베이스와 달리 테이블이나 행이 없고 컬렉션c0116ction, 문서 document, 필드field로 구성됩니다. 컬렉션은 문서의 컨테이너 역할을 하며 모든 문서는 항상 컬렉션에 저 장되어야 합니다. 문서는 파이어스토어의 저장 단위로 값이 있는 필드를 갖습니다. 문서의 가 장 큰 특징은 컬렉션을 필드로 가질 수 있다는 점입니다.
컬렉션과 문서는 항상 유일한 ID를 갖고 있어야 한다는 규칙이 있습니다. 여기서는 channels 라는 ID를 가진 하나의 컬렉션을 만들고 생성되는 채널들을 channels 컬렉션에 문서로 저장 하겠습니다. 파이어스토어는 채널 생성 시 ID를 지정하지 않으면 자동으로 중복되지 않는 ID 를 생성해서 문서의 ID로 이용합니다. 따라서 자동으로 생성되는 문서의 ID를 이용해 채널의 문서 ID가 중복되지 않도록 관리하겠습니다.
firestore database의 보안 규칙을 다음과 같이 설정합니다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /channels/{channel} {
allow read, write: if request.auth.uid != null;
}
}
}
MainTab에서 채널 생성 화면으로 이동할 수 있는 채널 생성 버튼을 만듭니다. firebase.js에 실제 데이터베이스에 채널을 추가해주는함수를 만들고 이를 이용해 채널 생성 화면에서 버튼을 누르면 데이터베이스에 채널이 추가되도록합니다.
채널 생성이 완료되면 채널 생성 화면을 남겨놓은 상태에서 생성된 채널로 이동하는 것이 아니 라, 채널 생성 화면을 제거하고 새로 생성된 채널로 이동하는 것이 일반적입니다. 채널 생성 화 면에서도 동일하게 동작하도록 navigation의 replace 함수를 이용했습니다.
# replace 함수는 navigate 함수처럼 화면을 이동하지만, 현재 화면을 스택에 유지하지 않고 새로운 화면과 교 체하면서 화면을 이동한다는 특징이 있습니다
채널이 생성되는 동안 사용자의 추가 행동을 방지하기 위해 ProgressContext를 이용하여 Spinner 컴포넌트가 렌더링되도록 하고, 채널 생성이 완료되면 채널 화면으로 이동하면서 현 재 입장하는 채널의 ID와 제목을 params로 함께 전달했습니다.
이어 채널 화면에서 params로 전달되는 내용을 받아 확인하도록 만들었습니다.
import React, { useContext, useEffect } from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Profile, ChannelList } from '../screens';
import { MaterialIcons } from '@expo/vector-icons';
import { ThemeContext } from 'styled-components/native';
import { getFocusedRouteNameFromRoute } from '@react-navigation/native';
const Tab = createBottomTabNavigator();
const TabBarIcon = ({ focused, name }) => {
const theme = useContext(ThemeContext);
return (
<MaterialIcons
name={name}
size={26}
color={focused ? theme.tabActiveColor : theme.tabInactiveColor}
/>
);
};
const MainTab = ({ navigation, route }) => {
const theme = useContext(ThemeContext);
useEffect(() => {
const title = getFocusedRouteNameFromRoute(route) ?? 'Channels';
navigation.setOptions({
headerTitle: title,
headerRight: () =>
title === 'Channels' && (
<MaterialIcons
name="add"
size={26}
style={{ margin: 10 }}
onPress={() => navigation.navigate('Channel Creation')}
/>
),
});
}, [route]);
return (
<Tab.Navigator
tabBarOptions={{
activeTintColor: theme.tabActiveColor,
inactiveTintColor: theme.tabInactiveColor,
}}
>
<Tab.Screen
name="Channels"
component={ChannelList}
options={{
tabBarIcon: ({ focused }) =>
TabBarIcon({
focused,
name: focused ? 'chat-bubble' : 'chat-bubble-outline',
}),
}}
/>
<Tab.Screen
name="Profile"
component={Profile}
options={{
tabBarIcon: ({ focused }) =>
TabBarIcon({
focused,
name: focused ? 'person' : 'person-outline',
}),
}}
/>
</Tab.Navigator>
);
};
export default MainTab;
import React, { useState, useRef, useEffect, useContext } from 'react';
import { Alert } from 'react-native';
import { ProgressContext } from '../contexts';
import { createChannel } from '../utils/firebase';
import styled from 'styled-components/native';
import { Input, Button } from '../components';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.background};
justify-content: center;
align-items: center;
padding: 0 20px;
`;
const ErrorText = styled.Text`
align-items: flex-start;
width: 100%;
height: 20px;
margin-bottom: 10px;
line-height: 20px;
color: ${({ theme }) => theme.errorText};
`;
const ChannelCreation = ({ navigation }) => {
const { spinner } = useContext(ProgressContext);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const descriptionRef = useRef();
const [errorMessage, setErrorMessage] = useState('');
const [disabled, setDisabled] = useState(true);
useEffect(() => {
setDisabled(!(title && !errorMessage));
}, [title, description, errorMessage]);
const _handleTitleChange = title => {
setTitle(title);
setErrorMessage(title.trim() ? '' : 'Please enter the title.');
};
const _handleCreateButtonPress = async () => {
try {
spinner.start();
const id = await createChannel({ title, description });
navigation.replace('Channel', { id, title });
} catch (e) {
Alert.alert('Creation Error', e.message);
} finally {
spinner.stop();
}
};
return (
<KeyboardAwareScrollView
contentContainerStyle={{ flex: 1 }}
extraScrollHeight={20}
>
<Container>
<Input
label="Title"
value={title}
onChangeText={_handleTitleChange}
onSubmitEditing={() => {
setTitle(title.trim());
descriptionRef.current.focus();
}}
onBlur={() => setTitle(title.trim())}
placeholder="Title"
returnKeyType="next"
maxLength={20}
/>
<Input
ref={descriptionRef}
label="Description"
value={description}
onChangeText={text => setDescription(text)}
onSubmitEditing={() => {
setDescription(description.trim());
_handleCreateButtonPress();
}}
onBlur={() => setDescription(description.trim())}
placeholder="Description"
returnKeyType="done"
maxLength={40}
/>
<ErrorText>{errorMessage}</ErrorText>
<Button
title="Create"
onPress={_handleCreateButtonPress}
disabled={disabled}
/>
</Container>
</KeyboardAwareScrollView>
);
};
export default ChannelCreation;
import { initializeApp } from 'firebase/app';
import {
getAuth,
signInWithEmailAndPassword,
createUserWithEmailAndPassword,
signOut,
updateProfile,
} from 'firebase/auth';
import { getFirestore, collection, doc, setDoc } from 'firebase/firestore';
import { getDownloadURL, getStorage, ref, uploadBytes } from 'firebase/storage';
import config from '../../firebase.json';
export const app = initializeApp(config);
const auth = getAuth(app);
// 사용자 로그인
export const signin = async ({ email, password }) => {
const { user } = await signInWithEmailAndPassword(auth, email, password);
return user;
};
// 이미지 업로드
const uploadImage = async uri => {
if (uri.startsWith('https')) {
return uri;
}
const response = await fetch(uri);
const blob = await response.blob();
const { uid } = auth.currentUser;
const storage = getStorage(app);
const storageRef = ref(storage, `/profile/${uid}/photo.png`);
await uploadBytes(storageRef, blob, {
contentType: 'image/png',
});
return await getDownloadURL(storageRef);
};
// 사용자 가입
export const signup = async ({ name, email, password, photoUrl }) => {
const { user } = await createUserWithEmailAndPassword(auth, email, password);
const photoURL = await uploadImage(photoUrl);
await updateProfile(auth.currentUser, { displayName: name, photoURL });
return user;
};
// 현재 사용자 정보 반환
export const getCurrentUser = () => {
const { uid, displayName, email, photoURL } = auth.currentUser;
return { uid, name: displayName, email, photoUrl: photoURL };
};
// 사용자 정보 업데이트
export const updateUserInfo = async photo => {
const photoUrl = await uploadImage(photo);
await updateProfile(auth.currentUser, { photoUrl });
return photoUrl;
};
// 로그아웃
export const logout = async () => {
await signOut(auth);
return {};
};
// Firestore 인스턴스 초기화
const db = getFirestore(app);
// 채널 생성
export const createChannel = async ({ title, description }) => {
const channelCollection = collection(db, 'channels');
const newChannelRef = doc(channelCollection);
const id = newChannelRef.id;
const newChannel = {
id,
title,
description,
createdAt: Date.now(),
};
await setDoc(newChannelRef, newChannel);
return id;
};
// 메시지 생성
export const createMessage = async ({ channelId, message }) => {
const docRef = doc(db, `channels/${channelId}/messages`, message._id);
await setDoc(docRef, { ...message, createdAt: Date.now() });
};
--채널 리스트(FlatList)
FlatList는 ScrollView와 유사하게 많은 데이터를 목록으로 표시하지만, 필요한 부분만 동적으로 렌더링하여 성능을 최적화하는 특징이 있습니다. ScrollView는 모든 데이터를 한 번에 렌더링하므로 데이터 양이 많을 경우 성능 저하가 발생할 수 있지만, FlatList는 스크롤에 맞춰 필요한 데이터만 추가로 렌더링하므로 가변적인 데이터 목록을 효율적으로 처리할 수 있습니다.
FlatList 컴포넌트를 사용하려면 3개의 속성을 지정해야 합니다. 먼저 렌더링할 항목의 데이터 를 배열로 전달해야 하고, 전달된 배열의 항목을 이용해 항목을 렌더링하는 함수를 작성해야 합니다. 마지막으로 각 항목에 키를 추가하기 위해 고유한 값을 반환하는 함수를 전달해야 합 니다.
render Item에 작성되는 함수는 파라미터로 항목의 데이터를 가진 item이 포함된 객체가 전달됩니 다. 파라미터로 전달되는 데이터를 이용해서 각 항목의 내용을 렌더링하고 클릭 시 채널 화면 으로 이동하도록 만들었습니다. 마지막으로 각 항목의 id값을 키로 이용하도록 keyExtractor 를 설정했습니다.
FlatList 컴포넌트에서 렌더링되는 데이터의 수는 windowSize 속성에 의해 결정됩니다.
windowSize의 기본값은 21이고, 이 값은 현재 화 면⑴과 현재 화면보다 앞쪽에 있는 데이터 (10), 그리고 현재 화면보다 뒤쪽에 있는 데이터 (10)를 의미합니다.
예를 들어, 한 화면에 10개의 항목이 보인다면, 앞뒤로 각각 100개의 데이터를 추가로 렌더링하여 총 210개의 데이터를 표시할 수 있습니다.또한, 화면이 리스트의 맨 앞에 있을 경우 이전 데이터는 렌더링되지 않으며, 이후 데이터만 9개의 화면 분량(약 101개)이 렌더링됩니다.
렌더링되는 데이터 양은 windowSize 값을 조절하여 변경할 수 있습니다. 작은 값으로 설정하면 메모리 사용량을 줄일 수 있지만, 빠르게 스크롤할 때 미리 로드되지 않은 부분이 순간적으로 비어 보일 수 있습니다.
예를 들어 한 화면에 10개의 항목이 렌더링되고 현재 화면의 앞뒤로 충분 히 많은 데이터가 있다고 가정하겠습니다. 현재 화면보다 이전 데이터 중에서 화면 10개만큼 렌더링할 수 있는 100개의 데이터를 렌더링하고, 이후 데이터 중에서도 화면 10개만큼 렌더링 할 수 있는 100개의 데이터를 렌더링하여 총 210개의 데이터를 렌더링합니다. 현재 화면(10 items) + 이전 데이터(10 items x 10 screens) + 이후 데이터(10 items x 10 screens) =210 화면은 가장 앞쪽 데이터이므로 이전 화면을 위해 렌더링할 데이터는 없으며 이후 화면들을 위 한 데이터 (9 items x 10 screens)를 렌더링합니다. 추가적으로 가장 아래에 약 20%정도 보 이는 10번째 항목을 0.2개로 보면 총 101.2개의 데이터가 렌더링되어야 하므로 1()1개의 데이 터가 렌더링되는 것입니다. 현재 화면(9.2 items) + 이전 데이터(0 items) + 이후 데이터(9.2 items x 10 screens) = 101.2 여러분도 화면을 스크롤하면서 터미널을 통해 렌더링되는 데이터를 확인해보세요. 만약 렌더링되는 데이터의 양을 조절하고 싶다면 windowSize의 값을 원하는 값으로 설정합 420 처음배우는리액트 네이티브 니다. windowSize의 값을 작은 값으로 변경하면 렌더링되는 데이터가 줄어들어 메모리의 소 비를 줄이고 성능을 향상시킬 수 있지만, 빠르게 스크롤하는 상황에서 미리 렌더링되지 않은 부분은 순간적으로 빈 내용이 나타날 수 있다는 단점이 있습니다.
스크롤이 이동하면 windowSize 값에 맞춰 현재 화면과 이전 및 이후 데이터를 렌더링하 는 것이 맞지만, 이미 렌더링된 항목도 다시 렌더링됩니다. 이전에 배웠던 useMemo와 유사한 React.memo로 해결했습니다.
React.memo는 6장 에서 공부한 useMemo Hook 함수와 매우 흡사하지만, 불필요한 함수의 재연산을 방지하는 useMemo와 달리 React.memo는 컴포넌트의 리렌더링을 방지한다는 차이가 있습니다.
채널 목록 화면에서 Firebase 데이터베이스로부터 데이터를 받아 렌더링할 수 있도록 설정했습니다.
테스트를 위해 일정량의 데이터를 미리 준비하는 것이 좋으며, 이를 위해 채널 생성 화면을 이용하거나 Firebase 콘솔에서 직접 데이터를 추가할 수 있습니다.
Firebase 콘솔에서 데이터를 추가할 경우, title과 description 필드는 문자열로 설정하고, 문서의 ID는 자동 생성된 값을 id 필드에 저장해야 합니다. 또한, createdAt 필드는 숫자 타입으로 지정해야 하며, 이를 위해 JavaScript 함수를 활용하여 정상적인 값을 입력해야 합니다.
채널 목록 데이터를 Firebase에서 받아와 useState를 사용해 관리하도록 설정했습니다. 테스트용으로 생성한 100개의 임시 데이터는 삭제했고, 데이터베이스에서 가져온 채널 문서의 ID를 항목의 키로 사용하여 불필요한 타입 변환 코드를 제거했습니다.
useEffect를 사용해 화면이 마운트될 때 onSnapshot 함수를 호출하여 데이터베이스 변경을 실시간으로 수신하도록 했습니다. onSnapshot은 채널이 추가되거나 수정될 때마다 자동으로 업데이트되며, createdAt 필드를 기준으로 내림차순 정렬하여 최신 채널이 상단에 표시되도록 설정했습니다.
화면이 언마운트될 때 수신 대기 상태를 해제하여 중복 데이터 수신을 방지했습니다. 이제 다른 기기에서 채널을 생성해도 목록이 실시간으로 업데이트되는지 확인할 수 있습니다.
moment 라이브러리를 사용하면 시간 및 날짜와 관련된 함수를 쉽게 작성할 수 있습니다.
npm install moment
import React, { useContext, useState, useEffect } from 'react';
import { FlatList } from 'react-native';
import styled, { ThemeContext } from 'styled-components/native';
import { MaterialIcons } from '@expo/vector-icons';
import moment from 'moment';
import { app } from '../utils/firebase';
import {
getFirestore,
collection,
onSnapshot,
query,
orderBy,
} from 'firebase/firestore';
const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.background};
`;
const ItemContainer = styled.TouchableOpacity`
flex-direction: row;
align-items: center;
border-bottom-width: 1px;
border-color: ${({ theme }) => theme.listBorder};
padding: 15px 20px;
`;
const ItemTextContainer = styled.View`
flex: 1;
flex-direction: column;
`;
const ItemTitle = styled.Text`
font-size: 20px;
font-weight: 600;
`;
const ItemDescription = styled.Text`
font-size: 16px;
margin-top: 5px;
color: ${({ theme }) => theme.listDescription};
`;
const ItemTime = styled.Text`
font-size: 12px;
color: ${({ theme }) => theme.listTime};
`;
const getDateOrTime = ts => {
const now = moment().startOf('day');
const target = moment(ts).startOf('day');
return moment(ts).format(now.diff(target, 'days') > 0 ? 'MM/DD' : 'HH:mm');
};
const Item = React.memo(
({ item: { id, title, description, createdAt }, onPress }) => {
const theme = useContext(ThemeContext);
return (
<ItemContainer onPress={() => onPress({ id, title })}>
<ItemTextContainer>
<ItemTitle>{title}</ItemTitle>
<ItemDescription>{description}</ItemDescription>
</ItemTextContainer>
<ItemTime>{getDateOrTime(createdAt)}</ItemTime>
<MaterialIcons
name="keyboard-arrow-right"
size={24}
color={theme.listIcon}
/>
</ItemContainer>
);
}
);
const ChannelList = ({ navigation }) => {
const [channels, setChannels] = useState([]);
const db = getFirestore(app);
useEffect(() => {
const collectionQuery = query(
collection(db, 'channels'),
orderBy('createdAt', 'desc')
);
const unsubscribe = onSnapshot(collectionQuery, snapshot => {
const list = [];
snapshot.forEach(doc => {
list.push(doc.data());
});
setChannels(list);
});
return () => unsubscribe();
}, []);
const _handleItemPress = params => {
navigation.navigate('Channel', params);
};
return (
<Container>
<FlatList
keyExtractor={item => item['id']}
data={channels}
renderItem={({ item }) => (
<Item item={item} onPress={_handleItemPress} />
)}
windowSize={3}
/>
</Container>
);
};
export default ChannelList;
6. 메시지
채팅 애플리케이션에서 메시지를 주고받는 화면은 일반적인 스크롤 방향과 반대입니다. 일반적인 앱(예: 페이스북, 인스타그램)은 최신 데이터가 위에 표시되고 아래로 스크롤되지만, 채팅 앱은 최신 메시지가 아래에 나타나고 위로 스크롤됩니다.
이를 구현하기 위해 FlatList 컴포넌트의 inverted 속성을 사용하면, 리스트가 뒤집힌 것처럼 동작하여 새로운 메시지가 아래에 나타나고, 위로 스크롤되도록 설정할 수 있습니다.
하지만 react-native-gifted-chat 라이브러리의 GiftedChat 컴포넌트는 더 다양한 설정이 가능합니다. 입력된 메시지를 설정된 사용자 정보 및 자동 생성된 ID와 함께 전달하는 기능뿐만 아니라, 전송 버튼을 커스터마이징하거나, 스크롤 위치에 따라 스크롤을 조정하는 버튼을 추가로 렌더링할 수도 있습니다.
일관성과 관리 편의성을 위해 데이터베이스에 메시지 문서를 생성할 때 파이어베이스에서 생성하는 ID 가 아닌 우리가 사용하는 GiftedChat 컴포넌트의 id를 사용하도록 수정하였습니다.
메시지에 함께 저장된 사용자 정보를 바탕으로 본인의 메시지는 오른쪽에, 상대방의 메시지는 왼쪽에 렌더링되었으며 메시지의 배경색도 다르게 적용되었습니다. 연속된 메시지에서는 하나 의 이미지만 렌더링되었고, 상대방의 메시지에는 사용자 이름과 메시지가 생성된 시간이 렌더 링되었습니다. 스크롤을 위로 이동시켜 일정 높이 이상 올라가면, 오른쪽 아래에 스크롤을 가 장 아래로 내리는 버튼이 생성되는 것을 확인할 수 있습니다.
추가로 GiftedChat컴포넌트는 사용자 설정도 가능합니다.
renderBubble은 GiftedChat 컴포넌트의 프로퍼티 중 하나로, 채팅 메시지의 모양이나 스타일을 커스터마이즈할 수 있는 방법입니다. 이 프로퍼티에 함수를 전달하면, 각 메시지를 어떻게 표시할지에 대한 사용자 정의 로직을 작성할 수 있습니다.
-채널 메시지
npm install react-native-gifted-chat
firebase 접근 권한
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// 채널에 대한 접근 권한
match /channels/{channel} {
allow read, write: if request.auth.uid != null;
// 채팅 메시지에 대한 접근 권한 추가
match /messages/{message} {
allow read, write: if request.auth.uid != null;
}
}
}
}
import React, { useState, useEffect, useLayoutEffect, useContext } from 'react';
import styled, { ThemeContext } from 'styled-components/native';
import { Alert } from 'react-native';
import { GiftedChat, Send } from 'react-native-gifted-chat';
import { MaterialIcons } from '@expo/vector-icons';
import { createMessage, getCurrentUser, app } from '../utils/firebase';
import {
getFirestore,
collection,
onSnapshot,
query,
doc,
orderBy,
} from 'firebase/firestore';
const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.background};
`;
const SendButton = props => {
const theme = useContext(ThemeContext);
return (
<Send
{...props}
disabled={!props.text}
containerStyle={{
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 4,
}}
>
<MaterialIcons
name="send"
size={24}
color={
props.text ? theme.sendButtonActivate : theme.sendButtonInactivate
}
/>
</Send>
);
};
const Channel = ({ navigation, route }) => {
const theme = useContext(ThemeContext);
const { uid, name, photoUrl } = getCurrentUser();
const [messages, setMessages] = useState([]);
const db = getFirestore(app);
useEffect(() => {
const docRef = doc(db, 'channels', route.params.id);
const collectionQuery = query(
collection(db, `${docRef.path}/messages`),
orderBy('createdAt', 'desc')
);
const unsubscribe = onSnapshot(collectionQuery, snapshot => {
const list = [];
snapshot.forEach(doc => {
list.push(doc.data());
});
setMessages(list);
});
return () => unsubscribe();
}, []);
useLayoutEffect(() => {
navigation.setOptions({ headerTitle: route.params.title || 'Channel' });
}, []);
const _handleMessageSend = async messageList => {
const newMessage = messageList[0];
try {
await createMessage({ channelId: route.params.id, message: newMessage });
} catch (e) {
Alert.alert('Send Message Error', e.message);
}
};
return (
<Container>
<GiftedChat
listViewProps={{
style: { backgroundColor: theme.background },
}}
placeholder="Enter a message..."
messages={messages}
user={{ _id: uid, name, avatar: photoUrl }}
onSend={_handleMessageSend}
alwaysShowSend={true}
textInputProps={{
autoCapitalize: 'none',
autoCorrect: false,
textContentType: 'none', // iOS only
underlineColorAndroid: 'transparent', // Android only
}}
multiline={false}
renderUsernameOnMessage={true}
scrollToBottom={true}
renderSend={props => <SendButton {...props} />}
/>
</Container>
);
};
export default Channel;
'대외활동 > 멋쟁이사자처럼_프론트엔드 12기' 카테고리의 다른 글
React Native로 하단탭 네비게이션 만들기 DRACONIST (0) | 2025.02.18 |
---|---|
트러블 슈팅. Command failed with exit code 1: gradlew.bat app: DRACONIST (0) | 2025.02.15 |
REACT NATIVE 스터디. 4주차-9장. 채팅 어플리케이션 DRACONIST (0) | 2025.02.06 |
<트러블슈팅>react native navigation DRACONIST (0) | 2025.02.05 |
REACT NATIVE 스터디. 3주차 퀴즈 DRACONIST (0) | 2025.01.30 |