[react] bottom sheet modal 구현

Ell!·2022년 2월 8일
1

react

목록 보기
23/28
post-thumbnail

개요

지난 여름부터 진행하고 있는 Dor.gg 프로젝트는 web 뷰를 기반으로 모바일 화면에서도 원활하게 사용할 수 있도록 반응형으로 제작하고 있다. 프로젝트 초기에는 모바일 앱을 react native나 flutter를 사용해서 만들려고 했으나 인력 부족으로 (...😥) 웹뷰를 그냥 보여주는 식으로 간단하게 앱을 런칭하였다. 그래서 아무래도 모바일 화면에 맞지 않는 UI가 많이 생겼는데...

문제점

그중 가장 두드러지는 문제점이 바로 모바일 뷰에 어울리지 않는 popover UI 였다.

거슬리는 이 UI를 조금 더 모바일 화면에 어울리는 bottom sheet으로 바꾸기로 하였다.

(material design의 bottom sheet design)

구현

구현에 앞서 구현에 필요한 단계를 생각(명세)해보았다

  1. 유저의 클릭에 modal이 나온다.
  2. modal은 아래쪽에 붙어있되, 바깥쪽을 클릭하면 닫힌다.
  3. modal의 윗부분에 손잡이를 붙여두어서 아래로 snap하면 닫힌다.

우리팀은 modal의 경우 modal을 열고 닫는 open/close를 redux로 관리해주고 있었다.

<Container onClick={() => dispatch('modal open')}>

popover에 children으로 GameProfile component로 전달하고 있었기 때문에, 매칭화면에서의 유저를 click했을 때, modal이 열리고 해당 modal에 children으로 GameProfile component를 전달하기로 하였다.

슈도코드로 표현해보면 다음과 같다.


<MatchingRoom>
  <MatchingUser />
  <MatchingUser />
  <MatchingUser />
  /* ... */
</MatchingRoom>

// MatchingUser 
<MatchingUser onClick={() => dispatch('모달 open')}> 



<Modal>
  <GameProfile />
</Modal>
</MatchingUser> 

여기서 문제가 발생했는데, MatchingRoom에 있는 수많은 MatchingUser에서 모두 modal이 열려버렸다..😫

결국 이를 해결하기 위해서 MatchingUser안에서 관리하던 Modal을 라우팅 상단에서 통합관리하기로 하였고, GameProfile에 필요한 props들도 redux로 전달해주기로 했다.

이 단계도 다른 modal을 구현하듯이 해주었다.

  return (
    <>
      <Modal>
        <CSSTransition
          in={mobileEventState.mobileBottomSheet}
          timeout={150}
          classNames="mobile-bottom-sheet"
          unmountOnExit
          onEnter={() => dispatch(triggerMobileBottomSheet)}
          onExited={() => dispatch(triggerMobileBottomSheet)}
        >
          <>
            <Container>
              <ContainerHeader>
                <span className="handle"></span>
              </ContainerHeader>
              <Wrapper>{children}</Wrapper>
            </Container>
            <BlackBG onClick={closeModal} />
          </>
        </CSSTransition>
      </Modal>
    </>
  );

BlackBG는 position absolute로 전체 화면을 덮어주도록 하였다. 이를 click하면 dispatch가 작동하며 close된다.

BlackBGContainer아래에 둔 이유는 CSSTransition이 첫번째 자식에게만 적용되었기 때문이다.

이 기능은 별도의 custom hook으로 분리하여 관리하였다.

const useBottomSheet = closeModal => {
  /* 
  작동 방법 : 
  1. first에 첫번째 touch의 위치 y를 넣어둠. 
  2. snapdown logic을 거치면서 중간 과정의 위치 y를 넣어둠
  3. 마지막 end에서 두 값 비교해서 과정의 y가 더 크면 (더 아래로 내려가면) 닫기
  */

  const record = useRef({
    first: '',
    process: '',
  });

  const handleTouchStart = e => {
    record.current.first = e.touches[0].screenY;
  };

  const snapDownLogic = _.throttle(e => {
    record.current.process = e.touches[0].screenY;
  }, 600);

  const handleTouchMove = useCallback(snapDownLogic, [snapDownLogic]);

  const handleTouchEnd = e => {
    if (record.current.first < record.current.process) {
      record.current.first = '';
      record.current.process = '';
      closeModal();
    }
  };

  return { handleTouchStart, handleTouchMove, handleTouchEnd };
};

구현 방법은 간단했다. touchStart에서 저장한 y값(화면 상단부터 touch까지의 거리)가 마지막 touchEnd 직전의 touchMove에서 저장한 y값보다 작으면 snap이 아래로 된 것으로 판단. modal을 닫아주었다.

개선 방안

사실 원래 인스타그램처럼 사용자의 touch를 따라 가능 모달을 구현하고 싶었다. 우리 앱에서는 그정도까지의 기능이 필요할 것 같지 않아 간단하게 구현했는데, 구현 방법을 따로 시간내서 공부를 해봐야겠다 싶었다.

참조

https://blog.mathpresso.com/bottom-sheet-for-web-55ed6cc78c00

profile
더 나은 서비스를 고민하는 프론트엔드 개발자.

0개의 댓글