`DraggableBottomSheet` 구현

ddoachi·2025년 4월 27일

TekaPicker

목록 보기
13/30

DraggableBottomSheet 코드 분석


전체 코드 (큰 부분별 소제목 추가)

// src/components/DraggableBottomSheet.tsx

import { Box } from '@mui/material';
import { useState, useRef, useEffect } from 'react';

const BOTTOM_NAV_HEIGHT = 56;
const HANDLE_HEIGHT = 40;
const MIN_SHEET_HEIGHT = BOTTOM_NAV_HEIGHT + HANDLE_HEIGHT;

export const DraggableBottomSheet = () => {
  // 상태 및 ref 선언
  const [height, setHeight] = useState<number>(MIN_SHEET_HEIGHT);
  const startYRef = useRef<number | null>(null);
  const heightRef = useRef<number>(MIN_SHEET_HEIGHT);
  const maxHeightRef = useRef<number>(window.innerHeight * 0.5);

  // 화면 리사이즈 대응
  useEffect(() => {
    const handleResize = () => {
      maxHeightRef.current = window.innerHeight * 0.5;
    };
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  // 터치 이벤트 핸들러
  const handleTouchStart = (e: React.TouchEvent) => {
    startYRef.current = e.touches[0].clientY;
    heightRef.current = height;
  };

  const handleTouchMove = (e: React.TouchEvent) => {
    if (startYRef.current !== null) {
      const deltaY = e.touches[0].clientY - startYRef.current;
      const newHeight = Math.min(
        Math.max(MIN_SHEET_HEIGHT, heightRef.current - deltaY),
        maxHeightRef.current
      );
      setHeight(newHeight);
    }
  };

  const handleTouchEnd = () => {
    startYRef.current = null;
  };

  // handle 클릭 시 toggle
  const handleClickHandle = () => {
    const threshold = 20;
    if (Math.abs(height - maxHeightRef.current) < threshold) {
      setHeight(MIN_SHEET_HEIGHT);
    } else {
      setHeight(maxHeightRef.current);
    }
  };

  // 렌더링
  return (
    <Box
      sx={{
        position: 'fixed',
        bottom: `${BOTTOM_NAV_HEIGHT}px`,
        left: 2,
        right: 2,
        height: `${height}px`,
        backgroundColor: 'white',
        border: '1px solid #e0e0e0',
        borderTopLeftRadius: 16,
        borderTopRightRadius: 16,
        boxShadow: 3,
        overflow: 'hidden',
        zIndex: 1400,
        touchAction: 'none',
        transition: 'height 0.1s ease',
      }}
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
    >
      {/* handle */}
      <Box
        sx={{
          position: 'absolute', // (1)
          top: 0,                // (2)
          left: '50%',
          transform: 'translate(-50%, 25%)',
          width: 50,
          height: 6,
          borderRadius: 3,
          backgroundColor: 'grey.400', // (3)
          cursor: 'pointer',
          userSelect: 'none',
          zIndex: 1,
        }}
        onClick={handleClickHandle}
      />

      {/* 본문 */}
      <Box
        sx={{
          position: 'relative', // (4)
          width: '100%',
          height: '100%',
          pt: 6,
        }}
      >
        <Box sx={{ p: 2, mt: 1 }}>
          Draggable BottomSheet Content
        </Box>
      </Box>
    </Box>
  );
};

코드 부분별 상세 분석

터치 이벤트 핸들러

const handleTouchStart = (e: React.TouchEvent) => {
  startYRef.current = e.touches[0].clientY; // (1)
  heightRef.current = height; // (2)
};

설명

  • (1) 터치 시작 Y좌표를 저장한다.
  • (2) 시작할 때의 height를 저장한다.
const handleTouchMove = (e: React.TouchEvent) => {
  if (startYRef.current !== null) {
    const deltaY = e.touches[0].clientY - startYRef.current; // (1)
    const newHeight = Math.min(
      Math.max(MIN_SHEET_HEIGHT, heightRef.current - deltaY), // (2)
      maxHeightRef.current
    );
    setHeight(newHeight); // (3)
  }
};

설명

  • (1) 이동한 거리를 계산한다 (deltaY)
  • (2) 이동에 따라 height를 계산한다 (dragging 방향에 주의)
  • (3) height를 업데이트한다.
const handleTouchEnd = () => {
  startYRef.current = null; // (1)
};

설명

  • (1) 터치가 끝나면 초기화한다.

handle 클릭 toggle

const handleClickHandle = () => {
  const threshold = 20;
  if (Math.abs(height - maxHeightRef.current) < threshold) { // (1)
    setHeight(MIN_SHEET_HEIGHT); // (2)
  } else {
    setHeight(maxHeightRef.current); // (3)
  }
};

설명

  • (1) height가 max 근처면
  • (2) minHeight로 줄이고
  • (3) 아니면 maxHeight로 확장한다.

렌더링 구조 및 스타일

<Box
  sx={{ position: 'fixed', ... }}
>
  <Box
    sx={{
      position: 'absolute', // (1)
      top: 0,                // (2)
      backgroundColor: 'grey.400', // (3)
    }}
  />

  <Box
    sx={{
      position: 'relative', // (4)
    }}
  >
    {/* Content */}
  </Box>
</Box>

설명

  • (1) handle은 absolute로 배치된다.
  • (2) top: 0으로 부모 기준 위에 붙는다.
  • (3) 연한 회색(grey.400)으로 표시한다.
  • (4) 본문은 relative로 자연스럽게 내부 레이아웃을 잡는다.

✅ absolute는 가장 가까운 relative, fixed, absolute, sticky 부모를 기준으로 위치를 잡는다.
✅ 여기서는 fixed가 기준이다.


요약

  • 드래그하면 height 조정.
  • 클릭하면 min/max 토글.
  • fixed + absolute + relative 위치관계 깔끔히 설계됨.
profile
내일도 풀스택

0개의 댓글