모바일에서 터치 스와이프 제대로 쓰려면 — iOS WKWebView까지
감정 캘린더 월 이동, 친구 페이지 탭 전환, 하단 탭바 스크롤 투 탑을 모바일 터치로 넣으면서 겪은 걸 정리해 둔다. 특히 iOS WKWebView에서는 React 합성 이벤트만으로는 수평 스와이프가 안 먹힌다는 걸 삽질 끝에 알게 됐다.
단순 스와이프 — 캘린더 월 이동
좌우 스와이프로 이전/다음 달로 넘기는 건 onTouchStart에서 X 저장, onTouchEnd에서 차이가 50px 넘으면 방향에 따라 handlePrevMonth / handleNextMonth 호출하는 식으로 충분했다. 50px는 스크롤 오동작과 의도적 스와이프 사이의 타협점. 스와이프 영역이 제한적이고 브라우저 뒤로가기와 겹치지 않는 경우에만 이 방식이 안전하다.
탭 스와이프 — 수평/수직 구분
친구 페이지는 탭이 세 개고, 각 탭 안에는 세로 스크롤 리스트가 있다. 그래서 수평 스와이프와 수직 스크롤을 구분해야 했다. touchStart에서 x, y 둘 다 저장하고, touchEnd에서 |dx| > 50 && |dx| > dy일 때만 탭 전환으로 인식했다. |dx| > dy가 없으면 대각선 스크롤해도 탭이 바뀌는 오동작이 난다.
이걸 React onTouchStart/onTouchEnd로만 구현했을 때는 Android에서는 잘 됐는데 iOS에서는 전혀 안 됐다.
iOS WKWebView — touchEnd가 안 오는 이유
iOS WKWebView의 allowsBackForwardNavigationGestures 때문에 수평 터치를 브라우저가 먼저 가로챈다. 뒤로가기/앞으로가기 제스처로 인식해서 JS까지 이벤트를 안 넘기고, 그 결과 touchEnd가 호출되지 않는다.
해결은 네이티브 addEventListener + **touchmove에서 preventDefault()**다.
touchmove리스너를{ passive: false }로 등록해야preventDefault()가 동작한다. React 합성 이벤트는 passive: true라서 막을 수 없다.- touchmove 시점에
dx > 10 && dx > dy이면 수평 스와이프로 보고 즉시e.preventDefault()로 브라우저 기본 동작을 막는다. touchend까지 기다리면 이미 iOS가 이벤트를 가져간 뒤라 소용없다. - 실제 "탭 전환" 판정은 touchend에서 50px 임계값으로 한다. 10px는 "의도 감지용", 50px는 "동작 확정용" 이중 임계값.
useEffect 안에서 등록하는 핸들러는 클로저로 activeTab을 캡처하므로, activeTabRef.current = activeTab으로 매 렌더마다 최신 값을 넣어 두고, 리스너 안에서는 ref만 읽도록 했다. 그래야 리스너를 매번 다시 붙이지 않아도 된다. cleanup에서 removeEventListener는 꼭 해야 한다.
스와이프 영역 — flex-1과 컨테이너
스와이프 핸들러를 "탭 컨텐츠를 감싼 div"에만 달아 두니까, 컨텐츠가 적은 탭(예: 요청 목록이 짧을 때)에서는 빈 영역을 스와이프해도 반응이 없었다. 그 div가 내용 높이만큼만 커져서, 화면의 나머지는 이벤트 대상이 아니었기 때문이다.
핸들러를 최상위 컨테이너로 옮기고, 그 컨테이너에 flex-1을 줘서 남은 세로 공간을 다 차지하게 했다. 터치 이벤트는 버블링되니까 바깥 컨테이너에만 걸어도 내부 어디를 터치해도 감지된다. "스와이프가 안 먹혀요"일 때는 핸들러가 붙은 요소의 실제 크기를 DevTools로 먼저 확인해 보는 게 좋다.
스크롤 투 탑 — 더블탭에서 싱글탭으로
하단 탭바에서 현재 탭을 다시 누르면 맨 위로 스크롤하는 기능이다. 처음엔 300ms 안에 두 번 탭하는 더블탭으로 했는데, 사용자가 잘 모르고 쓰는 경우가 많았다. iOS/Android 네이티브 앱은 보통 한 번만 누르면 스크롤 투 탑이라서, 그쪽으로 맞췄다.
이미 활성 탭이면 e.preventDefault()로 라우터 이동을 막고, scrollRef?.current?.scrollTo({ top: 0, behavior: 'smooth' })만 호출. 각 페이지에서 스크롤 컨테이너 ref를 탭바에 넘기는 패턴으로 두었다.
정리
- iOS WKWebView: 수평 스와이프는 네이티브
addEventListener+touchmove에서passive: false+preventDefault()조합이 필요하다. React 합성 이벤트만으로는 브라우저가 먼저 가로챈다. - 이중 임계값: 10px에서 방향 감지·차단, 50px에서 동작 확정.
- 스와이프가 안 먹히면: 이벤트가 붙은 요소가 실제로 화면을 얼마나 차지하는지 확인.
flex-1로 영역을 채우는 것만으로 해결되는 경우가 많다. - 클로저: useEffect 안 리스너는 ref로 최신 상태만 참조하게 하면 리스너 재등록을 줄일 수 있다.
- UX: 더블탭 같은 비표준보다, 네이티브 앱에서 쓰는 싱글탭 스크롤 투 탑이 발견하기 쉽고 직관적이다.