본문으로 건너뛰기
wooncloud

롱프레스 드래그가 안 되는 이유 — touch-action 은 제스처가 시작될 때 확정된다

touch-action 은 pointerdown 시점에 이미 확정돼 늦게 바꾸면 소용없고, 롱프레스 시작 순간 touchmove 를 passive: false 로 preventDefault 해야 스크롤이 드래그를 취소하지 않습니다.

·9 min read·— views··

상황

모바일 웹에서 "길게 눌러 드래그" 를 직접 구현했습니다. HTML5 Drag and Drop API 는 터치를 지원하지 않으니 Pointer Events 로 만드는 게 정석입니다.

// 500ms 눌러 있으면 드래그 모드로 진입
onPointerDown = (e) => {
  if (e.pointerType !== 'touch') return;
  timer = setTimeout(() => {
    setDragging(true);
    document.body.style.touchAction = 'none'; // 스크롤 잠금 (이라고 믿었다)
    createGhost(e);
  }, 500);
};

코드는 완벽해 보였습니다. 롱프레스 감지도 되고, 고스트 엘리먼트도 뜨고, elementsFromPoint 로 드롭 대상도 잘 찾습니다. 그런데 실기기에서 손가락을 움직이는 순간 드래그가 통째로 취소됐습니다. 고스트는 사라지고 목록만 스크롤됐습니다.

원인

touch-action 은 제스처가 시작되는 시점에 값이 확정됩니다.

브라우저는 touchstart(= 첫 pointerdown) 시점에 해당 지점의 touch-action 계산값을 읽어 "이 제스처를 스크롤로 처리할지" 를 미리 정합니다. 그 뒤에 값을 바꿔도 이미 진행 중인 제스처에는 반영되지 않습니다.

그래서 위 코드의 순서가 치명적입니다.

  1. 손가락이 닿습니다 → 브라우저: "여긴 touch-action: auto, 스크롤 가능한 제스처군"
  2. 500ms 뒤 우리가 touchAction = 'none' 으로 바꿉니다 → 이미 늦음
  3. 손가락이 움직입니다 → 브라우저가 예정대로 스크롤 시작
  4. 스크롤이 시작되면 브라우저는 포인터를 회수합니다 → pointercancel 발생
  5. pointercancel 핸들러가 드래그를 정리합니다 → 드래그 종료

즉 제가 짠 정리 로직이 정상 동작한 결과였습니다. 범인은 pointercancel 이 아니라 한 발 늦은 touch-action 이었습니다.

그럼 처음부터 touch-action: none 을 걸면 되지 않나요? 그러면 그 요소 위에서 평소 스크롤이 아예 안 됩니다. 목록 아이템이 드래그 소스인데 리스트를 스크롤할 수 없게 되는 셈이라, 조건부로 켜는 것 외엔 방법이 없습니다.

해결

진행 중인 제스처를 막을 수 있는 건 touch-action 이 아니라 이벤트 취소입니다. 드래그가 실제로 시작된 순간에만 touchmove 를 가로채 preventDefault() 합니다.

let touchGuard = null;
 
const blockNativeScroll = () => {
  if (touchGuard) return;
  touchGuard = (e) => { if (e.cancelable) e.preventDefault(); };
  // passive: false 가 핵심 — 기본값(passive)에서는 preventDefault 가 무시된다
  document.addEventListener('touchmove', touchGuard, { passive: false });
};
 
const unblockNativeScroll = () => {
  if (!touchGuard) return;
  document.removeEventListener('touchmove', touchGuard);
  touchGuard = null;
};

그리고 롱프레스가 발동하는 바로 그 순간, 다른 무엇보다 먼저 호출합니다.

timer = setTimeout(() => {
  blockNativeScroll();   // 가장 먼저
  setDragging(true);
  createGhost(e);
}, 500);

이러면 첫 touchmove 가 취소되어 스크롤이 애초에 시작되지 않고, 따라서 pointercancel 도 오지 않습니다. 드래그가 끝나거나 취소될 때 리스너를 반드시 해제합니다.

touchmove 리스너를 걸 때 { passive: false } 를 빼먹으면 아무 효과가 없습니다. 브라우저는 문서 레벨 터치 리스너를 기본 passive 로 취급하고, passive 리스너의 preventDefault() 는 조용히 무시됩니다(콘솔 경고만 뜹니다).

같이 걸린 것들

터치 드래그를 직접 만들 때 함께 밟게 되는 지뢰가 몇 개 더 있습니다.

드롭 직후 클릭이 한 번 더 발생한다

터치는 pointerup 뒤에 마우스 이벤트를 합성하므로, "탭 = 열기" 인 UI 라면 옮기자마자 항목이 열립니다. 드래그로 끝난 제스처의 다음 클릭 하나를 캡처 단계에서 삼킵니다.

function suppressNextClick() {
  const onClick = (ev) => { ev.preventDefault(); ev.stopPropagation(); };
  document.addEventListener('click', onClick, { capture: true, once: true });
  // click 이 합성되지 않는 경우도 있으니 반드시 회수
  setTimeout(() => document.removeEventListener('click', onClick, { capture: true }), 400);
}

iOS 는 롱프레스에 자체 메뉴를 띄운다

복사/공유 팝업이 우리 드래그가 시작되는 바로 그 제스처를 가로챕니다. user-select: none 만으로는 안 되고 별도 속성이 필요합니다.

[data-draggable] { -webkit-touch-callout: none; }

드래그 중엔 손가락으로 스크롤할 수 없다

방금 우리가 막았기 때문입니다. 화면 밖 대상으로 옮기려면 가장자리 자동 스크롤을 직접 구현해야 합니다. 없으면 "지금 보이는 곳으로만" 옮길 수 있는 반쪽 기능이 됩니다.

const tick = () => {
  const r = container.getBoundingClientRect();
  const EDGE = 72, MAX = 16;
  let dv = 0;
  if (pointer.y < r.top + EDGE)         dv = -((r.top + EDGE - pointer.y) / EDGE) * MAX;
  else if (pointer.y > r.bottom - EDGE) dv =  ((pointer.y - (r.bottom - EDGE)) / EDGE) * MAX;
  if (dv) {
    container.scrollTop += dv;
    highlightDropTargetAt(pointer.x, pointer.y); // 스크롤로 대상이 바뀌므로 같이 갱신
  }
  raf = requestAnimationFrame(tick);
};

하이라이트를 DOM 속성으로만 토글했다면 CSS 규칙이 실제로 있는지 확인한다

목록 전체 리렌더를 피하려고 프레임워크 상태 대신 setAttribute('data-drop-active', 'true') 를 쓰는 건 좋은 최적화지만, 대응하는 스타일이 없으면 기능은 도는데 화면엔 아무 변화가 없습니다. 행 배경이 인라인 스타일이라면 !important 도 필요합니다.

정리

  • touch-action 은 선언적 사전 설정입니다. 이미 시작된 제스처를 되돌리지 못합니다.
  • 진행 중인 제스처를 막는 유일한 방법은 touchmove + { passive: false } + preventDefault() 입니다.
  • 드래그가 "취소되는" 증상을 보면 pointercancel 을 의심하기 전에 브라우저가 스크롤을 시작한 것 아닌지 부터 확인합니다. 그게 대부분의 원인입니다.

참고