본문으로 건너뛰기
wooncloud

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

모바일 웹에서 롱프레스 드래그 구현 시 터치 동작이 스크롤로 취소되는 원인과 해결책을 다룹니다. touch-action 속성은 제스처 시작 시점에 확정되므로, 드래그 시작 순간에 preventDefault를 호출해 스크롤을 차단하고 포인터 이벤트를 제어하는 올바른 구현 방법을 상세히 설명합니다

·8 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 을 의심하기 전에 브라우저가 스크롤을 시작한 것 아닌지 부터 확인한다. 그게 대부분의 원인이다.

참고