Skip to content

Commit dd0dbfd

Browse files
authored
Merge pull request #43 from SOPT-all/feat/bottom-sheet/#40
[feat/#40] 바텀시트 구현
2 parents 1cbd46e + b17484f commit dd0dbfd

File tree

8 files changed

+373
-0
lines changed

8 files changed

+373
-0
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { style, keyframes } from "@vanilla-extract/css";
2+
import { color } from "@/shared/styles/tokens/color.css";
3+
4+
// overlay 애니메이션 정의
5+
const fadeIn = keyframes({
6+
from: { opacity: 0 },
7+
to: { opacity: 1 },
8+
});
9+
10+
// sheet 애니메이션 정의
11+
const slideUp = keyframes({
12+
from: { transform: "translateY(100%)" },
13+
to: { transform: "translateY(0)" },
14+
});
15+
16+
// overlay: 전체 바텀 시트의 레이아웃 컨테이너
17+
// backdrop과 sheet의 부모 컨테이너, flex-end로 sheet를 화면 하단에 배치
18+
export const overlay = style({
19+
position: "fixed",
20+
top: 0,
21+
left: 0,
22+
right: 0,
23+
bottom: 0,
24+
zIndex: 1000, // TODO: zIndex 토큰화
25+
display: "flex",
26+
alignItems: "flex-end", // bottom sheet는 화면 아래에서 올라와야 하므로, overlay의 bottom에 위치시켜야 함
27+
justifyContent: "center",
28+
animation: `${fadeIn} 0.2s ease-out`,
29+
});
30+
31+
// 반투명 검은 배경, 사용자 클릭 시 바텀시트 닫는 용도
32+
export const backdrop = style({
33+
position: "absolute",
34+
top: 0,
35+
left: 0,
36+
right: 0,
37+
bottom: 0,
38+
backgroundColor: "rgba(0, 0, 0, 0.35)",
39+
});
40+
41+
// 실제 바텀시트(흰색 박스))
42+
export const sheet = style({
43+
position: "relative", // overlay 기준으로 상대적인 위치 설정
44+
width: "37.5rem",
45+
maxHeight: "90dvh", //
46+
backgroundColor: color.white[100],
47+
borderTopLeftRadius: "16px",
48+
borderTopRightRadius: "16px",
49+
display: "flex",
50+
flexDirection: "column",
51+
zIndex: 1001, // TODO: zIndex 토큰화
52+
animation: `${slideUp} 0.3s ease-out`,
53+
});
54+
55+
export const content = style({
56+
padding: "0 1.6rem 1.6rem 1.6rem",
57+
overflowY: "auto",
58+
flex: 1, // sheet가 column이므로 content가 sheet의 모든 공간을 차지하도록 설정
59+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React, { useEffect } from "react";
2+
import { createPortal } from "react-dom";
3+
import * as styles from "./bottom-sheet.css";
4+
import { useBottomSheetDrag } from "./hooks/use-bottom-sheet-drag";
5+
import DragHandler from "@/shared/components/drag-handler/drag-handler";
6+
7+
interface Props {
8+
isOpen: boolean;
9+
onClose: () => void;
10+
children: React.ReactNode;
11+
}
12+
13+
const BottomSheet = ({ isOpen, onClose, children }: Props) => {
14+
const {
15+
sheetRef,
16+
contentRef,
17+
handleTouchStart,
18+
handleTouchMove,
19+
handleTouchEnd,
20+
handleDragHandlerMove,
21+
} = useBottomSheetDrag({ onClose });
22+
23+
// 바텀시트가 열렸을 때 뒷배경 스크롤 방지
24+
useEffect(() => {
25+
if (isOpen) {
26+
const originalOverflow = document.body.style.overflow;
27+
document.body.style.overflow = "hidden";
28+
29+
return () => {
30+
// isOpen이 변경되거나 컴포넌트가 언마운트될 때 실행, body의 overflow를 원래 값으로 복구
31+
document.body.style.overflow = originalOverflow;
32+
};
33+
}
34+
}, [isOpen]);
35+
36+
if (!isOpen) return null;
37+
38+
return createPortal(
39+
// TODO: 접근성 고려
40+
<div className={styles.overlay}>
41+
<div className={styles.backdrop} onClick={onClose} />
42+
<div ref={sheetRef} className={styles.sheet}>
43+
<DragHandler
44+
onTouchStart={handleTouchStart}
45+
onTouchMove={handleDragHandlerMove}
46+
onTouchEnd={handleTouchEnd}
47+
/>
48+
<div
49+
ref={contentRef}
50+
className={styles.content}
51+
onTouchStart={handleTouchStart}
52+
onTouchMove={handleTouchMove}
53+
onTouchEnd={handleTouchEnd}>
54+
{children}
55+
</div>
56+
</div>
57+
</div>,
58+
document.body
59+
);
60+
};
61+
62+
export default BottomSheet;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import React, { useRef, useState } from "react";
2+
3+
interface UseBottomSheetDragProps {
4+
onClose: () => void;
5+
}
6+
7+
// 상수 정의
8+
const CLOSE_THRESHOLD = 200; // sheet을 닫기 위한 최소 드래그 거리 (px)
9+
const CLOSE_TRANSITION_DURATION = 200; // sheet 닫힐 때 transition 시간 (ms)
10+
const RETURN_TRANSITION_DURATION = 300; // sheet 원위치 복귀 시 transition 시간 (ms)
11+
12+
export const useBottomSheetDrag = ({ onClose }: UseBottomSheetDragProps) => {
13+
const sheetRef = useRef<HTMLDivElement>(null);
14+
const contentRef = useRef<HTMLDivElement>(null);
15+
const [touchStartY, setTouchStartY] = useState(0); // 방향 판단용
16+
const [sheetStartY, setSheetStartY] = useState<number | null>(null); // sheet transform 기준점
17+
const dragDistanceRef = useRef(0); // sheet이 내려간 거리
18+
const isDraggingRef = useRef(false); // 드래그 중인지 여부
19+
20+
// 드래그 시작
21+
const handleTouchStart = (e: React.TouchEvent) => {
22+
setTouchStartY(e.touches[0].clientY);
23+
isDraggingRef.current = true;
24+
};
25+
26+
// sheet을 드래그하는 로직
27+
const moveSheet = (currentY: number) => {
28+
if (sheetStartY === null) {
29+
setSheetStartY(currentY);
30+
}
31+
32+
const sheetDeltaY = currentY - (sheetStartY ?? currentY);
33+
dragDistanceRef.current = sheetDeltaY;
34+
35+
if (sheetRef.current) {
36+
sheetRef.current.style.transition = "none"; // handleTouchEnd에서 추가된 transition 제거 -> sheet가 드래그에 즉각 반응
37+
sheetRef.current.style.transform = `translateY(${sheetDeltaY}px)`;
38+
}
39+
};
40+
41+
// content 영역 드래그
42+
const handleTouchMove = (e: React.TouchEvent) => {
43+
if (!isDraggingRef.current) return;
44+
45+
const currentY = e.touches[0].clientY; // 현재 드래그 y좌표
46+
const deltaY = currentY - touchStartY; // 드래그 이동 거리
47+
48+
// 드래그가 위에서 아래로 진행될 때 if문 진입(content를 위로 스크롤하거나, 바텀시트를 닫기 위한 드래그 액션)
49+
if (deltaY > 0) {
50+
// content의 최상단에 도달했을때 if문 진입 및 sheet 드래그
51+
if (contentRef.current && contentRef.current.scrollTop === 0) {
52+
moveSheet(currentY);
53+
}
54+
}
55+
};
56+
57+
// drag handler 영역 드래그
58+
const handleDragHandlerMove = (e: React.TouchEvent) => {
59+
if (!isDraggingRef.current) return;
60+
61+
const currentY = e.touches[0].clientY;
62+
const deltaY = currentY - touchStartY;
63+
64+
if (deltaY > 0) {
65+
// drag handler 조작 시 scrollTop 체크 없이 바로 sheet 드래그
66+
moveSheet(currentY);
67+
}
68+
};
69+
70+
// 드래그 종료
71+
const handleTouchEnd = () => {
72+
isDraggingRef.current = false;
73+
74+
if (sheetRef.current) {
75+
// CLOSE_THRESHOLD 이상 드래그하면 닫기
76+
if (dragDistanceRef.current > CLOSE_THRESHOLD) {
77+
// 부드럽게 아래로 내려가며 닫기
78+
sheetRef.current.style.transition = `transform ${CLOSE_TRANSITION_DURATION}ms ease-in`;
79+
sheetRef.current.style.transform = "translateY(100%)";
80+
81+
// transition 완료 후 onClose 호출
82+
setTimeout(() => {
83+
onClose();
84+
}, CLOSE_TRANSITION_DURATION);
85+
} else {
86+
// sheet 원위치로 복귀
87+
sheetRef.current.style.transition = `transform ${RETURN_TRANSITION_DURATION}ms ease-out`;
88+
sheetRef.current.style.transform = "translateY(0)";
89+
}
90+
}
91+
92+
dragDistanceRef.current = 0;
93+
setSheetStartY(null);
94+
};
95+
96+
return {
97+
sheetRef,
98+
contentRef,
99+
handleTouchStart,
100+
handleTouchMove,
101+
handleTouchEnd,
102+
handleDragHandlerMove,
103+
};
104+
};
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// 바텀시트 테스트용
2+
// 추후 삭제 예정
3+
4+
import { style } from "@vanilla-extract/css";
5+
import { typographyVars } from "@/shared/styles/typography.css";
6+
import { color } from "@/shared/styles/tokens/color.css";
7+
8+
export const container = style({
9+
padding: "2rem",
10+
minHeight: "100vh",
11+
});
12+
13+
export const title = style({
14+
...typographyVars.heading1,
15+
marginBottom: "2rem",
16+
color: color.black[200],
17+
});
18+
19+
export const openButton = style({
20+
padding: "1.2rem 2.4rem",
21+
backgroundColor: color.black[200],
22+
color: color.white[100],
23+
border: "none",
24+
borderRadius: "8px",
25+
...typographyVars.body2,
26+
marginBottom: "2rem",
27+
});
28+
29+
export const scrollContent = style({
30+
marginTop: "2rem",
31+
});
32+
33+
export const paragraph = style({
34+
...typographyVars.body3,
35+
color: color.gray[100],
36+
marginBottom: "1rem",
37+
});
38+
39+
export const sheetTitle = style({
40+
...typographyVars.caption1,
41+
color: color.black[200],
42+
marginBottom: "1rem",
43+
});
44+
45+
export const sheetDescription = style({
46+
...typographyVars.body3,
47+
color: color.gray[100],
48+
marginBottom: "2rem",
49+
});
50+
51+
export const sheetContent = style({
52+
display: "flex",
53+
flexDirection: "column",
54+
gap: "1rem",
55+
});
56+
57+
export const sheetItem = style({
58+
padding: "1.6rem",
59+
backgroundColor: color.white[200],
60+
borderRadius: "8px",
61+
...typographyVars.body3,
62+
color: color.black[200],
63+
border: `1px solid ${color.gray[300]}`,
64+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// 바텀시트 테스트용
2+
// 추후 삭제 예정
3+
4+
import { useState } from "react";
5+
import BottomSheet from "../bottom-sheet";
6+
import * as styles from "./bottom-sheet-test.css";
7+
8+
const BottomSheetTest = () => {
9+
const [isOpen, setIsOpen] = useState(false);
10+
11+
const handleOpen = () => setIsOpen(true);
12+
const handleClose = () => setIsOpen(false);
13+
14+
return (
15+
<div className={styles.container}>
16+
<h1 className={styles.title}>BottomSheet Test Page</h1>
17+
<button className={styles.openButton} onClick={handleOpen}>
18+
바텀시트 열기
19+
</button>
20+
21+
<div className={styles.scrollContent}>
22+
{Array.from({ length: 50 }, (_, i) => (
23+
<p key={i} className={styles.paragraph}>
24+
뒷배경 스크롤 테스트 {i + 1}
25+
</p>
26+
))}
27+
</div>
28+
29+
<BottomSheet isOpen={isOpen} onClose={handleClose}>
30+
<h2 className={styles.sheetTitle}>바텀시트 제목</h2>
31+
<p className={styles.sheetDescription}>
32+
아래로 드래그하거나 뒷배경을 탭하면 닫힘
33+
</p>
34+
<div className={styles.sheetContent}>
35+
{Array.from({ length: 30 }, (_, i) => (
36+
<div key={i} className={styles.sheetItem}>
37+
{i + 1}
38+
</div>
39+
))}
40+
</div>
41+
</BottomSheet>
42+
</div>
43+
);
44+
};
45+
46+
export default BottomSheetTest;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { style } from "@vanilla-extract/css";
2+
import { color } from "@/shared/styles/tokens/color.css";
3+
4+
export const container = style({
5+
width: "100%",
6+
display: "flex",
7+
padding: "1.6rem 0 1.2rem",
8+
justifyContent: "center",
9+
});
10+
11+
export const dragHandler = style({
12+
width: "6.4rem",
13+
height: "0.5rem",
14+
borderRadius: "5px",
15+
backgroundColor: color.gray[200],
16+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import React from "react";
2+
import * as styles from "./drag-handler.css";
3+
4+
interface Props {
5+
onTouchStart?: (e: React.TouchEvent) => void;
6+
onTouchMove?: (e: React.TouchEvent) => void;
7+
onTouchEnd?: (e: React.TouchEvent) => void;
8+
}
9+
10+
const DragHandler = ({ onTouchStart, onTouchMove, onTouchEnd }: Props) => {
11+
return (
12+
<div
13+
className={styles.container}
14+
onTouchStart={onTouchStart}
15+
onTouchMove={onTouchMove}
16+
onTouchEnd={onTouchEnd}>
17+
<div className={styles.dragHandler}></div>
18+
</div>
19+
);
20+
};
21+
22+
export default DragHandler;

src/shared/styles/tokens/zIndex.ts

Whitespace-only changes.

0 commit comments

Comments
 (0)