디자이너님이 이렇게 위아래로 움직이게 해주세요 요청하셨을때 이거 너무 쉽게 생각하고 네 금방할게요 이러면서 덤벼들었다가 눈물 날뻔했다...생각보다 블로그 글이 없어서 1차 당황했고 tailwind css 사용하신 분들 없어서 대충 추측하면서 한다고 2차 당황함
무엇보다 코드가 왜 이렇게 길고.. 만들어야 하는 파일들이 많은건지... 업무 중간에 머리 깨질거 같아서 뛰쳐나가고 싶은거 꾹 참았음ㅋㅋㅋ그래도 하나 잘 만들어놓으면 두고두고 여기저기 잘 쓸 수 있음! 여튼 기록해야지...
✔️Bottom sheet 바텀시트 란?
화면 하단에 위치하며 스크롤을 사용자가 드래그로 펴고 닫을 수 있는 화면을 의미한다. 보통 추가적인 정보나 액션을 제공하기 위해 사용된다. 총 2개의 영역으로 구분이 되는데
• 바텀시트 header
• 바텀 시트 content
기본적인 로직 툴은 https://blog.mathpresso.com/bottom-sheet-for-web-55ed6cc78c00
How to Make a Bottom Sheet for Web
TouchEvent를 이용하여 Bottom Sheet를 직접 만들어봅니다.
blog.mathpresso.com
이분 글이 참고하면서 작성했는데 이상하게 하려는 방향은 같은데 코드를 조금 수정해야만 내가 원하는대로 동작이 되어서...
여튼 내가 해야할 페이지에 맞게 코드를 조금 수정했다.
⛔️ React + Typescript + Tailwindcss로 작성되었습니다⛔️
framer-motion 설치
Div에 애니메이션을 좀 더 수월하게 넣어주기 위해 설치를 해주고
npm install framer-motion
아래 코드와 같이 import를 해주면 된다.
import { motion } from "framer-motion"
컴포넌트 설계하기
Bottomsheet header.tsx
import React from "react";
const BottomSheetHeader: React.FC = () => {
return (
<div className="relative h-12 pt-4 pb-1">
<div className="w-12 h-1 mx-auto bg-gray-300 rounded mt-4"></div>
</div>
);
};
export default BottomSheetHeader;
BottomSheet.tsx
import React from "react";
import BottomSheetHeader from "./BottomSheetHeader";
import { motion } from "framer-motion";
import useBottomSheet, {BOTTOM_SHEET_HEIGHT} from "../utils/useBottomSheet";
const Wrapper = motion.div; // 이렇게 선언된 이유는 framer-motion라이브러리를 사용해 애니메이션 적용할 수 있게 하기 위함
const BottomSheetContent = React.forwardRef<
HTMLDivElement,
{ children?: React.ReactNode }
>((props, ref) => (
<div
ref={ref}
className="overflow-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100"
>
{props.children}
</div>
));
interface BottomSheetProps {
children?: React.ReactNode;
onClose: () => void;
}
const BottomSheet: React.FC<BottomSheetProps> = ({ children }) => {
const { sheet, content } = useBottomSheet();
return (
<Wrapper
ref={sheet}
className={`fixed left-0 right-0 bottom-0 flex flex-col rounded-t-3xl bg-white shadow-lg h-[${BOTTOM_SHEET_HEIGHT}px] max-h-[900px]`}
>
<BottomSheetHeader />
<BottomSheetContent ref={content}>{children}</BottomSheetContent>
</Wrapper>
);
};
export default BottomSheet;
useBottomSheet는 커스텀훅으로 바텀시트의 동작을 관리하기 위한 로직이 포함되어 있다. 이 훅은 두 개의 ref 객체를 반환한다.
'sheet' : 바텀시트 전체를 참조하는 ref이다.
'content' : 바텀시트 내부의 콘텐츠를 참조하는 ref이다.
바텀 시트가 초기에 딱 떴을때 높이와 사용자가 드래그했을때 최대치로 올릴 수 있는 높이를 설정하였다.
- h-[${BOTTOM_SHEET_HEIGHT}px]: 바텀 시트의 높이를 설정합니다. 이 높이는 useBottomSheet에서 정의된 상수 BOTTOM_SHEET_HEIGHT를 사용합니다.
- max-h-[900px]: 바텀 시트의 최대 높이를 900px로 제한합니다.
개인적으로 제일 중요한거 같은 왜냐면 어려웠다..
useBottomSheet 훅을 통해 사용자가 바텀 시트를 끌어올리거나 내리는 제스처를 감지하고 처리할 수 있게 만든 것이다.
useBottomSheet.ts
import { useRef, useEffect } from "react";
import { MIN_Y, MAX_Y } from "./BottomSheetOption";
interface BottomSheetMetrics {
touchStart: {
sheetY: number; //터치 시작시 BottomSheet의
touchY: number;
};
touchMove: {
prevTouchY?: number;
movingDirection: "none" | "down" | "up";
};
isContentAreaTouched: boolean;
}
const useBottomSheet = () => {
const sheet = useRef<HTMLDivElement>(null);
const content = useRef<HTMLDivElement>(null);
const metrics = useRef<BottomSheetMetrics>({
touchStart: {
sheetY: 0,
touchY: 0,
},
touchMove: {
prevTouchY: 0,
movingDirection: "none",
},
isContentAreaTouched: false,
});
useEffect(() => {
const canUserMoveBottomSheet = () => {
const { touchMove, isContentAreaTouched } = metrics.current;
if (!isContentAreaTouched) {
return true;
}
if (sheet.current?.getBoundingClientRect().y !== MIN_Y) {
return true;
}
if (touchMove.movingDirection === "down") {
return content.current!.scrollTop <= 0;
}
return false;
};
const handleTouchStart = () => {
const { touchStart, touchMove } = metrics.current;
touchStart.sheetY = sheet.current!.getBoundingClientRect().y;
touchMove.prevTouchY = touchStart.touchY;
touchMove.movingDirection = "none"
};
const handleTouchMove = (e: TouchEvent) => {
const { touchStart, touchMove } = metrics.current;
const currentTouch = e.touches[0];
if (touchMove.prevTouchY === undefined) {
touchMove.prevTouchY = touchStart.touchY;
}
if (touchMove.prevTouchY === 0) {
touchMove.prevTouchY = touchStart.touchY;
}
if (touchMove.prevTouchY < currentTouch.clientY) {
touchMove.movingDirection = "down";
} else if (touchMove.prevTouchY > currentTouch.clientY) {
touchMove.movingDirection = "up";
}
if (canUserMoveBottomSheet()) {
e.preventDefault();
const touchOffset = currentTouch.clientY - touchStart.touchY;
let newHeight = MAX_Y - touchOffset;
if (newHeight < MIN_Y) {
newHeight = MIN_Y;
} else if (newHeight > MAX_Y) {
newHeight = MAX_Y;
}
sheet.current!.style.height = `${newHeight}px`;
}
};
const handleTouchEnd = () => {
document.body.style.overflowY = "auto";
const { touchMove } = metrics.current;
const currentSheetY = sheet.current?.getBoundingClientRect().y;
if (currentSheetY !== MIN_Y) {
if (touchMove.movingDirection === "down") {
sheet.current!.style.setProperty("transform", "translateY(0)");
}
if (touchMove.movingDirection === "up") {
sheet.current!.style.setProperty(
"transform",
`translateY(${MIN_Y - MAX_Y}px)`,
);
}
}
metrics.current = {
touchStart: {
sheetY: 0,
touchY: 0,
},
touchMove: {
prevTouchY: 0,
movingDirection: "none",
},
isContentAreaTouched: false,
};
};
const sheetElement = sheet.current;
sheet.current!.addEventListener("touchstart", handleTouchStart);
sheet.current!.addEventListener("touchmove", handleTouchMove);
sheet.current!.addEventListener("touchend", handleTouchEnd);
return () => {
sheetElement?.removeEventListener("touchstart", handleTouchStart);
sheetElement?.removeEventListener("touchmove", handleTouchMove);
sheetElement?.removeEventListener("touchend", handleTouchEnd);
};
}, []);
useEffect(() => {
const handleTouchStart = () => {
metrics.current!.isContentAreaTouched = true;
};
content.current!.addEventListener("touchstart", handleTouchStart);
}, []);
return { sheet, content };
};
export default useBottomSheet;
참조 https://blog.mathpresso.com/bottom-sheet-for-web-55ed6cc78c00
How to Make a Bottom Sheet for Web
TouchEvent를 이용하여 Bottom Sheet를 직접 만들어봅니다.
blog.mathpresso.com
리액트에서 Bottom Sheet 만들기
최근 프로젝트에서 Bottom Sheet를 만들어야 하는 일이 있었다. bottom sheet는 화면 아래단에 위치하며 스크롤로 펴고 닫을 수 있는 화면을 의미하는데 아래 사진을 보면 이해가 빠를 것이다. 출처 : ht
velog.io
'React' 카테고리의 다른 글
[React] 리액트 반응형 useMediaQuery (0) | 2024.08.30 |
---|---|
[React] useContext api (0) | 2024.08.27 |
[React]useReducer (0) | 2024.08.20 |
[React] react-hook-form 사용하기 (0) | 2024.08.13 |
[React] react-hook-form useRef 사용 (0) | 2024.08.13 |