깃허브 레포지토리이다.
https://github.com/choi-day/react-native-simple-chat
화면 이동에 필요한 내비게이션을 이용하여 화면을 구성한다.
npm install @react-navigation/native
expo install react-native-gesture-handler react-native-reanimated react-native-
screens react-native-safe-area-context @react-native-community/masked-view
npm install @react-navigation/stack @react-navigation/bottom-tabs
npm install styled-componentsnpm install styled-components prop-typesreact-native-simple-chat
│
├── assets
│ ├── icon.png
│ └── splash.png
│
├── src
│ ├── components
│ │
│ ├── contexts
│ │
│ ├── navigations
│ │
│ ├── screens
│ │
│ ├── utils
│ │
│ ├── App.js
│ └── theme.js
│
└── App.js
theme.js - 공통으로 사용할 색을 정의하고 배경색과 글자색을 미리 정의했다.
const colors = {
white: '#ffffff',
black: '#000000',
grey_0: '#d5d5d5',
grey_1: '#a6a6a6',
red: '#e84118',
blue: '#3679fe',
};
export const theme = {
background: colors.white,
text: colors.black
}
src/App.js - 컴포넌트에 정의된 theme를 사용할 수 있도록 한다.
import React from 'react';
import { StatusBar } from 'react-native';
import { ThemeProvider } from 'styled-components/native';
import { theme } from './theme';
const App = () => {
return (
<ThemeProvider theme={theme}>
<StatusBar barStyle="dark-content" />
</ThemeProvider>
);
};
export default App;
App.js - App컴포넌트가 메인 파일이 되도록 한다
import App from './src/App';
export default App;
이 프로젝트에서는 서버 대신 파이어베이스를 이용한다. 설정과 관련된 부분은 아래 글을 참고하자.
src/App.js
import React, { useState } from 'react';
import { StatusBar, Image } from 'react-native';
import AppLoading from 'expo-app-loading';
import { Asset } from 'expo-asset';
import * as Font from 'expo-font';
import { ThemeProvider } from 'styled-components/native';
import { theme } from './theme';
const cacheImages = images => {
return images.map(image => {
if (typeof image === 'string') {
return Image.prefetch(image);
} else {
return Asset.fromModule(image).downloadAsync();
}
});
};
const cacheFonts = fonts => {
return fonts.map(font => Font.loadAsync(font));
};
const App = () => {
const [isReady, setIsReady] = useState(false);
const _loadAssets = async () => {
const imageAssets = cacheImages([require('../assets/splash.png')]);
const fontAssets = cacheFonts([]);
await Promise.all([...imageAssets, ...fontAssets]);
};
return isReady ? (
<>
<ThemeProvider theme={theme}>
<StatusBar barStyle="dark-content" />
</ThemeProvider>
</>
) : (
<AppLoading
startAsync={_loadAssets}
onFinish={() => setIsReady(true)}
onError={console.warn}
/>
);
};
export default App;
스플래시 이미지 로딩
(필요하다면) 폰트 로딩
Promise.all로 모든 로딩이 끝날 때까지 기다림
로딩이 끝나야 실제 화면이 렌더링된다.
URL 형태의 문자열 → Image.prefetch
require로 불러오는 로컬 이미지 → Asset.fromModule().downloadAsync()
둘 다 Promise를 반환하기 때문에 나중에 Promise.all()로 처리할 수 있다.
책에서는 expo에서 AppLoading을 import 하고 있으나 Expo SDK 45 이후로 AppLoading은 expo에서 제거되었다.
npm install expo-app-loading
위와 같이 설치 후 'expo-app-loading’으로 바꿔주면 정상적으로 스플래쉬 화면이 뜬다
로그인 및 회원가입 기능을 구현한다. 로그인 화면에서 이메일과 비밀번호를 입력받고 회원가입시 서비스에서 이용할 이름과 프로필 사진을 받는다.
로그인 화면과 회원가입 화면을 구현한다
screens/Login.js
import React from 'react';
import styled from 'styled-components/native';
import { Text, Button } from 'react-native';
const Container = styled.View`
flex: 1;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.background};
`;
const Login = ({ navigation }) => {
return (
<Container>
<Text style={{ fontSize: 30 }}>Login Screen</Text>
<Button title="Signup" onPress={() => navigation.navigate('Signup')} />
</Container>
);
};
export default Login;
screens/Signup.js
import React from 'react';
import styled from 'styled-components/native';
import { Text } from 'react-native';
const Container = styled.View`
flex: 1;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.background};
`;
const Signup = () => {
return (
<Container>
<Text style={{ fontSize: 30 }}>Signup Screen</Text>
</Container>
);
};
export default Signup;
screens/index.js
import Login from './Login';
import Signup from './Signup';
export { Login, Signup};
화면을 한 곳에서 관리하기 위해 필요한 파일이다.
navigationsAuthStack.js
import React, { useContext } from 'react';
import { ThemeContext } from 'styled-components/native';
import { createStackNavigator } from '@react-navigation/stack';
import { Login, Signup } from '../screens';
const Stack = createStackNavigator();
const AuthStack = () => {
const theme = useContext(ThemeContext);
return (
<Stack.Navigator
initialRouteName="Login"
screenOptions={{
headerTitleAlign: 'center',
cardStyle: { backgroundColor: theme.background },
}}
>
<Stack.Screen name="Login" component={Login} />
<Stack.Screen name="Signup" component={Signup} />
</Stack.Navigator>
);
};
export default AuthStack;
📝 전체 동작 요약
navigations/index.js
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import AuthStack from './AuthStack';
const Navigation = () => {
return (
<NavigationContainer>
<AuthStack />
</NavigationContainer>
);
};
export default Navigation;
📝 전체 동작 요약
components/Image.js
import React from 'react';
import styled from 'styled-components/native';
import PropTypes from 'prop-types';
const Container = styled.View`
align-self: center;
margin-bottom: 30px;
`;
const StyledImage = styled.Image`
background-color: ${({ theme }) => theme.imageBackground};
width: 100px;
height: 100px;
`;
const Image = ({ uri, imageStyle }) => {
return (
<Container>
<StyledImage source={{ uri: uri }} style={imageStyle} />
</Container>
);
};
Image.propTypes = {
uri: PropTypes.string,
imageStyle: PropTypes.object,
};
export default Image;
📝 전체 동작 요약
components/Image.js
import Image from './Image'
export {Image}
파이어베이스 Storage에 logo 파일을 업로드한다.
이름을 클릭하면 이미지파일 링크를 얻을 수 있다.
service firebase.storage {
match /b/{bucket}/o {
match /logo.png {
allow read;
}
}
}
규칙 또는 Rules탭으로 들어가 로그인하지 않아도 이미지에 접근할 수 있도록 수정한다.
잘 받아와지는지 궁금하다면 이미지 파일 링크를 외부에서 접속해본다. 이미지가 잘 뜬다면 적용이 된것이다.
utils/image.js
const prefix = 'https://firebasestorage.googleapis.com/v0/b/react-native-simple-chat-469fb.firebasestorage.app/o';
export const images = {
logo: `${prefix}/logo.png?alt=media`,
};
이미지를 불러오는 코드이다
App.js
const _loadAssets = async () => {
const imageAssets = cacheImages([require('../assets/splash.png'),
...Object.values(images),
]);
const fontAssets = cacheFonts([]);
파이어베이스의 이미지 파일도 함께 불러오도록 수정한다
Login.js
const Login = ({ navigation }) => {
return (
<Container>
<Image uri={images.logo} imageStyle={{borderRadius: 8}}/>
<Text style={{ fontSize: 30 }}>Login Screen</Text>
<Button title="Signup" onPress={() => navigation.navigate('Signup')} />
</Container>
);
};
이미지를 로그인화면에 삽입한다.
theme.js
label: colors.grey_1,
inputPlaceholder: colors.grey_1,
inputBorder: colors.grey_1,
input에 들어갈 색상을 정의해준다
compoenets/Input.js
import React, { useState } 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 }) => theme.background};
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 = ({
label,
value,
onChangeText,
onSubmitEditing,
onBlur,
placeholder,
isPassword,
returnKeyType,
maxLength,
}) => {
const [isFocused, setIsFocused] = useState(false);
return (
<Container>
<Label isFocused={isFocused}>{label}</Label>
<StyledTextInput
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
/>
</Container>
);
};
Input.defaultProps = {
onBlur: () => {},
};
Input.propTypes = {
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
onChangeText: PropTypes.func.isRequired,
onSubmitEditing: PropTypes.func.isRequired,
onBlur: PropTypes.func,
placeholder: PropTypes.string,
isPassword: PropTypes.bool,
returnKeyType: PropTypes.oneOf(['done', 'next']),
maxLength: PropTypes.number,
};
export default Input;
📝 Input 컴포넌트 전체 동작 요약
Login.js
const Login = ({ navigation }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
<Container>
<Image url={images.logo} imageStyle={{ borderRadius: 8 }} />
<Input
label="Email"
value={email}
onChangeText={text => setEmail(text)}
onSubmitEditing={() => {}}
placeholder="Email"
returnKeyType="next"
/>
<Input
label="Password"
value={password}
onChangeText={text => setPassword(text)}
onSubmitEditing={() => {}}
placeholder="Password"
returnKeyType="done"
isPassword
/>
</Container>
);
};
📝 수정된 동작 요약
Login.js
...
const passwordRef = useRef();
...
return (
<Container>
<Image url={images.logo} imageStyle={{ borderRadius: 8 }} />
<Input
label="Email"
value={email}
onChangeText={text => setEmail(text)}
onSubmitEditing={() => passwordRef.current.focus()}
placeholder="Email"
returnKeyType="next"
/>
...
<Input
ref = {password}
label="Password"
📝 수정된 동작 요약
Input.js
...
onBlur={() => {
setIsFocused(false);
if (typeof onBlur === 'function') {
onBlur();
}
}}
...
갑자기 onBlur();가 작동하지 않아 에러가 떴다. onBlur prop이 없거나(undefined), 함수가 아닐 때도 크래시가 나지 않도록 방어 코드를 추가했다.
비밀번호를 입력할 때 키보드에 가려 보이지 않는다. 키보드 스크롤 뷰를 이용하여 사용성을 개선한다.
npm install react-native-keyboard-aware-scroll-view
react-native-keyboard-aware-scroll-view 를 설치한다
Login.js
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
...
return (
<KeyboardAwareScrollView
contentContainerStyle={{ flex: 1 }}
extraScrollHeight={20}
>
<Container>
...
</Container>
</KeyboardAwareScrollView>
);
사용자가 이메일을 틀린 형식으로 입력했다면 이메일을 다시 입력하라는 경고를 띄워야 한다.
theme.js
errorText: colors.red,
오류 메세지 색상을 정의한다
utils/common.js
export const validateEmail = email => {
const regex = /^[0-9?A-z0-9?]+(\.[0-9?A-z0-9?]+)*@[0-9?A-z]+\.[A-z]{2,}(\.[A-z]{0,3})?$/;
return regex.test(email);
};
export const removeWhitespace = text => {
const regex = /\s/g;
return text.replace(regex, '');
};
Login.js
...
import { Image, Input } from '../components';
import { images } from '../utils/images';
import { validateEmail, removeWhitespace } from '../utils/common';
...
const ErrorText = styled.Text`
align-self: flex-start;
width: 100%;
height: 20px;
margin-bottom: 10px;
line-height: 20px;
color: ${({ theme }) => theme.errorText};
`;
const Login = ({ navigation }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const passwordRef = useRef(null);
const _handleEmailChange = email => {
const changedEmail = removeWhitespace(email);
setEmail(changedEmail);
setErrorMessage(validateEmail(changedEmail) ? '' : 'Please verify your email.');
};
const _handlePasswordChange = password => {
setPassword(removeWhitespace(password));
};
return (
<KeyboardAwareScrollView contentContainerStyle={{ flex: 1 }} extraScrollHeight={20}>
<Container>
<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={() => {}}
placeholder="Password"
returnKeyType="done"
isPassword
/>
<ErrorText>{errorMessage}</ErrorText>
</Container>
</KeyboardAwareScrollView>
);
};
...
📝 수정된 동작 요약
로그인 버튼을 구현해보자
theme.js
buttonBackground: colors.blue,
buttonTitle: colors.white,
buttonUnfilledTitle: colors.blue,
버튼과 관련된 색상을 정의한다
components/Button.js
import React from 'react';
import styled from 'styled-components/native';
import PropTypes from 'prop-types';
const TRANSPARENT = 'transparent';
const Container = styled.TouchableOpacity`
background-color: ${({ theme, isFilled }) =>
isFilled ? theme.buttonBackground : TRANSPARENT};
align-items: center;
border-radius: 4px;
width: 100%;
padding: 10px;
`;
const Title = styled.Text`
height: 30px;
line-height: 30px;
font-size: 16px;
color: ${({ theme, isFilled }) =>
isFilled ? theme.buttonTitle : theme.buttonUnfilledTitle};
`;
const Button = ({ containerStyle, title, onPress, isFilled }) => {
return (
<Container style={containerStyle} onPress={onPress} isFilled={isFilled}>
<Title isFilled={isFilled}>{title}</Title>
</Container>
);
};
Button.defaultProps = {
isFilled: true,
};
Button.propTypes = {
containerStyle: PropTypes.object,
title: PropTypes.string,
onPress: PropTypes.func.isRequired,
isFilled: PropTypes.bool,
};
export default Button;
버튼 컴포넌트를 컴포넌트의 index.js에 추가하고 로그인 화면에 적용한다
Login.js
import { Image, Input, Button } from '../components';
...
const _handleLoginButtonPress = () => {};
return (
<KeyboardAwareScrollView
contentContainerStyle={{ flex: 1 }}
extraScrollHeight={20}
>
<Container>
...
<Input
label="Password"
value={password}
onChangeText={_handlePasswordChange}
onSubmitEditing={_handleLoginButtonPress}
placeholder="Password"
returnKeyType="done"
isPassword
/>
<ErrorText>{errorMessage}</ErrorText>
<Button title="Login" onPress={_handleLoginButtonPress} />
<Button
title="Sign up with email"
onPress={() => navigation.navigate('Signup')}
isFilled={false}
/>
</Container>
</KeyboardAwareScrollView>
);
Login.js
import React, { useState, useRef, useEffect } from 'react';
...
const [disabled, setDisabled] = useState(true);
useEffect(() => {
setDisabled(!(email && password && !errorMessage));
}, [email, password, errorMessage]);
...
<ErrorText>{errorMessage}</ErrorText>
<Button
title="Login"
onPress={_handleLoginButtonPress}
disabled={disabled}
/>
<Button
title="Sign up with email"
onPress={() => navigation.navigate('Signup')}
isFilled={false}
/>
</Container>
</KeyboardAwareScrollView>
);
disabled 상태 변수를 추가하여 버튼의 활성화/비활성화 상태를 관리함useEffect 훅을 사용하여 이메일(email), 비밀번호(password) 입력 여부와 에러 메시지(errorMessage) 존재 여부를 감시함disabled props를 전달하여 조건 불충족 시 버튼을 누를 수 없도록 처리함Button.js
...
const Container = styled.TouchableOpacity`
...
opacity: ${({ disabled }) => (disabled ? 0.5 : 1)};
`;
...
const Button = ({
containerStyle,
title,
onPress,
isFilled,
disabled,
}) => {
return (
<Container
style={containerStyle}
onPress={onPress}
isFilled={isFilled}
disabled={disabled}
>
<Title isFilled={isFilled}>{title}</Title>
</Container>
);
};
Button.defaultProps = {
isFilled: true,
disabled: false,
};
Button.propTypes = {
...
disabled: PropTypes.bool,
};
export default Button;
스택 네비게이션을 사용하기때문에 헤더가 있어야 한다.(이전 화면으로 돌아가기 위함)
theme.js
headerTintColor: colors.black,
헤더에서 사용할 색상을 정의한다
AuthStack.js
...
<Stack.Navigator
...
>
<Stack.Screen
name="Login"
component={Login}
options={{ headerShown: false }}
/>
<Stack.Screen name="Signup" component={Signup} />
</Stack.Navigator>
...
AuthStack.js
...
<Stack.Navigator
initialRouteName="Login"
screenOptions={{
headerTitleAlign: 'center',
cardStyle: { backgroundColor: theme.background },
headerTintColor: theme.headerTintColor,
}}
>
<Stack.Screen
name="Login"
component={Login}
options={{ headerShown: false }}
/>
<Stack.Screen
name="Signup"
component={Signup}
options={{ headerBackTitleVisible: false }}
/>
</Stack.Navigator>
...
아이폰의 노치디자인에 가려 헤더가 안 보일 수 있다. UI를 개선해보자.
Login.js
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const Container = styled.View`
...
padding: 0 20px;
padding-top: ${({ insets: { top } }) => top}px;
padding-bottom: ${({ insets: { bottom } }) => bottom}px;
`;
...
const Login = ({ navigation }) => {
const insets = useSafeAreaInsets();
...
return (
<KeyboardAwareScrollView ...>
<Container insets={insets}>
...
</Container>
</KeyboardAwareScrollView>
);
};
회원가입시 이미지를 원형으로 보이게 할 예정이다.
Image.js
const StyledImage = styled.Image`
background-color: ${({ theme }) => theme.imageBackground};
width: 100px;
height: 100px;
border-radius: ${({ rounded }) => (rounded ? 50 : 0)}px;
`;
const Image = ({ uri, imageStyle, rounded }) => {
return (
<Container>
<StyledImage source={{ uri: uri }} style={imageStyle} rounded={rounded} />
</Container>
);
};
Image.defaultProps = {
rounded: false,
};
Image.propTypes = {
uri: PropTypes.string,
imageStyle: PropTypes.object,
rounded: PropTypes.bool,
};
export default Image;
Signup.js
import React, { useState, useRef, useEffect } from 'react';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import { Image, Input, Button } from '../components';
import { validateEmail, removeWhitespace } from '../utils/common';
const ErrorText = styled.Text`
align-items: flex-start;
width: 100%;
height: 20px;
margin-bottom: 10px;
line-height: 20px;
color: ${({ theme }) => theme.errorText};
`;
const Signup = ({ navigation }) => {
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 emailRef = useRef(null);
const passwordRef = useRef(null);
const passwordConfirmRef = useRef(null);
useEffect(() => {
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 6 characters at least.';
} else if (password !== passwordConfirm) {
_errorMessage = 'Passwords need to match.';
} else {
_errorMessage = '';
}
setErrorMessage(_errorMessage);
}, [name, email, password, passwordConfirm]);
useEffect(() => {
setDisabled(!(name && email && password && passwordConfirm && !errorMessage));
}, [name, email, password, passwordConfirm, errorMessage]);
const _handleSignupButtonPress = () => {
// TODO: signup logic
};
return (
<KeyboardAwareScrollView ...>
<Container>
<Image rounded />
<Input ... label="Name" value={name} ... />
<Input ... label="Email" value={email} ... />
<Input ... label="Password" value={password} ... />
<Input ... label="Password Confirm" value={passwordConfirm} ... />
<ErrorText>{errorMessage}</ErrorText>
<Button title="Signup" onPress={_handleSignupButtonPress} disabled={disabled} />
</Container>
</KeyboardAwareScrollView>
);
};
useEffect를 사용하여 입력값이 변경될 때마다 실시간으로 유효성 검사를 수행하고 에러 메시지를 업데이트함disabled 상태를 관리함useRef를 활용하여 엔터키 입력 시 다음 입력창으로 포커스가 자동으로 이동하도록 사용자 경험(UX)을 개선함Signup.js
...
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 6 characters at least.';
} else if (password !== passwordConfirm) {
_errorMessage = 'Passwords need to match.';
} else {
_errorMessage = '';
}
setErrorMessage(_errorMessage);
} else {
didMountRef.current = true;
}
}, [name, email, password, passwordConfirm]);
...
useRef를 사용하여 컴포넌트 마운트 여부를 추적하는 didMountRef 변수를 추가함useEffect 내부에서 didMountRef.current 값을 확인하여 초기 렌더링 시에는 유효성 검사 로직이 실행되지 않도록 수정함0 20px에서 40px 20px로 수정하여 상하 여백을 확보함utils/image.js
photo: `${prefix}/photo.png?alt=media`,
회원가입 이미지를 파이어베이스에서 불러오도록 한다
Signup.js
import { images } from '../utils/images'
...
const Signup = ({ navigation }) => {
const [photoUrl, setPhotoUrl] = useState(images.photo)
...
return (
...
<Container>
<Image rounded url={photoUrl}/>
...
</Container>
...
);
};
유저가 직접 갤러리에 있는 이미지를 삽입할 수 있도록 버튼을 만든다.
theme.js
imageBackground: colors.grey_0,
imageButtonBackground: colors.grey_1,
imageButtonIcon: colors.white,
버튼에 들어갈 색상을 정의한다
image.js
import { MaterialIcons } from '@expo/vector-icons';
...
const ButtonContainer = styled.TouchableOpacity`
background-color: ${({ theme }) => theme.imageButtonBackground};
position: absolute;
bottom: 0;
right: 0;
width: 30px;
height: 30px;
border-radius: 15px;
justify-content: center;
align-items: center;
`;
const ButtonIcon = styled(MaterialIcons).attrs({
name: 'photo-camera',
size: 22,
})`
color: ${({ theme }) => theme.imageButtonIcon};
`;
const PhotoButton = ({ onPress }) => {
return (
<ButtonContainer onPress={onPress}>
<ButtonIcon />
</ButtonContainer>
);
};
...
const Image = ({ url, imageStyle, rounded, showButton, onPress }) => {
return (
<Container>
<StyledImage source={{ uri: url }} style={imageStyle} rounded={rounded} />
{showButton && <PhotoButton onPress={onPress} />}
</Container>
);
};
...
PhotoButton, ButtonContainer, ButtonIcon 컴포넌트를 구현함Image 컴포넌트의 props로 showButton을 전달받아 true일 경우에만 카메라 버튼이 렌더링되도록 조건부 렌더링을 적용함showButton이 활성화된 경우 사용자가 버튼을 클릭하면 onPress 함수가 실행되도록 연결함uri prop의 이름을 url로 변경하고, Image 컴포넌트 사용처에서도 이를 반영하도록 수정함Signup.js
...
<Container>
<Image rounded url={photoUrl} showButton />
...
</Container>
...
Image 컴포넌트에 showButton 속성을 추가함showButton 값을 true로 설정하여 프로필 이미지 우측 하단에 카메라 아이콘 버튼이 보이도록 활성화함갤러리에 직접 접근하여 사진을 불러오도록 구현해보자
npm install expo-image-picker
expo-image-picker 설치
Image.js
import { Platform, Alert } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
...
const Image = ({ url, imageStyle, rounded, showButton, onChangeImage }) => {
useEffect(() => {
(async () => {
try {
if (Platform.OS === 'ios') {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Photo Permission',
'Please turn on the camera roll permissions.'
);
}
}
} catch (e) {
Alert.alert('Photo Permission Error', e.message);
}
})();
}, []);
const _handleEditButton = async () => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 1,
});
if (!result.canceled) {
onChangeImage(result.assets[0].uri);
}
} catch (e) {
Alert.alert('Photo Error', e.message);
}
};
return (
<Container>
<StyledImage source={{ uri: url }} style={imageStyle} rounded={rounded} />
{showButton && <PhotoButton onPress={_handleEditButton} />}
</Container>
);
};
Signup.js
...
<Container>
<Image
rounded
url={photoUrl}
showButton
onChangeImage={url => setPhotoUrl(url)}
/>
...
</Container>
...
이제 파이어베이스에서 정보를 요청하고 전송, 인증받는 코드를 구현해보자
Login.js
import { Alert } from 'react-native';
import { login } from '../utils/firebase';
...
const _handleLoginButtonPress = async () => {
try {
const user = await login({ email, password });
Alert.alert('Login Success', user.email);
} catch (e) {
Alert.alert('Login Error', e.message);
}
};
...
<Button title="Login" onPress={_handleLoginButtonPress} disabled={disabled} />
_handleLoginButtonPress를 구현함disabled 속성을 Login 버튼에 적용함Signup.js
import { Alert } from 'react-native';
import { signup } from '../utils/firebase';
...
const _handleSignupButtonPress = async () => {
try {
if (disabled) return;
const trimmedName = name.trim();
const trimmedEmail = removeWhitespace(email);
const trimmedPassword = removeWhitespace(password);
// (중략: 유효성 검사 로직)
const user = await signup({
email: trimmedEmail,
password: trimmedPassword,
name: trimmedName,
photoUrl,
});
Alert.alert('Signup Success', user?.email ?? trimmedEmail);
navigation.navigate('Login');
} catch (e) {
Alert.alert('Signup Error', e?.message ?? 'Unknown error');
}
};
...
signup 함수를 호출하여 회원가입 정보를 전송하는 비동기 함수 _handleSignupButtonPress를 구현함Login)으로 이동하도록 내비게이션 처리를 추가함catch 블록에서 에러 메시지를 알림창으로 표시하여 사용자에게 안내함Signup.js
const _handleSignupButtonPress = async () => {
try {
const trimmedName = name.trim();
const trimmedEmail = removeWhitespace(email);
const trimmedPassword = removeWhitespace(password);
const trimmedPasswordConfirm = removeWhitespace(passwordConfirm);
const user = await signup({
email: trimmedEmail,
password: trimmedPassword,
name: trimmedName,
photoUrl,
});
console.log(user);
Alert.alert('Signup Success', user.email);
} catch (e) {
Alert.alert('Signup Error', e.message);
}
};
useEffect에서 disabled 상태를 관리하고 있으므로, 버튼 클릭 시에는 별도의 중복 검사 없이 바로 회원가입 요청을 보내도록 수정함로그인과 회원가입이 완전히 진행 되기 전 다른 버튼이 눌리지 않도록 대기시키는 스피너를 구현해보자
theme.js
spinnerBackground: colors.black,
spinnerIndicator: colors.white,
색상을 정의한다
Spinner.js
import React, { useContext } from 'react';
import { ActivityIndicator } from 'react-native';
import styled, { ThemeContext } from 'styled-components/native';
const Container = styled.View`
position: absolute;
z-index: 2;
opacity: 0.3;
width: 100%;
height: 100%;
justify-content: center;
background-color: ${({ theme }) => theme.spinnerBackground};
`;
const Spinner = () => {
const theme = useContext(ThemeContext);
return (
<Container>
<ActivityIndicator size="large" color={theme.spinnerIndicator} />
</Container>
);
};
export default Spinner;
ActivityIndicator를 사용하여 기본 로딩 인디케이터를 화면 중앙에 배치함styled-components를 활용해 화면 전체를 덮는 절대 위치(position: absolute)의 반투명 배경 컨테이너를 구현함z-index를 2로 설정하여 다른 UI 요소들보다 위에 나타나도록 처리함useContext로 현재 테마 정보를 가져와 인디케이터 색상(color)을 동적으로 설정함Spinner.js 를 components의 index와 navigations의 index에 등록한다
contexts/Progress.js
import React, { useState, createContext } from 'react';
const ProgressContext = createContext({
inProgress: false,
spinner: () => {},
});
const ProgressProvider = ({ children }) => {
const [inProgress, setInProgress] = useState(false);
const spinner = {
start: () => setInProgress(true),
stop: () => setInProgress(false),
};
const value = { inProgress, spinner };
return (
<ProgressContext.Provider value={value}>
{children}
</ProgressContext.Provider>
);
};
export { ProgressContext, ProgressProvider };
contexts/index.js
import { ProgressContext, ProgressProvider } from './Progress';
export { ProgressContext, ProgressProvider };
contexts/index.js에 Progress.js를 등록해준다. 파일을 더 쉽게 관리하기 위함이다.
App.js
import { ProgressProvider } from './contexts';
...
const App = () => {
...
return isReady ? (
<ThemeProvider theme={theme}>
<ProgressProvider>
<StatusBar barStyle="dark-content" />
<Navigation />
</ProgressProvider>
</ThemeProvider>
) : (
...
);
};
Login.js
import React, { useState, useRef, useEffect, useContext } from 'react';
import { ProgressContext } from '../contexts';
...
const Login = ({ navigation }) => {
const { spinner } = useContext(ProgressContext);
...
const _handleLoginButtonPress = async () => {
try {
spinner?.start?.();
const user = await login({ email, password });
Alert.alert('Login Success', user.email);
} catch (e) {
Alert.alert('Login Error', e.message);
} finally {
spinner?.stop?.();
}
};
...
};
_handleLoginButtonPress)의 시작 부분에서 spinner.start()를 호출하여 로딩 인디케이터를 화면에 표시함await login)이 완료되거나 에러가 발생하면, finally 블록에서 spinner.stop()을 호출하여 스피너를 숨김Signup.js
import React, { useState, useRef, useEffect, useContext } from 'react';
import { ProgressContext } from '../contexts';
...
const Signup = ({ navigation }) => {
const { spinner } = useContext(ProgressContext);
...
const _handleSignupButtonPress = async () => {
try {
spinner.start();
const trimmedName = name.trim();
const trimmedEmail = removeWhitespace(email);
const trimmedPassword = removeWhitespace(password);
...
Alert.alert('Signup Success', user.email);
} catch (e) {
Alert.alert('Signup Error', e.message);
} finally {
spinner.stop();
}
};
...
};
spinner 객체를 Context로부터 구조 분해 할당으로 받아옴_handleSignupButtonPress) 시작 부분에 spinner.start()를 호출하여 로딩 화면을 표시함finally 블록) spinner.stop()을 호출하여 스피너를 제거함으로써, 회원가입 진행 중임을 사용자에게 시각적으로 알리는 로직을 완성함현재 파이어베이스에서 문제가 생겨 무한 로딩이 된다... 책과 달라진 부분이 많아 찾으면서 오류를 고치고 있는 중이다. 다 고쳐지면 글 수정해야지 ㅜ