05롱프레스 판정 — 대기 시간과 이동 임계값으로 스크롤과 드래그를 가르는 계산
pointerdown 에서 타이머를 걸어 일정 시간(예: 250ms)이 지나야 드래그 모드로 전환하고, 그 사이 pointermove 누적 이동거리가 임계값을 넘으면 스크롤 의도로 보고 타이머를 취소합니다. 반대로 손을 뗀 pointercancel 이 타이머보다 먼저 오면 그냥 탭으로 끝냅니다.
쓰임모바일 리스트 재정렬
setTimeoutpointermovepointercancel

<main class="stage">
<div class="lp">
<p class="lp__hint">250ms 를 버티고 이동 임계값을 넘지 않아야 드래그로 전환합니다</p>
<ul class="lp__list" id="lpList">
<li class="lp__row" data-id="0"><span class="lp__txt">빨래</span></li>
<li class="lp__row" data-id="1"><span class="lp__txt">설거지</span></li>
<li class="lp__row" data-id="2"><span class="lp__txt">청소</span></li>
<li class="lp__row" data-id="3"><span class="lp__txt">분리수거</span></li>
</ul>
<p class="lp__meta"><b class="lp__state" id="lpState">press 0ms</b><span class="lp__unit" id="lpUnit">move 0px</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 220ms;
$easing: cubic-bezier(.2,.8,.2,1);
$color: $subject-cream;
@include stage($stage-ink);
.lp {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.lp__hint {
margin: 0;
padding: 0 $sp-1;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-cream, .72);
}
.lp__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: $sp-1;
overflow-y: auto;
}
.lp__row {
padding: $sp-2;
border-radius: $r-sm;
background: rgba($subject-cream, .12);
box-shadow: $shadow-press;
color: $color;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
touch-action: none;
cursor: pointer;
}
.lp__row--drag {
background: $subject-blue-deep;
box-shadow: $shadow-raised;
position: relative;
z-index: 2;
}
.lp__txt {
pointer-events: none;
}
.lp__meta {
margin: 0;
padding: 0 $sp-1;
display: flex;
align-items: baseline;
gap: $sp-1;
}
.lp__state {
font-weight: 700;
font-size: 12px;
color: $color;
}
.lp__unit {
font-weight: 400;
font-size: 11px;
color: rgba($subject-cream, .72);
}
@media (max-width: 340px) {
.lp { max-width: 260px; gap: $sp-1; }
.lp__hint { font-size: 10px; }
.lp__row { font-size: 11px; padding: $sp-1; }
.lp__list { max-height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.lp__row {
transition: none !important;
transform: none !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lp {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: 8px;
}
.lp__hint {
margin: 0;
padding: 0 4px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(255, 247, 230, 0.72);
}
.lp__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
}
.lp__row {
padding: 8px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.12);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #fff7e6;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
touch-action: none;
cursor: pointer;
}
.lp__row--drag {
background: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
position: relative;
z-index: 2;
}
.lp__txt {
pointer-events: none;
}
.lp__meta {
margin: 0;
padding: 0 4px;
display: flex;
align-items: baseline;
gap: 4px;
}
.lp__state {
font-weight: 700;
font-size: 12px;
color: #fff7e6;
}
.lp__unit {
font-weight: 400;
font-size: 11px;
color: rgba(255, 247, 230, 0.72);
}
@media (max-width: 340px) {
.lp {
max-width: 260px;
gap: 4px;
}
.lp__hint {
font-size: 10px;
}
.lp__row {
font-size: 11px;
padding: 4px;
}
.lp__list {
max-height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.lp__row {
transition: none !important;
transform: none !important;
}
}// 롱프레스 판정(05) — pointerdown 에서 setTimeout(HOLD_MS) 타이머를 걸고, 타이머가 살아있는 동안
// pointermove 의 이동량이 임계값(MOVE_PX)을 넘으면 clearTimeout 으로 취소해 스크롤/탭으로 넘긴다.
// 타이머가 끝까지 살아남아 activate() 가 불리면 그 행이 포인터를 따라가는 드래그로 바뀐다.
// 표시되는 250ms 는 HOLD_MS 상수와 같은 값이다.
(function () {
var list = document.getElementById('lpList');
var stateEl = document.getElementById('lpState');
var unitEl = document.getElementById('lpUnit');
if (!list || !stateEl || !unitEl) return;
var HOLD_MS = 250;
var MOVE_PX = 8;
var press = null; // { row, startX, startY, timer, pressStart, raf }
var active = null; // { row, offsetInRow }
function rowsExcept(row) {
return Array.prototype.slice.call(list.children).filter(function (r) { return r !== row; });
}
function flipInsert(row, target) {
var siblings = rowsExcept(row);
var first = siblings.map(function (s) { return s.getBoundingClientRect(); });
list.insertBefore(row, target);
siblings.forEach(function (s, i) {
var r = s.getBoundingClientRect();
var dy = first[i].top - r.top;
if (dy !== 0) {
s.style.transition = 'none';
s.style.transform = 'translateY(' + dy + 'px)';
requestAnimationFrame(function () {
s.style.transition = 'transform 220ms cubic-bezier(.2,.8,.2,1)';
s.style.transform = 'translateY(0)';
});
}
});
}
function reflow(row, pointerY) {
var siblings = rowsExcept(row);
var target = null;
for (var i = 0; i < siblings.length; i++) {
var r = siblings[i].getBoundingClientRect();
if (pointerY < r.top + r.height / 2) { target = siblings[i]; break; }
}
if (target === row.nextElementSibling) return;
if (target === null && row === list.lastElementChild) return;
flipInsert(row, target);
}
function pressTick() {
if (!press) return;
var el = press.pressStart === null ? 0 : performance.now() - press.pressStart;
stateEl.textContent = 'press ' + Math.min(HOLD_MS, Math.round(el)) + 'ms';
press.raf = requestAnimationFrame(pressTick);
}
function startPress(row, x, y) {
cancelPress();
press = { row: row, startX: x, startY: y, pressStart: performance.now(), raf: null, timer: null };
press.timer = setTimeout(function () { activate(row, y); }, HOLD_MS);
pressTick();
}
function cancelPress() {
if (!press) return;
if (press.timer) clearTimeout(press.timer);
if (press.raf) cancelAnimationFrame(press.raf);
press = null;
}
function activate(row, y) {
if (press && press.row === row) cancelPress();
var r = row.getBoundingClientRect();
row.classList.add('lp__row--drag');
stateEl.textContent = '드래그';
active = { row: row, offsetInRow: y - r.top };
}
function moveActive(clientY) {
if (!active) return;
var listRect = list.getBoundingClientRect();
var ty = clientY - active.offsetInRow - listRect.top - active.row.offsetTop;
active.row.style.transform = 'translateY(' + ty.toFixed(1) + 'px)';
reflow(active.row, clientY);
}
function endActive() {
if (!active) return;
var row = active.row;
active = null;
row.classList.remove('lp__row--drag');
row.style.transition = 'transform 220ms cubic-bezier(.2,.8,.2,1)';
row.style.transform = 'translateY(0)';
setTimeout(function () { row.style.transition = ''; }, 240);
stateEl.textContent = 'press 0ms';
unitEl.textContent = 'move 0px';
}
function onDown(e) {
var row = e.target.closest('.lp__row');
if (!row) return;
stop();
row.setPointerCapture(e.pointerId);
startPress(row, e.clientX, e.clientY);
}
function onMove(e) {
if (active) { moveActive(e.clientY); return; }
if (!press) return;
var dx = e.clientX - press.startX, dy = e.clientY - press.startY;
var d = Math.sqrt(dx * dx + dy * dy);
unitEl.textContent = 'move ' + Math.round(d) + 'px';
if (d > MOVE_PX) cancelPress();
}
function onUp() {
if (active) { endActive(); return; }
cancelPress();
stateEl.textContent = 'press 0ms';
unitEl.textContent = 'move 0px';
}
list.addEventListener('pointerdown', onDown);
list.addEventListener('pointermove', onMove);
list.addEventListener('pointerup', onUp);
list.addEventListener('pointercancel', onUp);
// 자동 시연 — 같은 함수를 실제 타이밍으로 호출한다(setTimeout 은 실제 시간을 쓴다)
var playing = true;
function stop() { playing = false; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function loopOnce() {
var row = list.children[0];
var r = row.getBoundingClientRect();
startPress(row, r.left + r.width / 2, r.top + r.height / 2);
setTimeout(function () {
if (!active) return;
var r3 = list.children[2] ? list.children[2].getBoundingClientRect() : r;
moveActive(r3.top + r3.height / 2 + 4);
}, HOLD_MS + 120);
setTimeout(function () { onUp(); }, HOLD_MS + 420);
}
if (reduce) {
activate(list.children[0], list.children[0].getBoundingClientRect().top);
endActive();
} else {
loopOnce();
setInterval(function () { if (playing) loopOnce(); }, 1700);
}
})();// DragTouchLongPress — vanilla 판(run/336/demo/05-drag-touch-long-press/index.html)
// 의 React 포트. pointerdown 에서 setTimeout(HOLD_MS) 타이머를 걸고, 타이머가 살아있는 동안
// pointermove 의 이동량이 임계값(MOVE_PX)을 넘으면 clearTimeout 으로 취소해 스크롤·탭으로
// 넘긴다. 타이머가 끝까지 살아남아 activate() 가 불리면 그 행이 포인터를 따라가는 드래그로
// 바뀐다. 표시되는 250ms 는 HOLD_MS 상수와 같은 값이다.
import { useEffect, useRef } from "react";
import styles from "./DragTouchLongPress.module.scss";
export type DragTouchLongPressProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "250ms 를 버티고 이동 임계값을 넘지 않아야 드래그로 전환합니다";
const ROW_TXT = ["빨래", "설거지", "청소", "분리수거"];
type Box<T> = { current: T };
type PressState = { row: HTMLElement; startX: number; startY: number; pressStart: number; raf: number; timer: number };
type ActiveState = { row: HTMLElement; offsetInRow: number };
export default function DragTouchLongPress({
duration = "220ms",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6"
}: DragTouchLongPressProps) {
const listRef = useRef<HTMLUListElement | null>(null);
const stateRef = useRef<HTMLElement | null>(null);
const unitRef = useRef<HTMLSpanElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const list = listRef.current;
const stateEl = stateRef.current;
const unitEl = unitRef.current;
if (!list || !stateEl || !unitEl) return;
const HOLD_MS = 250;
const MOVE_PX = 8;
let press: PressState | null = null;
let active: ActiveState | null = null;
function rowsExcept(row: HTMLElement): HTMLElement[] {
return Array.prototype.slice.call(list!.children).filter((r: HTMLElement) => r !== row);
}
function flipInsert(row: HTMLElement, target: HTMLElement | null): void {
const siblings = rowsExcept(row);
const first = siblings.map((s) => s.getBoundingClientRect());
list!.insertBefore(row, target);
siblings.forEach((s, i) => {
const r = s.getBoundingClientRect();
const dy = first[i].top - r.top;
if (dy !== 0) {
s.style.transition = "none";
s.style.transform = "translateY(" + dy + "px)";
requestAnimationFrame(() => {
s.style.transition = "transform 220ms cubic-bezier(.2,.8,.2,1)";
s.style.transform = "translateY(0)";
});
}
});
}
function reflow(row: HTMLElement, pointerY: number): void {
const siblings = rowsExcept(row);
let target: HTMLElement | null = null;
for (let i = 0; i < siblings.length; i++) {
const r = siblings[i].getBoundingClientRect();
if (pointerY < r.top + r.height / 2) {
target = siblings[i];
break;
}
}
if (target === row.nextElementSibling) return;
if (target === null && row === list!.lastElementChild) return;
flipInsert(row, target);
}
function pressTick(): void {
if (!press) return;
const el = press.pressStart === null ? 0 : performance.now() - press.pressStart;
stateEl!.textContent = "press " + Math.min(HOLD_MS, Math.round(el)) + "ms";
press.raf = requestAnimationFrame(pressTick);
rafRef.current = press.raf;
}
function startPress(row: HTMLElement, x: number, y: number): void {
cancelPress();
press = { row, startX: x, startY: y, pressStart: performance.now(), raf: 0, timer: 0 };
press.timer = window.setTimeout(() => activate(row, y), HOLD_MS);
pressTick();
}
function cancelPress(): void {
if (!press) return;
if (press.timer) clearTimeout(press.timer);
if (press.raf) cancelAnimationFrame(press.raf);
press = null;
}
function activate(row: HTMLElement, y: number): void {
if (press && press.row === row) cancelPress();
const r = row.getBoundingClientRect();
row.classList.add(styles.rowDrag);
stateEl!.textContent = "드래그";
active = { row, offsetInRow: y - r.top };
}
function moveActive(clientY: number): void {
if (!active) return;
const listRect = list!.getBoundingClientRect();
const ty = clientY - active.offsetInRow - listRect.top - active.row.offsetTop;
active.row.style.transform = "translateY(" + ty.toFixed(1) + "px)";
reflow(active.row, clientY);
}
function endActive(): void {
if (!active) return;
const row = active.row;
active = null;
row.classList.remove(styles.rowDrag);
row.style.transition = "transform 220ms cubic-bezier(.2,.8,.2,1)";
row.style.transform = "translateY(0)";
setTimeout(() => {
row.style.transition = "";
}, 240);
stateEl!.textContent = "press 0ms";
unitEl!.textContent = "move 0px";
}
function onDown(e: PointerEvent): void {
const row = (e.target as HTMLElement).closest("." + styles.row) as HTMLElement | null;
if (!row) return;
stop();
row.setPointerCapture(e.pointerId);
startPress(row, e.clientX, e.clientY);
}
function onMove(e: PointerEvent): void {
if (active) {
moveActive(e.clientY);
return;
}
if (!press) return;
const dx = e.clientX - press.startX;
const dy = e.clientY - press.startY;
const d = Math.sqrt(dx * dx + dy * dy);
unitEl!.textContent = "move " + Math.round(d) + "px";
if (d > MOVE_PX) cancelPress();
}
function onUp(): void {
if (active) {
endActive();
return;
}
cancelPress();
stateEl!.textContent = "press 0ms";
unitEl!.textContent = "move 0px";
}
list.addEventListener("pointerdown", onDown as EventListener);
list.addEventListener("pointermove", onMove as EventListener);
list.addEventListener("pointerup", onUp as EventListener);
list.addEventListener("pointercancel", onUp as EventListener);
let playing = true;
function stop(): void {
playing = false;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const timeouts: number[] = [];
let intervalId = 0;
function loopOnce(): void {
const row = list!.children[0] as HTMLElement;
const r = row.getBoundingClientRect();
startPress(row, r.left + r.width / 2, r.top + r.height / 2);
timeouts.push(
window.setTimeout(() => {
if (!active) return;
const r3Row = list!.children[2] as HTMLElement | undefined;
const r3 = r3Row ? r3Row.getBoundingClientRect() : r;
moveActive(r3.top + r3.height / 2 + 4);
}, HOLD_MS + 120)
);
timeouts.push(window.setTimeout(onUp, HOLD_MS + 420));
}
if (reduce) {
const first = list.children[0] as HTMLElement;
activate(first, first.getBoundingClientRect().top);
endActive();
} else {
loopOnce();
intervalId = window.setInterval(() => {
if (playing) loopOnce();
}, 1700);
}
return () => {
cancelAnimationFrame(rafRef.current);
timeouts.forEach((t) => clearTimeout(t));
if (intervalId) clearInterval(intervalId);
list.removeEventListener("pointerdown", onDown as EventListener);
list.removeEventListener("pointermove", onMove as EventListener);
list.removeEventListener("pointerup", onUp as EventListener);
list.removeEventListener("pointercancel", onUp as EventListener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.lp}>
<p className={styles.hint}>{HINT}</p>
<ul className={styles.list} ref={listRef}>
{ROW_TXT.map((txt, i) => (
<li key={i} className={styles.row}>
<span className={styles.txt}>{txt}</span>
</li>
))}
</ul>
<p className={styles.meta}>
<b className={styles.state} ref={stateRef} style={{ color }}>press 0ms</b>
<span className={styles.unit} ref={unitRef}>move 0px</span>
</p>
</div>
</main>
);
}05 롱프레스 판정 — 대기 시간과 이동 임계값으로 스크롤과 드래그를 가르는 계산 — 측정 누적면적 21.082% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 드래그 앤 드롭 UI 리스트 — 목표 인덱스 계산을 얹은 9종
이 데모가 속한 글
드래그 앤 드롭 UI 리스트 — 목표 인덱스 계산을 얹은 9종포인터가 놓인 위치에서 목표 인덱스를 계산해 리스트·칸반·그리드가 자리를 바꾸고, 롱프레스·Alt 화살표·가장자리 자동 스크롤까지 — 드래그로 순서 바꾸는 리스트 9종, 코드는 비밀번호 zip으로 드립니다. 드래그 이 글의 데모 전부 보기


