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

상황
모바일 웹에서 "길게 눌러 드래그" 를 직접 구현했다. 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 계산값을 읽어 "이 제스처를 스크롤로 처리할지" 를 미리 정한다. 그 뒤에 값을 바꿔도 이미 진행 중인 제스처에는 반영되지 않는다.
그래서 위 코드의 순서가 치명적이다.
- 손가락이 닿는다 → 브라우저: "여긴
touch-action: auto, 스크롤 가능한 제스처군" - 500ms 뒤 우리가
touchAction = 'none'으로 바꾼다 → 이미 늦음 - 손가락이 움직인다 → 브라우저가 예정대로 스크롤 시작
- 스크롤이 시작되면 브라우저는 포인터를 회수한다 →
pointercancel발생 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을 의심하기 전에 브라우저가 스크롤을 시작한 것 아닌지 부터 확인한다. 그게 대부분의 원인이다.