tailwindcss + framer-motion으로 bottom sheet 만들기

Rocky·2023년 1월 8일
2

Intro

이전에는 MUI에서 Drawer 컴포넌트의 SwipeableDrawer컴포넌트(링크)를 사용해서 구현했던 BottomSheet를 직접 구현하려 하니 고려해야 할 부분이 많은 걸 느꼈다.
차 후 직접 구현해야하는 사람들에게 도움이 되고자 이 글을 작성한다.

바텀시트(Bottom Sheet)란

바텀시트(Bottom Sheet)는 화면 하단에서 올라오며 유저에게 추가적인 정보를 보여주는 UI 컴포넌트이다.

기획과 디자인에 따라 바텀시트는 다양한 모습을 가질 수 있으며,
구글의 디자인 시스템인 Material Design에서는 바텀시트를 몇 가지 종류로 구분하고 있는데,

  • Standard Bottom Sheet: 화면 하단에 상주하며 주 컨텐츠를 보완하는 역할을 한다.
    유저는 주 컨텐츠와 바텀시트 사이를 빠르게 오갈 수 있음.
  • Modal Bottom Sheet: 주 컨텐츠에 대한 대화상자나 메뉴를 표시하는 역할을 한다.
    보이지 않다가 필요할 때 나타나고, 시트의 뒷영역을 만질 수 없도록 처리하는 것이 특징.

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

요구사항

  1. 버튼 이벤트가 발생하였을 때, 바텀시트가 열려야함
  2. 바텀시트가 오픈되어있을 경우 바텀시트 외부의 컨텐츠들은 선택이 안됨 + 바텀시트가 닫힘 + esc키로도 닫혀야함
  3. 바텀시트를 위로 잡아당겼을 경우(드래그) 바텀시트가 닫혀야함

작업사항

  1. 어느 컴포넌트에서 바텀시트를 오픈하더라도 body의 자식요소로 오픈 될 수 있도록 ReactPortal을 활용
  2. 바텀시트 닫히는 상황
    • 바텀시트가 열리는 부분을 제외하고 backdrop을 구현하고 백드롭 영역 선택 시, 닫히도록 작업
    • 바텀시트가 열려있는 경우, ESC키 이벤트가 발생했을 경우 닫히도록 작업
  3. framer-motion으로 드래그 기능 + 애니메이션 추가

ReactPortal 컴포넌트

'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메소드를 사용하여 원하는 부분의 하위 요소로 데이터가 렌더 될 수 있도록 해준다.

useEscKeyClose 훅스

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이벤트가 실행되도록 처리

BottomSheet 컴포넌트

'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;
profile
r이 열한개!

0개의 댓글