문제: 작동은 하지만 손맛이 없는 숫자 피커
초기 NumberDial은 드래그로 값을 바꾸는 기능은 정상 동작했지만, 손을 떼는 순간 즉시 멈추는 동작 때문에 iOS 피커 특유의 자연스러운 감각이 부족했습니다.
- 터치 종료 즉시 정지: 관성(inertia) 부재
- 값 변경이 임계점(예: 64px)에서만 발생: 계단식 전환
- 불투명도/강조가 단계식 하드코딩: 연속 피드백 부족
- 빠른 스와이프 시 state 의존 콜백으로 stale closure 위험
typescript1const handleEnd = () => {2 touchStartY.current = null3 mouseStartY.current = null4 currentPositionY.current = 0 // 즉시 종료5}
해결 전략: 값이 아니라 위치(scrollY)를 모델링
핵심은 값을 직접 변경하지 않고, 먼저 픽셀 단위 위치를 물리적으로 업데이트한 뒤 그 위치에서 값을 파생하는 구조로 바꾸는 것입니다.
- 입력: 드래그 이동량을 scrollY(px)로 반영
- 시뮬레이션: 속도와 마찰로 관성 감속
- 종료: 임계 조건에서 가장 가까운 항목으로 스냅
- 출력: scrollY 기반으로 현재 값/시각 효과 계산
typescript1const currentValue = base + Math.round(scrollY / ITEM_HEIGHT)2const offsetInItem = scrollY % ITEM_HEIGHT
핵심 구현 1: 손을 떼는 순간의 속도 추정
전체 드래그 평균 속도가 아니라, 최근 100ms 샘플만으로 종료 시점의 체감 속도를 계산합니다. 이렇게 해야 플리킹 감각이 자연스럽습니다.
typescript1const now = performance.now()2velocitySamplesRef.current = [3 ...velocitySamplesRef.current.filter((s) => now - s.time <= 100),4 { time: now, y: clientY },5]67const calcVelocity = () => {8 const samples = velocitySamplesRef.current9 if (samples.length < 2) return 010 const first = samples[0]11 const last = samples[samples.length - 1]12 const dt = last.time - first.time13 if (dt <= 0) return 014 return clamp((last.y - first.y) / dt, -MAX_VELOCITY, MAX_VELOCITY)15}
MAX_VELOCITY를 제한해 한 프레임에 여러 항목을 건너뛰는 현상을 막고, 과도한 튐을 방지했습니다.
핵심 구현 2: 관성 감속과 프레임 독립성
typescript1const animate = (now: number) => {2 const dt = now - lastTime3 lastTime = now45 position += velocity * dt6 velocity *= 0.9578 rafIdRef.current = requestAnimationFrame(animate)9}
- dt 기반 적분으로 60Hz/120Hz 환경에서 동일한 체감 유지
- 마찰 계수(0.95)로 지수 감쇠를 적용해 기계적 감속을 회피
- 속도와 위치를 ref로 관리해 고빈도 렌더링 비용 최소화
핵심 구현 3: 종료 조건과 스냅
속도만으로 종료를 판단하면 중간 지점에서 어색한 점프가 생깁니다. 그래서 속도와 경계 근접도를 함께 사용했습니다.
typescript1const nearest = Math.round(position / ITEM_HEIGHT) * ITEM_HEIGHT2const dist = Math.abs(position - nearest)34const nearZero = Math.abs(velocity) <= 0.25const nearBoundary = dist <= 46const slowEnough = Math.abs(velocity) <= 0.0478if ((nearZero && nearBoundary) || slowEnough) {9 startSnap(nearest)10}
typescript1const animateSnap = (current: number) => {2 if (Math.abs(current - target) < 0.5) return finish(target)3 const next = current + (target - current) * 0.24 requestAnimationFrame(() => animateSnap(next))5}
스냅도 즉시 점프가 아니라 지수 보간으로 처리해 마지막 안착 동작을 부드럽게 만들었습니다.
렌더링 품질: 계단식 대신 연속 피드백
scrollY의 소수점 구간을 활용해 항목별 opacity/scale/translate를 연속적으로 계산하면 시각적으로도 물리 엔진의 움직임이 그대로 전달됩니다.
- 선택 항목 강조를 distance 기반 연속 함수로 계산
- transition 의존을 줄이고, rAF 업데이트를 그대로 표현
- 값 변경 순간뿐 아니라 이동 중간 프레임도 시각적으로 반응
React 구현에서 중요했던 포인트
- 고빈도 값(scrollY, velocity)은 ref, 화면 표시값은 state로 분리
- 비동기 루프(rAF/setTimeout)에서 최신 콜백 참조를 위해 ref 사용
- touchmove 제어가 필요하면 passive: false로 등록
- 언마운트 시 rAF/타이머를 반드시 정리
typescript1const onChangeRef = useRef(onChange)2useLayoutEffect(() => {3 onChangeRef.current = onChange4}, [onChange])
재사용: useChainScroll로 추상화
물리 로직을 훅으로 분리하자 ChainPicker/TimePicker 등 다른 입력 UI에서도 동일한 감각을 쉽게 재사용할 수 있었습니다.
- 컴포넌트는 렌더링 책임에 집중
- 물리 상수/아이템 높이만 주입해서 도메인별 튜닝 가능
- 동작 일관성 확보로 UI 완성도 개선
마무리
“좋은 인터랙션은 거대한 엔진보다, 올바른 모델링(위치), 작은 물리 상수(마찰/스냅), 그리고 안정적인 React 패턴(ref/state 분리)에서 시작됩니다.