다짐) React Native Modal Refactoring (Part.2 전역 상태로 핸들링하기)

2ast·2023년 8월 20일
0

지난 시간에 이어, 이번에는 모달에 보여지는 컨텐츠를 전역상태로 핸들링해보도록 하겠다. 다짐에서는 zustand를 전역상태 라이브러리로 채택하고 있기 때문에 zustand를 기준으로 설명하겠지만, 어떤 상태관리 라이브러리를 쓰든 완전히 무방하다.

store 만들기 (전역상태 선언하기)

가장 먼저 store를 생성해준다. 나는 modalProps state 하나와 modalProps를 셋팅해주는 openModal, 그리고 modalProps를 초기화하는 closeModal 두개의 함수를 정의해주었다. (참고로 다짐에서는 modal, bottom sheet 등 현재 스크린에 오버레이 되서 사용자에게 메시지를 전달하는 컴포넌트들을 dialog로 묶어서 관리하고 있기 때문에 store 이름을 dialogHandler로 명명했다.)

import {create} from 'zustand';

import {
  DgModalProps,
} from '~/types/overlay/dialog-props.type';

interface DialogHandlerState {
  modalProps?: DgModalProps;
  openModal: (modalProps: DgModalProps) => void;
  closeModal: () => void;
}

export const useDialogHandler = create<DialogHandlerState>(set => ({
  modalProps: undefined,
  openModal: (
    modalProps: DgModalProps,
  ) =>
	set({modalProps}),
  closeModal: () => set({modalProps: undefined}),
}));

Dialog Provider 만들기

이제 아까 만들어둔 Modal 컴포넌트를 루트에 배치해야하는데, 코드 재사용성과 높은 가독성을 위해 Dialog Provider라는 Wrapper를 제작해주기로 했다.


type OverlayScreenNameType = 'root' | ValueOf<ScreenNameTypes>;

interface DialogProviderProps {
  children: React.ReactNode;
  isRoot?: boolean;
  screenName: OverlayScreenNameType;
}


const DialogProvider = ({
  children,
  isRoot = false,
  screenName,
}: DialogProviderProps) => {
  const {width, height} = useWindowDimensions();

  const isActiveRoute = useGetIsActiveRoute({screenName, isRoot});

  if (!isRoot && isAndroid) {
    return <>{children}</>;
  }
  return (
    <>
      {children}
      <DgModal isActiveRoute={isActiveRoute} />
    </>
  );
};

export default DialogProvider;

export const withOverlay = (
  InnerComponent: () => JSX.Element,
  screenName: OverlayScreenNameType,
) => {
  return () => (
    <DialogProvider screenName={screenName}>
      <InnerComponent />
    </DialogProvider>
  );
};

여기서부터 조금 고려할게 많아진다. useGetIsActiveRoute는 무엇인지, 특정 조건에서 왜 바로 children을 반환해주는지 궁금하겠지만 일단은 넘어가고 뒤에서 조금 더 자세히 다뤄보도록 하겠다.
그리고 하단에 보면 withOverlay라는 함수가 있는데, 필요에 따라 편리한 형태로 DialogProvider를 적용할 수 있도록 HOC 형태로도 구현해둔 것이다. (실제 다짐에서는 DialogProvider를 ToastProvider와 함께 감싼 OverlayProvider가 존재하기 때문에 HOC의 이름이 withOverlay로 명명되어 있다.)

DialogProvider로 navigator 감싸주기

이제 지금까지 제작한 DialogProvider를 root에 감싸주면 된다.


const Stack = createNativeStackNavigator();
const RootStackNavigator = () => {
  return (
    <DialogProvider isRoot={true} screenName={'root'}>
      <Stack.Navigator>
        <Stack.Screen
          name={SCREEN_NAMES.SIGN_STACK}
          component={SignStackNavigator}
          options={{
            presentation: 'modal',
          }}
        />
        <Stack.Screen
          name={SCREEN_NAMES.SEARCH_STACK}
          component={SearchStackNavigator}
          options={{
            presentation: 'fullScreenModal',
          }}
        />
        <Stack.Screen
          name={SCREEN_NAMES.PAYMENT_RESULT}
          component={PaymentResultScreen}
          options={{
            presentation: 'fullScreenModal',
          }}
        />
		...
      </Stack.Navigator>
    </DialogProvider>
  );
};

export default RootStackNavigator;

openModal custom hook 만들기

이제 루트에 모달 컴포넌트 배치가 끝났으니, 실제로 global state에 값을 할당해서 모달을 띄워보자.

const {openModal,closeModal} = useDialogHandler()

const onSignOut = () =>{
	...
}

const openSignOutConfirmModal = () => {
  openModal(
    {
      title: '로그아웃 하시겠어요?',
      content: "언제든지 다시 로그인하실 수 있어요.",
      mainButtonLabel: '로그아웃',
      onMainButtonPress: onSignOut,
      subButtonLabel: '다음에',
      onSubButtonPress: closeModal,
      onBackdropPress: closeModal,
      onBackButtonPress: closeModal,
    }
  );
};

참고로, 현재 다짐은 스크린 단위로 modal handling custom hook을 만들어 사용하고 있다.

const {openSignOutConfirmModal, openFindIdNotFoundModal} = useSignDialogHandler()

이대로 끝이 아니다. (IOS modal screen 대응)

