Skip to content

Commit 831f361

Browse files
authored
Merge pull request #512 from meowzip/reese
feat: enhance DatePicker and BottomSheet components with improved touch handling
2 parents eadb20e + 8503973 commit 831f361

File tree

6 files changed

+288
-79
lines changed

6 files changed

+288
-79
lines changed

src/components/common/DatePicker.tsx

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1-
import React, { useState } from 'react';
1+
import React, { useState, useEffect } from 'react';
22
import Picker from '../ui/picker/Picker';
33

4-
const years = Array.from({ length: 5 }, (_, i) => 2021 + i);
5-
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}월`);
6-
const days = Array.from({ length: 31 }, (_, i) => `${i + 1}일`);
7-
84
type DatePickerProps = {
95
onSelectedChange: (selected: string) => void;
106
};
117

128
const DatePicker = ({ onSelectedChange }: DatePickerProps) => {
139
const today = new Date();
14-
const initialYear = today.getFullYear();
15-
const initialMonth = `${today.getMonth() + 1}월`; // JavaScript months are 0-based
16-
const initialDay = `${today.getDate()}일`;
10+
const currentYear = today.getFullYear();
11+
const currentMonth = today.getMonth() + 1;
12+
const currentDay = today.getDate();
13+
14+
const years = Array.from({ length: 5 }, (_, i) => currentYear - 2 + i);
15+
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}월`);
16+
const days = Array.from({ length: 31 }, (_, i) => `${i + 1}일`);
17+
18+
const initialYear = currentYear;
19+
const initialMonth = `${currentMonth}월`;
20+
const initialDay = `${currentDay}일`;
1721

