GODRICH

모바일 터치 제스처 9종 — 손가락 하나로 돌아가는 UI

모바일 터치 제스처는 손가락이 화면에 닿아 움직인 거리와 속도를 읽어 화면을 바꾸는 조작 방식입니다. 아홉 개 중 둘은 손을 떼는 순간에야 결과가 갈리고, 하나는 끝까지 밀지 않으면 아무 일도 일어나지 않습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 유행 순서가 아니라 손가락이 화면에 닿아 있는 시간 순서로 놓았습니다. 처음은 닿았다 떨어지기만 하는 탭 두 번(01)이고, 그다음은 끝까지 밀어야 끝나는 한 방향 밀기(02)입니다. 이어서 손가락이 원을 그리며 값을 바꾸고(03), 화면 한 장을 통째로 밀어 넘기고(04), 민 거리로 통과와 거절을 가릅니다(05). 여섯 번째부터는 손가락이 시작한 자리가 의미를 갖습니다. 화면 가장자리에서 시작하면 뒤로 가기(06)가 되고, 목록 끝에서 더 당기면 저항이 붙으며(07), 아래에서 끌어올리면 시트가 단계에 걸립니다(08). 마지막 하나(09)만 손가락이 둘입니다. 아홉 개 전부 pointerdown·pointermove·pointerup 하나로 마우스와 손가락을 같이 받고, 제스처마다 버튼이나 화살표 키라는 대체 조작이 따로 들어 있습니다.

01더블탭 줌

사진을 두 번 연속 누르면 두 번째로 닿은 자리를 중심으로 확대되고, 한 번 더 누르면 원래 크기로 돌아옵니다. 확대 중심은 transformOrigin에 탭 좌표를 그대로 넣어 잡고, 옆의 더하기·빼기 버튼은 같은 단계 배열을 밟습니다.

dblclicktransform-originaria-live
photo.addEventListener('pointerdown', function (e) {
  var now = Date.now();
  if (now - lastTap < 320) {
    var r = photo.getBoundingClientRect();
    var x = e.clientX - r.left, y = e.clientY - r.top;
    pulse(x, y);
    go(at === STEPS.length - 1 ? 0 : at + 1, x, y);
    lastTap = 0;
    e.preventDefault();
  } else { lastTap = now; }
});

02슬라이드 잠금 해제 트랙

손잡이를 트랙 끝까지 밀어야만 잠금이 풀리고, 중간에 손을 떼면 제자리로 돌아옵니다. 손잡이를 잡는 순간 setPointerCapture가 그 포인터를 붙들어, 손가락이 트랙 밖으로 나가도 이동이 끊기지 않습니다.

setPointerCapturetranslateXArrowRight
handle.addEventListener('pointerdown', function (e) {
  if (done) return;
  dragging = true; sx = e.clientX; x0 = x;
  handle.setPointerCapture(e.pointerId);
  handle.style.transition = 'none';
  e.preventDefault();
});
handle.addEventListener('pointermove', function (e) {
  if (!dragging) return;
  paint(x0 + (e.clientX - sx));
});

03돌려서 조절하는 다이얼

원형 손잡이를 잡아 돌린 각도만큼 값이 오르내리고, 게이지 호가 그 각도까지 채워집니다. 각도는 다이얼 중심과 손가락 좌표 사이의 Math.atan2로 구하고, 시작 각도 −135도를 기준으로 270도 안에 가둡니다.

atan2stroke-dashoffsetArrowUp
function fromPointer(e) {
  var r = dial.getBoundingClientRect();
  var dx = e.clientX - (r.left + r.width / 2);
  var dy = e.clientY - (r.top + r.height / 2);
  var deg = Math.atan2(dy, dx) * 180 / Math.PI;
  var rel = deg - BASE;
  while (rel > 180) rel -= 360;
  while (rel < -180) rel += 360;
  if (rel < -SPAN / 2) rel = -SPAN / 2;
  if (rel > SPAN / 2) rel = SPAN / 2;
  setValue((rel + SPAN / 2) / SPAN * 100);
}

04스와이프 탭 전환

패널이 손가락을 실시간으로 따라오다가, 손을 떼는 순간 남은 거리와 속도를 같이 보고 마저 넘어갈지 제자리로 돌아올지 정합니다. 거리는 44px, 속도는 0.45px/ms가 기준선이고 둘 중 하나만 넘겨도 넘어갑니다.

translateXthresholdroving tabindex
function drop() {
  if (!dragging) return;
  dragging = false;
  track.style.transition = '';
  var dx = lastX - x0;
  if (dx < -44 || v < -.45) go(at + 1);
  else if (dx > 44 || v > .45) go(at - 1);
  else go(at);
}

