이전에는 MUI에서 Drawer 컴포넌트의 SwipeableDrawer
컴포넌트(링크)를 사용해서 구현했던 BottomSheet
를 직접 구현하려 하니 고려해야 할 부분이 많은 걸 느꼈다.
차 후 직접 구현해야하는 사람들에게 도움이 되고자 이 글을 작성한다.
바텀시트(Bottom Sheet)는 화면 하단에서 올라오며 유저에게 추가적인 정보를 보여주는 UI 컴포넌트이다.
기획과 디자인에 따라 바텀시트는 다양한 모습을 가질 수 있으며,
구글의 디자인 시스템인 Material Design에서는 바텀시트를 몇 가지 종류로 구분하고 있는데,
Standard Bottom Sheet
: 화면 하단에 상주하며 주 컨텐츠를 보완하는 역할을 한다.Modal Bottom Sheet
: 주 컨텐츠에 대한 대화상자나 메뉴를 표시하는 역할을 한다.✱ 출처 : https://blog.mathpresso.com/bottom-sheet-for-web-55ed6cc78c00
ReactPortal
을 활용backdrop
을 구현하고 백드롭 영역 선택 시, 닫히도록 작업framer-motion
으로 드래그 기능 + 애니메이션 추가'use client';
import type { ReactElement, ReactNode } from 'react';
import { useEffect, useState } from 'react';
import * as ReactDOM from 'react-dom';
import { atom, useRecoilState } from 'recoil';
import { useIsomorphicLayoutEffect } from '@/hooks';
const ReactPortal = ({
children,
loadingComponent = null,
portalRootElement,
}: {
children: ReactNode;
loadingComponent?: ReactElement;
portalRootElement: Element | DocumentFragment;
}) => {
if (!portalRootElement) return loadingComponent;
return ReactDOM.createPortal(children, portalRootElement);
};
export default ReactPortal;
react-dom
패키지의 createPortal
메소드를 사용하여 원하는 부분의 하위 요소로 데이터가 렌더 될 수 있도록 해준다.
import { useCallback, useEffect } from 'react';
const useEscKeyClose = (onClose: (event?: KeyboardEvent) => void | null) => {
const escKeyClose = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') onClose(event);
},
[onClose],
);
useEffect(() => {
window.addEventListener('keydown', escKeyClose);
return () => window.removeEventListener('keydown', escKeyClose);
}, [escKeyClose]);
};
export default useEscKeyClose;
바텀시트가 열려있을 경우에 esc
키를 누르면 window
객체에서 keyDown
이벤트가 실행되도록 처리
'use client';
import type { MouseEvent } from 'react';
import type { DefaultBottomSheetContainerProps } from '@/types/components';
import clsx from 'clsx';
import { motion, PanInfo, useAnimation } from 'framer-motion';
import { useEffect, useRef, useState } from 'react';
import ReactPortal from '@/components/atoms/ReactPortal';
import { useEscKeyClose, useIsomorphicLayoutEffect } from '@/hooks';
const BottomSheet = ({
children,
headerComponent,
height = 80,
heightUnit = '%',
isBackdrop = true,
isBackdropClose = false,
isDrag = true,
isOpen,
onClose,
onOpen,
sx,
targetId = 'bottom-sheet-default',
zIndex = 4,
}: DefaultBottomSheetContainerProps) => {
const backdropRef = useRef<HTMLDivElement | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
const [portalRootElement, setPortalRootElement] =
useState<HTMLElement | null>(null);
const PLUS_HEIGHT = `${height}${heightUnit}`;
const MINUS_HEIGHT = `-${height}${heightUnit}`;
// useEscKeyClose 훅스 사용
useEscKeyClose((e) => (!isBackdropClose ? onClose(e, 'esc') : null));
const controls = useAnimation();
// body 태그 안에 해당하는 id를 가지고 있는 div태그에 접근하여 ReactPortal로 렌더
useEffect(() => {
setPortalRootElement(document.getElementById(targetId));
}, [targetId]);
// 바텀시트의 isOpen이 true일 경우, ReactPortal에 자식으로 들어간 div태그의 자식들을 보이고, 스크롤이 보이지 않도록 body태그의 스타일 조정
useEffect(() => {
if (isOpen) {
const { body } = document;
const bottomSheet = document.querySelector(`#${targetId}`);
const root = document.querySelector('#root');
bottomSheet.setAttribute('style', 'display: flex');
root.setAttribute('aria-hidden', 'true');
body.setAttribute('style', 'overflow: hidden');
controls.start('visible');
return () => {
body.removeAttribute('style');
root.setAttribute('aria-hidden', 'false');
controls.start('hidden');
};
}
}, [controls, isOpen, isBackdrop]);
// 바텀시트의 헤더부분에서 드래그 이벤트가 발생하였을 경우, 이벤트 속도를 감지해서 닫힐지 여부를 판단하는 로직
const onDragEnd = (
event: PointerEvent,
{ point, velocity }: PanInfo,
): void => {
const shouldClose =
(velocity.y > -20 &&
(event.type === 'pointerup' || event.target === backdropRef.current)) ||
velocity.y > 20 ||
(velocity.y >= 0 && point.y > 45);
if (shouldClose) {
controls.start('hidden');
onClose(event, 'drag');
} else {
controls.start('visible');
if (onOpen) onOpen();
}
};
// framer-motion을 활용하여 애니메이션 및 헤더에 드래그 기능 추가
return (
<ReactPortal portalRootElement={portalRootElement}>
{isOpen && isBackdrop && (
<motion.div
ref={backdropRef}
aria-hidden="true"
className="fixed inset-0 h-full w-full bg-black opacity-50"
id="bottomSheetBackdrop"
initial="hidden"
style={{ zIndex: zIndex - 1 }}
onClick={(e: MouseEvent<HTMLDivElement>) =>
isBackdropClose ? null : onClose(e, 'backdrop')
}
/>
)}
<motion.div
animate={controls}
aria-modal={isOpen}
className={clsx(
{
'rounded-t-20px': isBackdrop,
'rounded-t-[15px]': !isBackdrop,
},
`fixed w-full min-w-280 max-w-420 ${sx}`,
)}
initial="hidden"
role="dialog"
style={{ height: PLUS_HEIGHT, zIndex }}
transition={{
damping: 40,
stiffness: 400,
type: 'spring',
}}
variants={{
hidden: { bottom: MINUS_HEIGHT },
visible: { bottom: 0 },
}}
>
<header
ref={headerRef}
className="relative flex h-56px w-full items-center"
>
<motion.div
className="absolute z-0 flex h-56px w-full cursor-grab items-center rounded-t-20px bg-tp-white100"
drag={isDrag ? 'y' : false}
dragConstraints={headerRef}
dragElastic={0.03}
dragMomentum={false}
onDragEnd={onDragEnd}
/>
<div className="pointer-events-none z-1 w-full">
{headerComponent}
</div>
</header>
{children}
</motion.div>
</ReactPortal>
);
};
export default BottomSheet;