이제 모든게 해피하게 동작하는 커스텀 모달 리팩토링이 모두 끝난 것일까? 슬프게도 그렇지 않다. 아까전에 지나쳤던 DialogProvider 내부 로직을 기억하는가? 이제 그 로직에 대해 설명할 시간이다. 이전에도 여러번 언급했듯이 ios의 modal presentation screen들은 모두 modal 취급이 된다. 즉, 현재 보여지는 레이어의 최상단에 삽입되어 나타난다.
RootStackNavigator를 보면 presentation이 각각 modal과 fullScreenModal로 설정된 nvigator와 screen들이 있다. 현재 구조에서 내가 SignStackNavigator를 보고있고, 그곳에서 openModal을 호출하여 모달을 노출시킨다고 해도 모달은 SignStackNavigator 아래 레이어인 RootStackNavigator 단에서 렌더링될 뿐이기 때문에 사용자에게 모달이 보여지지 않는다.

이 문제를 해결하기 위해서는 modal presentation screen들 또한 각각 DialogProvider로 감싸주어, 해당 스크린 레이어에서 모달을 그려야한다.

const SignStackNavigator = () =>{
	...
    return <DialogProvider screenName={SCREEN_NAMES.SIGN_STACK}>
        ...
    </DialogProvider>

}
const PaymentResultScreen = () =>{
	...
    return ...
}
export default withOverlay(PaymentResultScreen, SCREEN_NAMES.PAYMENT_RESULT);

이렇게 해주면 modal presentation screen이 보여지고 있는 상황에서 openModal을 호출해도 현재 스크린 위에 모달이 보일 것이다. 다만 이번에는 android에서 문제가 생긴다. android는 modal presentation screen option을 설정해도 modal로 취급되지 않기 때문이다. 즉 현재 적용된 DialogProvider 갯수만큼 모달이 중첩되어 한번에 노출되는 문제가 생긴다.

이 케이스를 예외처리 하기 위해 isRoot가 false이면서 android인 경우 DialogHandler를 적용되지 않도록 처리한 것이었다.

const DialogProvider = ({
  children,
  isRoot = false,
  screenName,
}: DialogProviderProps) => {

  if (!isRoot && isAndroid) {
    return <>{children}</>;
  }
  
  return (
    <>
      {children}
      <DgModal isActiveRoute={isActiveRoute} />
    </>
  );
};

ios도 아예 문제가 없지 않다. 당장 보여지는 것은 한개의 모달일지 몰라도 실제로는 아래쪽 레이어에 보이지 않는 모달이 하나 더 렌더링 되고 있는 상태이기 때문이다. 일반적인 경우에는 전혀 문제될 것이 없지만, 바텀시트와 같이 나타나고 사라지는데 애니메이션이 있다면 눈에 거슬리게 되는 상황이 연출될 수 있다. 또한 같은 모달이 N개 중첩되어 렌더링되기 때문에 리소스 낭비는 물론 디버깅에도 악영향을 줄 수 있다.

이 케이스를 예외처리 하기 위해 useGetIsActiveRoute hook으로 현재 최상단에 올라와 있는 active route를 찾아내, active route에만 모달을 렌더링하도록 제어해주었다.


const useGetIsActiveRoute = ({
  screenName,
  isRoot,
}: {
  screenName: OverlayScreenNameType;
  isRoot: boolean;
}) => {
  const [isActiveRoute, setIsActiveRoute] = useState(true);

  const navState = useNavigationState(state => state);

  useEffect(() => {
    if (navState) {
      const routes = navState.routes.map(i => i.name);

      const activeRoute = routes[routes.length - 1];
      if (
        activeRoute.includes('Stack') ||
        activeRoute === SCREEN_NAMES.PAYMENT_RESULT
      ) {
        setIsActiveRoute(activeRoute === screenName);
      } else {
        setIsActiveRoute(isRoot);
      }
    }
  }, [navState]);

  return isIOS ? isActiveRoute : isRoot;
};

export default useGetIsActiveRoute;

지난 파트에서 Custom Modal을 만들 때 isActiveRoute를 props로 받았었다. 그 isActiveRoute의 출처가 바로 useGetIsActiveRoute hook이었던 것이다.

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);

이렇게 현재 최상단에 활성화되어 있는 스크린에만 Modal을 띄움으로써 모든 에러케이스를 우회하여 예외처리를 완료할 수 있었다. 이제 행복하게 모달을 사용할 수 있다. 지금까지 모달을 구현한 방법과 동일하게 bottom sheet, toast notification, snackbar, tooltip, loading screen 등을 만들어 추가해주면 버그 없는 클린한 overlay component 컨트롤이 가능해진다.

사실 조금 더 쉬웠으면 좋겠다

솔직히 이렇게 노출 로직에 예외처리 해주는게 쉽다고는 말 못하겠다. 의도된 대로 잘 동작한다고해서 그게 좋은 코드라고 생각하지 않는다. 개인적으로 이렇게 트릭키하게 구현해놓은 코드는 이미 레거시 코드라고 생각한다. 그런 관점에서 모달 리팩토링을 하면서 내가 작성한 코드들은 볼때마다 뿌듯하면서도 한편으론 하루빨리 제거해버리고 싶은 그런 애증의 코드인 것 같다. 아직까지도 과연 RN에서 커스텀 모달을 사용하는 방법 중에 이게 최선인지 늘 고민하고 있다. 혹시라도 더 쉽고 좋은 방법을 공유해주실 분이 계신다면 정말 감사하겠다.

profile
React-Native 개발블로그

2개의 댓글

comment-user-thumbnail
2024년 2월 6일

RN에서 모달을 글로벌로 관리하는 방법을 찾다가 우연히 들어왔는데, 재미있게 읽고 갑니다 ㅎㅎ 좋은 글 감사합니다!

1개의 답글