1822
const [selectedYear, setSelectedYear] = useState<string | number>(
1923
initialYear
@@ -22,6 +26,15 @@ const DatePicker = ({ onSelectedChange }: DatePickerProps) => {
2226
initialMonth
2327
);
2428
const [selectedDay, setSelectedDay] = useState<string | number>(initialDay);
29+
const [isReady, setIsReady] = useState(false);
30+
31+
useEffect(() => {
32+
const timer = setTimeout(() => {
33+
setIsReady(true);
34+
}, 100);
35+
36+
return () => clearTimeout(timer);
37+
}, []);
2538

2639
const handleYearChange = (year: string | number) => {
2740
setSelectedYear(year);
@@ -37,22 +50,62 @@ const DatePicker = ({ onSelectedChange }: DatePickerProps) => {
3750

3851
return (
3952
<div className="flex flex-col items-center rounded-lg bg-white">
40-
<div className="flex items-center justify-center gap-[9px] self-stretch px-4 pb-8 pt-4">
41-
<Picker
42-
list={years}
43-
onSelectedChange={handleYearChange}
44-
initialSelected={initialYear}
45-
/>
46-
<Picker
47-
list={months}
48-
onSelectedChange={handleMonthChange}
49-
initialSelected={initialMonth}
50-
/>
51-
<Picker
52-
list={days}
53-
onSelectedChange={handleDayChange}
54-
initialSelected={initialDay}
55-
/>
53+
<div
54+
className="flex items-start justify-center gap-[9px] self-stretch px-4 pb-8 pt-4"
55+
style={{
56+
height: '180px',
57+
alignItems: 'flex-start'
58+
}}
59+
>
60+
{isReady && (
61+
<>
62+
<div
63+
style={{
64+
flex: 1,
65+
display: 'flex',
66+
justifyContent: 'center',
67+
alignItems: 'flex-start'
68+
}}
69+
>
70+
<Picker
71+
key="year-picker"
72+
list={years}
73+
onSelectedChange={handleYearChange}
74+
initialSelected={initialYear}
75+
/>
76+
</div>
77+
<div
78+
style={{
79+
flex: 1,
80+
display: 'flex',
81+
justifyContent: 'center',
82+
alignItems: 'flex-start'
83+
}}
84+
>
85+
<Picker
86+
key="month-picker"
87+
list={months}
88+
onSelectedChange={handleMonthChange}
89+
initialSelected={initialMonth}
90+
/>
91+
</div>
92+
<div
93+
style={{
94+
flex: 1,
95+
display: 'flex',
96+
justifyContent: 'center',
97+
alignItems: 'flex-start'
98+
}}
99+
>
100+
<Picker
101+
key="day-picker"
102+
list={days}
103+
onSelectedChange={handleDayChange}
104+
initialSelected={initialDay}
105+
/>
106+
</div>
107+
</>
108+
)}
56109
</div>
57110
<div className="flex h-12 w-full flex-1 items-start justify-center gap-2 self-stretch px-4">
58111
<button

src/components/ui/BottomSheet.tsx

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
AnimatePresence,
77
useMotionValue,
88
useTransform,
9-
MotionProps
9+
MotionProps,
10+
PanInfo
1011
} from 'framer-motion';
1112

1213
interface BottomSheetProps extends MotionProps {
@@ -32,20 +33,43 @@ const BottomSheet: React.FC<BottomSheetProps> = ({
3233
const [windowHeight, setWindowHeight] = useState<number>(0);
3334

3435
useEffect(() => {
35-
setWindowHeight(window.innerHeight);
36+
const updateHeight = () => {
37+
setWindowHeight(window.innerHeight);
38+
};
39+
40+
updateHeight();
41+
window.addEventListener('resize', updateHeight);
42+
window.addEventListener('orientationchange', updateHeight);
43+
44+
return () => {
45+
window.removeEventListener('resize', updateHeight);
46+
window.removeEventListener('orientationchange', updateHeight);
47+
};
3648
}, []);
3749

3850
const initialHeightValue = windowHeight * 0.2;
3951
const y = useMotionValue(initialHeightValue);
4052
const bottomSheetRef = useRef<HTMLDivElement>(null);
53+
const dragHandleRef = useRef<HTMLDivElement>(null);
4154

4255
const handleDragEnd = (
4356
event: MouseEvent | TouchEvent | PointerEvent,
44-
info: { velocity: { y: number } }
57+
info: PanInfo
4558
) => {
4659
const threshold = windowHeight * 0.25;
4760
const closeThreshold = windowHeight * 0.7;
4861
const endY = y.get();
62+
const velocity = info.velocity.y;
63+
64+
if (velocity > 500) {
65+
setIsVisible(false);
66+
return;
67+
}
68+
69+
if (velocity < -500) {
70+
y.set(0);
71+
return;
72+
}
4973

5074
if (endY < threshold) {
5175
y.set(0);
@@ -67,6 +91,35 @@ const BottomSheet: React.FC<BottomSheetProps> = ({
6791
}
6892
};
6993

94+
useEffect(() => {
95+
if (!isVisible) return;
96+
97+
const handleTouchMove = (e: TouchEvent) => {
98+
if (
99+
bottomSheetRef.current &&
100+
!bottomSheetRef.current.contains(e.target as Node)
101+
) {
102+
e.preventDefault();
103+
}
104+
};
105+
106+
const handleTouchStart = (e: TouchEvent) => {
107+
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
108+
e.stopPropagation();
109+
}
110+
};
111+
112+
document.addEventListener('touchmove', handleTouchMove, { passive: false });
113+
document.addEventListener('touchstart', handleTouchStart, {
114+
passive: false
115+
});
116+
117+
return () => {
118+
document.removeEventListener('touchmove', handleTouchMove);
119+
document.removeEventListener('touchstart', handleTouchStart);
120+
};
121+
}, [isVisible]);
122+
70123
return (
71124
<>
72125
{isVisible && (
@@ -86,8 +139,9 @@ const BottomSheet: React.FC<BottomSheetProps> = ({
86139
animate={{
87140
y: initialHeightValue,
88141
transition: {
89-
type: 'tween',
90-
ease: 'easeOut',
142+
type: 'spring',
143+
damping: 30,
144+
stiffness: 300,
91145
duration: 0.3
92146
}
93147
}}
@@ -99,24 +153,48 @@ const BottomSheet: React.FC<BottomSheetProps> = ({
99153
duration: 0.3
100154
}
101155
}}
102-
{...(!disableDrag && {
103-
drag: 'y',
104-
dragConstraints: { top: 0 },
105-
onDragEnd: handleDragEnd
106-
})}
107-
style={{ y, height: bottomSheetHeight }}
108-
className={`fixed inset-x-0 bottom-0 z-50 mx-auto max-w-[640px] rounded-tl-3xl rounded-tr-3xl bg-white shadow-lg ${
156+
style={{
157+
y,
158+
height: bottomSheetHeight
159+
}}
160+
className={`fixed inset-x-0 bottom-0 z-50 mx-auto flex max-w-[640px] flex-col rounded-tl-3xl rounded-tr-3xl bg-white shadow-lg ${
109161
overflow ? overflow : 'overflow-hidden'
110162
}`}
111163
{...props}
112164
>
113-
{topBar && (
114-
<div className="topBar relative text-center">
115-
<div className="drag-bar mx-auto my-2 h-1 w-10 rounded-full bg-gray-300" />
116-
{topBar}
117-
</div>
118-
)}
119-
{children}
165+
<motion.div
166+
ref={dragHandleRef}
167+
{...(!disableDrag && {
168+
drag: 'y',
169+
dragConstraints: {
170+
top: -windowHeight * 0.5,
171+
bottom: windowHeight * 0.7
172+
},
173+
dragElastic: 0.1,
174+
onDragEnd: handleDragEnd,
175+
whileDrag: { cursor: 'grabbing' }
176+
})}
177+
className="relative cursor-grab active:cursor-grabbing"
178+
style={{ touchAction: disableDrag ? 'auto' : 'none' }}
179+
>
180+
<div className="drag-bar mx-auto my-2 h-1 w-10 rounded-full bg-gray-300" />
181+
{topBar && (
182+
<div className="topBar relative pb-2 text-center">{topBar}</div>
183+
)}
184+
</motion.div>
185+
186+
{/* 컨텐츠 영역 - 스크롤 가능하지만 드래그 불가 */}
187+
<div
188+
className={`flex-1 ${overflow ? overflow : 'overflow-y-auto'}`}
189+
style={{
190+
touchAction: 'pan-y', // 세로 스크롤만 허용
191+
WebkitOverflowScrolling: 'touch' // iOS 스크롤 최적화
192+
}}
193+
onTouchMove={e => e.stopPropagation()}
194+
onTouchStart={e => e.stopPropagation()}
195+
>
196+
{children}
197+
</div>
120198
</motion.div>
121199
)}
122200
</AnimatePresence>

src/components/ui/picker/List.tsx

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,53 @@
1-
import { forwardRef } from 'react';
1+
import { forwardRef, useEffect } from 'react';
22

33
interface ListProps {
44
children: React.ReactNode;
5-
onScroll: () => void;
5+
onScroll: (e: React.UIEvent<HTMLUListElement>) => void;
66
}
77
const List = forwardRef(
8-
({ children, onScroll }: ListProps, ref: React.Ref<HTMLUListElement>) => (
9-
<ul
10-
ref={ref}
11-
onScroll={onScroll}
12-
className="scrollbar-none relative m-0 h-36 w-full list-none overflow-hidden overflow-y-scroll p-0 scrollbar-hide"
13-
>
14-
{children}
15-
</ul>
16-
)
8+
({ children, onScroll }: ListProps, ref: React.Ref<HTMLUListElement>) => {
9+
useEffect(() => {
10+
const handleTouchMove = (e: TouchEvent) => {
11+
e.stopPropagation();
12+
};
13+
14+
const handleTouchStart = (e: TouchEvent) => {
15+
e.stopPropagation();
16+
};
17+
18+
const currentRef = (ref as React.RefObject<HTMLUListElement>)?.current;
19+
20+
if (currentRef) {
21+
currentRef.addEventListener('touchmove', handleTouchMove, {
22+
passive: true
23+
});
24+
currentRef.addEventListener('touchstart', handleTouchStart, {
25+
passive: true
26+
});
27+
28+
return () => {
29+
currentRef.removeEventListener('touchmove', handleTouchMove);
30+
currentRef.removeEventListener('touchstart', handleTouchStart);
31+
};
32+
}
33+
}, [ref]);
34+
35+
return (
36+
<ul
37+
ref={ref}
38+
onScroll={onScroll}
39+
className="scrollbar-none relative m-0 h-36 w-full list-none overflow-hidden overflow-y-scroll p-0 scrollbar-hide"
40+
style={{
41+
touchAction: 'pan-y',
42+
WebkitOverflowScrolling: 'touch'
43+
}}
44+
onTouchMove={e => e.stopPropagation()}
45+
onTouchStart={e => e.stopPropagation()}
46+
>
47+
{children}
48+
</ul>
49+
);
50+
}
1751
);
1852

1953
List.displayName = 'List';
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1-
const ListCenter = () => <div className="sticky top-12 h-12"></div>;
1+
const ListCenter = () => (
2+
<div
3+
className="pointer-events-none absolute inset-x-0 top-12 z-10 h-12 border-y border-gray-200 bg-transparent"
4+
style={{
5+
top: '48px',
6+
height: '48px'
7+
}}
8+
/>
9+
);
210

311
export default ListCenter;

src/components/ui/picker/ListItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const ListItem = forwardRef(
88
({ children, isSelected }: ListItemProps, ref: React.Ref<HTMLLIElement>) => (
99
<li
1010
ref={ref}
11-
className={`flex h-12 items-center justify-center ${isSelected ? 'flex h-10 items-center justify-center self-stretch rounded-lg bg-gr-50 font-semibold opacity-100' : 'opacity-40'}`}
11+
className={`flex h-12 items-center justify-center px-[2rem] ${isSelected ? 'self-stretch rounded-lg bg-gr-50 font-semibold opacity-100' : 'opacity-40'}`}
1212
>
1313
{children}
1414
</li>

0 commit comments

Comments
 (0)