05스와이프 카드 덱

카드를 좌우로 끌면 기울기와 함께 통과·거절 스탬프가 진해지고, 64px을 넘기면 판정이 확정되며 카드가 화면 밖으로 날아갑니다. 기울기는 끈 거리를 9로 나눈 값이고 최대 12도에서 멈춥니다.

rotatevelocityaria-label
top.addEventListener('pointermove', function (e) {
  if (!dragging) return;
  var dx = e.clientX - x0;
  var r = Math.max(-12, Math.min(12, dx / 9));
  top.style.transform = 'translateX(' + dx + 'px) rotate(' + r + 'deg)';
  top.querySelector('.cd__stamp--pass').style.opacity = Math.max(0, Math.min(1, dx / 64));
  top.querySelector('.cd__stamp--skip').style.opacity = Math.max(0, Math.min(1, -dx / 64));
});

06가장자리 스와이프 백

화면 왼쪽 가장자리 28px 안에서 시작한 드래그만 뒤로 가기로 인식하고, 그보다 안쪽에서 시작한 드래그는 그냥 무시합니다. 끌린 거리가 프레임 폭의 55%를 기준으로 잰 진행률에서 72%를 넘어야 이동이 확정되고, 못 미치면 미리 나온 이전 화면이 다시 들어갑니다.

translateXthresholdhistory
frame.addEventListener('pointerdown', function (e) {
  var r = frame.getBoundingClientRect();
  if (e.clientX - r.left > 28) return;
  dragging = true; x0 = e.clientX;
  frame.setPointerCapture(e.pointerId);
  prev.style.transition = 'none';
  cur.style.transition = 'none';
  e.preventDefault();
});

07러버밴드 경계 저항

목록 끝을 넘겨 당기면 넘어간 거리의 3분의 1만 따라와 손가락과 화면이 어긋나기 시작하고, 그 어긋남이 곧 "여기가 끝"이라는 신호가 됩니다. 손을 떼면 튀는 이징으로 원래 자리에 돌아갑니다.

overscrollresistancecubic-bezier
function paint(y) {
  var b = bounds();
  var yd = y;
  if (yd < b.top) yd = b.top + (yd - b.top) / 3;
  if (yd > b.bottom) yd = b.bottom + (yd - b.bottom) / 3;
  cur = y;
  inner.style.transform = 'translateY(' + (-yd) + 'px)';
  topEnd.classList.toggle('is-on', y < -6);
  bottomEnd.classList.toggle('is-on', y > b.bottom + 6);
}

08끌어올리는 바텀시트

시트가 손가락을 따라 오르내리다가 놓으면 가장 가까운 단계로 붙고, 아래로 세게 던지면 거리와 상관없이 닫힙니다. 던졌는지는 마지막 두 이벤트 사이의 속도로 재고 0.5px/ms가 경계입니다.

translateYsnapEscape
function drop() {
  if (!dragging) return;
  dragging = false;
  sheet.style.transition = '';
  var cur2 = startPos + (lastY - startY);
  if (v0 > .5) { go(0); return; }
  if (v0 < -.5) { go(SNAPS.length - 1); return; }
  var best = 0, dist = 1e9;
  SNAPS.forEach(function (s, i) {
    var d = Math.abs(cur2 - s);
    if (d < dist) { dist = d; best = i; }
  });
  go(best);
}

09핀치 줌

손가락 두 개의 좌표를 각각 기억해 두고, 둘 사이 거리의 비율로 확대율을, 기울기 차이로 회전 각도를 만듭니다. 마우스에는 두 번째 손가락이 없으므로 휠로 확대하고 드래그로 이동하게 따로 열어 두었습니다.

pointermapscalectrl+wheel
function pinchInfo() {
  var ks = Object.keys(ptrs);
  if (ks.length < 2) return null;
  var a = ptrs[ks[0]], b = ptrs[ks[1]];
  var r = frame.getBoundingClientRect();
  var ax = a.x - r.left, ay = a.y - r.top, bx = b.x - r.left, by = b.y - r.top;
  return {
    d: Math.hypot(ax - bx, ay - by),
    ang: Math.atan2(by - ay, bx - ax) * 180 / Math.PI
  };
}

어디서 깨지는가 — 함정

