react native에서는 앱 내 알림을 위해서 주로 toast를 사용합니다. toast관련해서 다양한 npm패키지가 있습니다.
관련된 인기 패키지는 여기에서 찾아볼 수 있습니다.
프로젝트에서는 기존에 ios toast를 위해서 react-native-simple-toast를 사용하고 있었습니다. 하지만 Toast 요구사항이 고도화되면서 기존 라이브러리로 커버하기 힘든 케이스가 생겨나기 시작했습니다. 기존에 사용중인 react-native-simple-toast는 너비,색상조차 커스텀이 어려울 정도로 굉장히 스타일링에 제한적었으며 타 라이브러리 역시 요구조건을 모두 갖추기에는 어려움이 있었습니다.
그래서, 프로젝트에서 향후 디자인 요구조건이 계속 고도화되어도 스타일에 상관없이 바로 대응할 수 있도록 toast를 직접 만들어보기로 했습니다.
현재 프로젝트의 요구사항은 메인 사진과 같습니다. (+fade in/out 기능)
toast는 버튼처럼 컴포넌트로 간단히 감싸는 작업만으로는 제작할 수 없었습니다. 그 이유는,
- 모든 화면에서 screen에 대하여 같은 레이어 레벨에 위치해야한다.
- toast 컴포넌트 뒤의 레이어가 항상 클릭 가능해야한다.
- pagenation이 일어날 때 toast를 띄우는 경우 최종적으로 이동하는 페이지에서 toast에 들어갈 데이터와 event를 감지해야한다.
- Animation을 구현해야한다.
정도가 있었습니다. 1번부터 차근차근 해결과정을 따라가보겠습니다.
먼저 Screen.js파일 내부에 아래와 같이 Toast를 심어주었습니다.
메인 View 바로 아래에 심어줍니다.
import React, { useRef } from 'react';
import { StatusBar, StyleSheet, View, SafeAreaView } from 'react-native';
import Toast from '../component/toast';
const Screen = ({
type = 'safe',
bgColor = 'white',
children,
toastMargin,
bottomSafeAreaColor = 'none',
topSafeAreaColor = 'none',
topSafeArea = true,
bottomSafeArea = true,
toastText = undefined,
style,
}) => {
const Container = type === 'safe' ? SafeAreaView : View;
const toastRef = useRef(null);
return (
<>
{topSafeArea && (
<SafeAreaView style={{ backgroundColor: topSafeAreaColor }} />
)}
<Container style={[styles.container, style]}>
<StatusBar barStyle="dark-content" backgroundColor={bgColor} />
<View style={dstyles(bgColor).ScreenView}>{children}</View>
<Toast ref={toastRef} toastText={toastText} toastMargin={toastMargin} />
</Container>
{bottomSafeArea && bottomSafeAreaColor !== 'none' ? (
<SafeAreaView style={{ backgroundColor: bottomSafeAreaColor }} />
) : (
<></>
)}
</>
);
};
export default Screen;
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
const dstyles = (bgColor) =>
StyleSheet.create({
ScreenView: {
flex: 1,
backgroundColor: bgColor,
},
});
다음으로는 Toast.js파일을 살펴보겠습니다.
import { WINDOW_WIDTH } from '@gorhom/bottom-sheet';
import React, {
useState,
useRef,
useCallback,
forwardRef,
useImperativeHandle,
} from 'react';
import { useEffect } from 'react';
import { StyleSheet, Text } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withSequence,
runOnJS,
} from 'react-native-reanimated';
import { toSize } from '../config/globalStyle';
const Toast = forwardRef((props, ref) => {
useEffect(() => {
if (props.toastText) {
ref?.current?.show(props.toastText);
}
}, [props.toastText, ref]);
const [message, setMessage] = useState('');
const toastOpacity = useSharedValue(0);
const isShowed = useRef(false);
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: toastOpacity.value,
};
}, []);
useImperativeHandle(ref, () => ({ show }));
const turnOnIsShow = useCallback(() => {
isShowed.current = false;
}, []);
const show = useCallback(
(message) => {
setMessage(message);
isShowed.current = true;
toastOpacity.value = withSequence(
withTiming(0.94, { duration: 300 }),
withTiming(0.94, { duration: 800 }),
withTiming(0, { duration: 300 }, () => {
runOnJS(turnOnIsShow)();
}),
);
},
[toastOpacity, turnOnIsShow],
);
if (!props.toastText) {
return <></>;
}
return (
<Animated.View
pointerEvents="box-none"
style={[
styles.rootContainer,
animatedStyle,
props.toastMargin && { marginTop: toSize(40) },
]}
>
<Text style={styles.message}>{message}</Text>
</Animated.View>
);
});
const styles = StyleSheet.create({
rootContainer: {
position: 'absolute',
backgroundColor: 'rgb(47,47,47)',
paddingVertical: toSize(9),
width: WINDOW_WIDTH - toSize(44),
marginLeft: toSize(22),
borderRadius: toSize(6),
justifyContent: 'center',
alignItems: 'center',
},
message: {
color: 'rgb(255, 255, 255)',
},
});
export default Toast;
애니메이션을 위해서는 React-native에서 유명한 react-native-reanimated를 사용했습니다. 기본적으로 toast 컴포넌트는 항상 화면위에 존재하고, opacity를 useSharedValue Hook을 사용해서 조절하여 마치 컴포넌트가 존재하지 않다가 나타나듯 표현했습니다. 또한 컴포넌트가 클릭에 반응하지 않도록 Animated.View 내부에 pointerEvents="box-none" 옵션도 적용했습니다.
또한 부모 컴포넌트에서 toast의 ref를 조작하기 위해서 ForwardRef를 사용하였습니다. ForwardRef에 대한 자세한 내용은 여기에 있습니다.
이와 같이 구현하여서 toast가 필요한 페이지에서
const [toastText,setToastText] = useState()
useEffect(()=>{
if(params?.toastText){
setToastText(params.toastText)
}
},[params?.toastText])
return(<Screen toastText={toastText}> {children} </Screen>)
이런 형태로 사용할 수 있게 되었습니다.
하지만 이게 끝이 아니었다. 두 번 이상 같은 텍스트를 요청하는경우 setToastText에서 메시지 변화를 감지하지 못해서 Toast가 화면에 나타나지 않는 현상이 발생했습니다.
그래서 updateSameText 함수를 아래와 같이 만듭니다.
const updateSameText = (text1, text2) => {
if (text1 === text2) {
if (text1 === undefined) {
return undefined;
}
return ' ' + text1 + ' ';
} else {
return text1;
}
};
export default updateSameText;
이후 기존에 페이지에서의 코드를 수정합니다.
const [toastText,setToastText] = useState()
useEffect(()=>{
if(params?.toastText){
setToastText((text)=>updateSameText(params.toastText, text))
}
},[params?.toastText])
return(<Screen toastText={toastText}> {children} </Screen>)
이렇게 하면 업데이트될 때 마다 앞뒤로 공백이 붙어서, 1줄짜리 가운데 정렬 toast에 대해서 해결이 됩니다.
하지만 2줄이상의 Toast일 때 문자열의 위치가 다소 이동한듯 보여질 수 있습니다. 이부분을 해결하기 위해서는 setState는 얕은비교를 하기때문에, toastText를 Object형태로 만들어서 key값을 넣어 강제로 immutable한 값으로 만들어야 합니다.