다짐) React Native Modal Refactoring (Part.1 Custom Modal 만들기)

2ast·2023년 8월 20일
0
post-custom-banner

구현해놓은 모든 Modal을 갈아 엎었던 이유

다짐 알보칠 프로젝트(다짐을 RN으로 처음부터 다시 만드는 프로젝트) 막바지에 이르러서 지금까지 구현했던 모든 Modal component를 갈아엎는 결정을 했다. 여기서 표현하는 '갈아엎었다'는 크게 두가지 관점에서 진행되었다.

  1. 필요한 모달을 하나씩 구현해서 스크린 단위로 넣어주었던 방식에서 root에 modal layout component를 배치하고, 그 contents를 전역상태로 핸들링하는 방식으로 바꾸었다.

  2. React Native에서 제공하는 Modal component를 기반으로 작성되었던 모달을 모두 View, Pressable 등 컴포넌트를 이용해 직접 커스텀해서 제작했다.

모달을 전역상태로 관리하게 된 이유

기존에는 필요한 모달을 하나씩 구현해서 스크린 컴포넌트에 직접 배치해주었다.

const Home =() =>{
  const [isAModalVisible,setIsAModalVisible] = useState(false);
  const [isBModalVisible,setIsBModalVisible] = useState(false);
  const [isCModalVisible,setIsCModalVisible] = useState(false);
  
  return <HomeLayout>
    ...
    <AModal isVisible={isAModalVisible} setIsVisible={setIsAModalVisible}/>
    <BModal isVisible={isBModalVisible} setIsVisible={setIsBModalVisible}/>
    <CModal isVisible={isCModalVisible} setIsVisible={setIsCModalVisible}/>
  </HomeLayout>
}

하지만 이렇게 하나씩 배치할 경우 스크린 컴포넌트에 modal과 관련된 state가 과하게 많아진다는 단점이 있다. 각 모달마다 visible state가 필요하고, 만약 아이템 삭제나 수정 등 사용자 액션에 따라 특정 함수가 실행되어야 할 경우 state와 function이 modal의 props로 추가되어야 하기도 한다.

const SCREEN =() =>{
  const [isItemDeleteConfirmModalVisible,setIsItemDeleteConfirmModalVisible] = useState(false);
  const [deletingItem,setDeletingItem] = useState();
  
  const onPressDelete =(id:string) =>{
  	...
    setIsItemDeleteConfirmModalVisible(false)
  }
    
  const onPressCancel =() =>{
    ...
    setIsItemDeleteConfirmModalVisible(false)
  }
  
  return <HomeLayout>
    ...
    <ItemDeleteConfirmModal 
      isVisible={isItemDeleteConfirmModalVisible} 
      deletingItem={deletingItem} 
      onPressDelete={onPressDelete} 
      onPressCancel={onPressCancel}/>
  </HomeLayout>
}

이렇듯 과도한 스테이트 선언과 장황한 props들로 인해 스크린 컴포넌트의 가독성이 떨어지는 문제를 방지하고자 modal layout을 프로젝트 root에 배치하고, contents를 전역상태로 핸들링해서 띄우는 방식으로 변경했다.

RN에서 제공해주는 Modal을 사용하지 않고 직접 구현한 이유

React Native 중첩 네비게이션 구조 설계하기에서도 언급했듯이, ios의 modal은 같은 레이어에 한개밖에 띄울 수 없다. 그리고 modal presentation screen도 modal, modal도 modal, bottom sheet도 modal이다. 생각보다 별거 아닌 제약조건 같지만 실제로 앱을 구현하다보면 꽤나 거슬리는 이슈를 많이 만들어낸다.
이 이슈에 대응하기 위해 모달이 모달이 아니게 만들어버렸다. RN의 Modal 컴포넌트를 사용하는게 아니라, 모달처럼 보이도록 뷰 컴포넌트를 작성하고, 스크린의 최상단에 배치해두었다. 이로써 이제는 ios에서도 modal로 취급되지 않으므로, modal의 제약조건을 회피할 수 있게 되었다.

모달 컴포넌트 만들기

Background Layer 만들기

모달이 떴을 때 배경에 반투명한 레이어를 깔아주기 위해 OverlayContainer component를 만들어주었다. 이 컴포넌트는 bottom sheet 등 다양한 곳에 재활용될 수 있기 때문에 AnimatedView로 제작해주어 fadeIn 효과를 활성화할 수 있게 해주는 등 확장성을 어느정도 고려해서 작성했다.

const OverlayBackground = styled(Animated.View)<{
  width?: number;
  height?: number;
}>`
  justify-content: center;
  align-items: center;
  position: absolute;
  width: ${({width}) => (width ? `${width}px` : '100%')};
  height: ${({height}) => (height ? `${height}px` : '100%')};
`;

