제스처와 터치 인터랙션 디자인: 모바일 UX 완전 가이드

디자인

제스처 디자인터치 인터랙션모바일 UXPointer Events사용자 경험

이 글은 누구를 위한 것인가

  • 스와이프, 드래그, 핀치 등 제스처 UX를 설계하려는 팀
  • 터치 친화적 인터페이스를 구현하려는 프론트엔드 개발자
  • 모바일 인터랙션 패턴을 체계적으로 적용하려는 팀

들어가며

마우스 클릭은 정확하지만 손가락은 넓다. 터치 타겟이 너무 작으면 실수가 늘어나고, 제스처 피드백이 없으면 "버튼이 눌렸나?" 의문이 든다. 모바일 UX는 손가락의 특성을 고려한 설계가 필요하다.

이 글은 bluefoxdev.kr의 제스처 터치 인터랙션 UX 가이드 를 참고하여 작성했습니다.


1. 터치 인터랙션 설계 원칙

[터치 타겟 크기]
  Apple HIG: 최소 44×44pt
  Google Material: 최소 48×48dp
  WCAG 2.5.5: 최소 44×44px (AAA)
  
  버튼이 작아도 터치 영역은 크게:
  .small-icon { padding: 12px; } /* 아이콘 16px + 패딩 = 40px */

[제스처 피드백]
  시각적: 색상 변화, 스케일 (transform: scale(0.95))
  촉각: navigator.vibrate([10]) (Android)
  애니메이션: 리플 효과, 이동 트랙

[스와이프 임계값]
  의도적 스와이프: 50px 이상 이동
  빠른 스와이프(flick): velocity > 0.3 px/ms
  취소 구간: 30% 이하 이동 후 손 떼면 취소

[제스처 충돌 방지]
  수직 스크롤 vs 가로 스와이프:
  - 처음 이동 방향 감지 후 고정
  touch-action: pan-y (가로 제스처만 처리)
  touch-action: none (모두 JS로 처리)

2. 제스처 인터랙션 구현

import { useRef, useState } from 'react';

// 스와이프 삭제 컴포넌트
function SwipeToDelete({ children, onDelete }: { children: React.ReactNode; onDelete: () => void }) {
  const [offset, setOffset] = useState(0);
  const startX = useRef(0);
  const isDragging = useRef(false);
  const threshold = 80; // 삭제 임계값 (px)

  const handlePointerDown = (e: React.PointerEvent) => {
    startX.current = e.clientX;
    isDragging.current = true;
    (e.target as HTMLElement).setPointerCapture(e.pointerId);
  };

  const handlePointerMove = (e: React.PointerEvent) => {
    if (!isDragging.current) return;
    const diff = e.clientX - startX.current;
    // 왼쪽으로만 스와이프
    setOffset(Math.min(0, diff));
  };

  const handlePointerUp = () => {
    isDragging.current = false;
    if (Math.abs(offset) >= threshold) {
      // 삭제 애니메이션 후 콜백
      setOffset(-window.innerWidth);
      setTimeout(onDelete, 300);
    } else {
      setOffset(0); // 원위치
    }
  };

  return (
    <div style={{ position: 'relative', overflow: 'hidden' }}>
      {/* 삭제 배경 */}
      <div style={{
        position: 'absolute', inset: 0, background: '#ef4444',
        display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
        paddingRight: 20, color: 'white', fontWeight: 600,
      }}>
        삭제
      </div>
      {/* 컨텐츠 */}
      <div
        style={{ transform: `translateX(${offset}px)`, transition: isDragging.current ? 'none' : 'transform 0.3s ease', touchAction: 'pan-y' }}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
      >
        {children}
      </div>
    </div>
  );
}

// 당겨서 새로고침 (Pull to Refresh)
function PullToRefresh({ onRefresh, children }: { onRefresh: () => Promise<void>; children: React.ReactNode }) {
  const [pullDistance, setPullDistance] = useState(0);
  const [isRefreshing, setIsRefreshing] = useState(false);
  const startY = useRef(0);
  const threshold = 80;

  const handleTouchStart = (e: React.TouchEvent) => {
    if (window.scrollY === 0) startY.current = e.touches[0].clientY;
  };

  const handleTouchMove = (e: React.TouchEvent) => {
    if (window.scrollY > 0) return;
    const distance = e.touches[0].clientY - startY.current;
    if (distance > 0) setPullDistance(Math.min(distance * 0.5, 120));
  };

  const handleTouchEnd = async () => {
    if (pullDistance >= threshold) {
      setIsRefreshing(true);
      await onRefresh();
      setIsRefreshing(false);
    }
    setPullDistance(0);
  };

  return (
    <div onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd}>
      <div style={{ height: pullDistance, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', transition: isRefreshing ? 'none' : 'height 0.3s' }}>
        {isRefreshing ? '새로고침 중...' : pullDistance >= threshold ? '손을 떼세요' : '당겨서 새로고침'}
      </div>
      {children}
    </div>
  );
}

마무리

터치 UX의 핵심은 "적절한 크기, 명확한 피드백, 예측 가능한 동작"이다. 터치 타겟은 최소 44px, 스와이프 임계값은 충분히 크게 설정해 실수 삭제를 방지한다. Pointer Events API는 마우스/터치/펜을 통합 처리해 코드 중복을 줄인다.