React-Native Toast 커스텀 제작기

박재현 ( Jcurver )·2022년 11월 28일
0

react native에서는 앱 내 알림을 위해서 주로 toast를 사용합니다. toast관련해서 다양한 npm패키지가 있습니다.
관련된 인기 패키지는 여기에서 찾아볼 수 있습니다.

프로젝트에서는 기존에 ios toast를 위해서 react-native-simple-toast를 사용하고 있었습니다. 하지만 Toast 요구사항이 고도화되면서 기존 라이브러리로 커버하기 힘든 케이스가 생겨나기 시작했습니다. 기존에 사용중인 react-native-simple-toast는 너비,색상조차 커스텀이 어려울 정도로 굉장히 스타일링에 제한적었으며 타 라이브러리 역시 요구조건을 모두 갖추기에는 어려움이 있었습니다.

그래서, 프로젝트에서 향후 디자인 요구조건이 계속 고도화되어도 스타일에 상관없이 바로 대응할 수 있도록 toast를 직접 만들어보기로 했습니다.

현재 프로젝트의 요구사항은 메인 사진과 같습니다. (+fade in/out 기능)
toast는 버튼처럼 컴포넌트로 간단히 감싸는 작업만으로는 제작할 수 없었습니다. 그 이유는,

  1. 모든 화면에서 screen에 대하여 같은 레이어 레벨에 위치해야한다.
  2. toast 컴포넌트 뒤의 레이어가 항상 클릭 가능해야한다.
  3. pagenation이 일어날 때 toast를 띄우는 경우 최종적으로 이동하는 페이지에서 toast에 들어갈 데이터와 event를 감지해야한다.
  4. 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한 값으로 만들어야 합니다.

profile
FE developer / Courage is very important when it comes to anything.

0개의 댓글