제스처 데모의 진짜 함정은 손가락 쪽이 아니라 화면 밖으로 나간 요소가 문서 크기에 그대로 남는다는 점이었습니다. 05번 카드 덱은 판정이 끝난 카드를 translateX(230px)로 날려 보내는데, 카드가 눈에 보이지 않게 된 뒤에도 그 위치가 문서 폭에 계속 잡힙니다. 320px 폭으로 재어 보니 카드 한 장이 화면 오른쪽으로 100px 넘게 튀어나와 있었고, 무대를 감싼 overflow: hidden은 그 값을 줄여 주지 않았습니다. 보이지 않는 것과 크기에 안 잡히는 것은 다른 일입니다. 고친 방법은 덱 자신에게 overflow: hidden과 고정 크기를 줘서 카드가 덱 밖으로 나가는 순간 잘려 나가게 한 것이고, 그러자 같은 애니메이션에서 변한 픽셀 면적이 29.6%에서 9.7%로 내려갔습니다. 날아가던 카드가 무대 밖까지 그리던 만큼이 통째로 빠진 값입니다.

그다음으로 자주 걸린 것은 회전입니다. 03번 다이얼은 손잡이를 rotate로 돌리는데, 손잡이 안에 넣은 스피커 아이콘도 같이 돌아 값이 커질수록 거꾸로 섰습니다. 같은 각도만큼 반대로 돌리는 애니메이션을 아이콘에만 따로 걸어 세워 두었습니다. 게이지 호도 처음에는 stroke-dasharray에 길이 하나만 줬다가 그 길이가 원 둘레를 넘도록 반복되면서 호가 꼬였습니다. 270도만 그리려면 그리는 길이 197.92와 남기는 길이 66.06을 함께 적어야 합니다. 이런 자잘한 수정까지 다 반영한 아홉 벌 소스는 압축 비밀번호 49t2bve3를 넣으면 열리는 zip 안에 들어 있고, 바닐라와 React 판이 같은 값을 씁니다.

접근성

아홉 개 전부 제스처가 유일한 길이 되지 않게 만들었습니다. 01번은 더하기·빼기 버튼, 02번은 화살표 오른쪽 키, 03번은 화살표 네 방향과 Home·End, 04번은 탭 버튼과 좌우 화살표에 Home·End 까지, 05번은 통과·거절 버튼과 좌우 화살표, 06번은 뒤로 가기 버튼, 07번은 위아래 화살표, 08번은 단계 버튼과 Escape, 09번은 확대·축소 버튼과 화살표 이동이 각각 같은 동작을 합니다. 값을 다루는 03번에는 role="slider"aria-valuenow가 붙어 지금 값을 읽어 주고, 상태가 바뀌는 01·02·03·05·09번의 표시 영역에는 aria-live="polite"를 걸어 화면을 못 보는 사람도 결과를 듣습니다. prefers-reduced-motion: reduce에서는 자동 재생 루프만 끄고 실제 조작과 상태 값은 그대로 남습니다. 포인터 이벤트로 마우스와 터치를 한 벌로 받는 방식은 MDN의 포인터 이벤트 문서에 정리돼 있습니다.

손가락으로 누르는 부품을 더 찾는다면 내비게이션 카테고리스크롤 카테고리에 같은 계열이 모여 있습니다.

FAQ

터치 이벤트 대신 포인터 이벤트만 써도 되나요?

됩니다. 아홉 개 전부 touchstartmousedown을 따로 쓰지 않고 pointerdown·pointermove·pointerup 한 벌만 씁니다. 포인터 이벤트는 마우스·손가락·펜을 같은 모양으로 넘겨주기 때문에, 마우스로 끌든 손가락으로 밀든 같은 함수가 그대로 돌아갑니다. 다만 두 손가락을 같이 다루는 09번처럼 포인터를 여러 개 받으려면 e.pointerId를 키로 좌표를 따로 기억해 둬야 합니다.

드래그하다 손가락이 요소 밖으로 나가면 왜 멈추나요?

포인터를 붙잡지 않았기 때문입니다. 기본 동작에서는 손가락이 요소 경계를 넘는 순간 그 요소가 이벤트를 더 받지 못합니다. 02번처럼 pointerdown에서 setPointerCapture(e.pointerId)를 한 번 부르면 그 포인터가 떨어질 때까지 이벤트가 계속 그 요소로 오고, 빠른 드래그에서도 손잡이가 멈추지 않습니다.

스와이프가 페이지 스크롤과 자꾸 겹칩니다

CSS touch-action으로 어느 방향을 브라우저에 넘길지 정해야 합니다. 가로로만 미는 04번은 touch-action: pan-y라서 세로 스크롤은 그대로 두고 가로만 가져가고, 07·08·09번처럼 방향을 전부 직접 다루는 곳은 touch-action: none으로 브라우저 기본 스크롤을 아예 끕니다. 이 한 줄이 없으면 손가락 하나로 두 가지가 동시에 움직입니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.