interface OverlayContainerProps {
  width?: number;
  height?: number;
  hideBackground?: boolean;
  children: React.ReactNode;
  style?: StyleProp<ViewStyle>;
  onBackButtonPress?: () => void;
  animationEnabled?: boolean;
  opacity?: number;
}

const OverlayContainer = ({
  width,
  height,
  hideBackground,
  children,
  style,
  onBackButtonPress,
  animationEnabled = false,
  opacity = 0.3,
}: OverlayContainerProps) => {
  const onBackPress = () => {
    onBackButtonPress && onBackButtonPress();
    return true;
  };
  useAndroidBackHandler({onBackPress});
  return (
    <OverlayBackground
      width={width}
      height={height}
      style={style}
      entering={animationEnabled ? FadeIn.duration(300) : undefined}
      exiting={animationEnabled ? FadeOut.duration(300) : undefined}>
      {hideBackground ? null : <FillOverlayOpacity opacity={opacity} />}
      {children}
    </OverlayBackground>
  );
};

export default OverlayContainer;

코드를 보면 useAndroidBackHandler 훅을 쓰고 있는 부분이 있는데, 이 부분은 android에서 물리 버튼을 통해 모달을 끌 수 있도록 listener를 설정해주는 커스텀 훅이다. useAndroidBackHandler의 간단한 내부 구현체는 이렇게 생겼다.(실제 다짐에서는 예외처리를 위해 몇가지 로직이 추가되어 있다.)

const useAndroidBackHandler = ({onBackPress}: {onBackPress: () => boolean}) => {

  useEffect(() => {
    BackHandler.addEventListener('hardwareBackPress', onBackPress);
    
    return () => {
      BackHandler.removeEventListener('hardwareBackPress', onBackPress);
    };
  }, [onBackPress]);

};

이제 실제로 모달이 그려질 layout component를 만들 예정이다. 컴포넌트 구조를 중심으로 코드를 생략했으니, 각각 요구된 모달의 디자인대로 커스텀해서 사용하면 된다.



const Container = styled.Pressable`
  //화면 전체를 덮는 투명한 Pressable layer
  //모달의 여백을 눌렀을 때 이벤트를 발생시키기 위해 추가했다.
`;

const ModalContainer = styled.Pressable`
  //실제로 화면에 그려질 모달의 영역 컨테이너
`;

const ContentContainer = styled.View`
  //모달의 컨텐츠 영역 컨테이너
`;

const ModalButton = styled.TouchableOpacity`
  //모달의 버튼 컴포넌트
`;

const ModalLayout = ({
  title,
  content,
  mainButtonLabel,
  onMainButtonPress
  subButtonLabel,
  onSubButtonPress,
  disabled,
  contentContainerStyle,
  onBackdropPress,
}: DgModalProps) => {
  const theme = useTheme();

  const {width, height} = useWindowDimensions();

  return (
    <Container onPress={onBackdropPress} width={width} height={height}>
      <ModalContainer width={width}>
        <ContentContainer style={contentContainerStyle}>
          //모달의 컨텐츠를 정의
        </ContentContainer>
        <Row>
          //모달의 버튼을 정의
        </Row>
      </ModalContainer>
    </Container>
  );
};
export default ModalLayout;

여기서 ModalContainer에 onPress를 넘겨주지 않음에도 Pressable로 선언한 이유는, 배경에 깔리는 Container가 Pressable로 선언되어 onBackdropPress를 받고 있기 때문이다. 만약 ModalContainer가 View로 선언됐다면 모달의 내부를 터치해도 onBackdropPress가 호출된다. 따라서 ModalContainer를 Pressable로 선언함으로써 터치 이벤트가 Container까지 도달하지 못하도록 해주었다.

const Modal = ({isActiveRoute}: {isActiveRoute: boolean}) => {
  const modalProps = useDialogHandler(state => state.modalProps);

  const isShown = modalProps && isActiveRoute;

  return isShown ? (
    <OverlayContainer
      onBackButtonPress={modalProps.onBackButtonPress}
      hideBackground={modalProps?.hideBackground}>
      <ModalLayout {...modalProps} />
    </OverlayContainer>
  ) : null;
};
export default React.memo(Modal);

위에서 만들어준 OverlayContainer와 ModalLayout을 조립하여 최종적인 Custom Modal component가 만들어졌다. Modal의 특성상 부모가 리렌더 될때마다 다시 렌더링될 필요가 없기 때문에 memo로 감싸주었다.

참고로 위 코드에서 보이는 isActiveRoute와 modalProps는 전역으로 모달을 관리할 때 필요한 부분이므로 다음 파트에서 다루도록 할 예정이다.

profile
React-Native 개발블로그
post-custom-banner

0개의 댓글