지난 여름부터 진행하고 있는 Dor.gg 프로젝트는 web 뷰를 기반으로 모바일 화면에서도 원활하게 사용할 수 있도록 반응형으로 제작하고 있다. 프로젝트 초기에는 모바일 앱을 react native나 flutter를 사용해서 만들려고 했으나 인력 부족으로 (...😥) 웹뷰를 그냥 보여주는 식으로 간단하게 앱을 런칭하였다. 그래서 아무래도 모바일 화면에 맞지 않는 UI가 많이 생겼는데...
그중 가장 두드러지는 문제점이 바로 모바일 뷰에 어울리지 않는 popover
UI 였다.
거슬리는 이 UI를 조금 더 모바일 화면에 어울리는 bottom sheet
으로 바꾸기로 하였다.
(material design의 bottom sheet design)
구현에 앞서 구현에 필요한 단계를 생각(명세)해보았다
우리팀은 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된다.
BlackBG
를 Container
아래에 둔 이유는 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