04ハンドル専用 — 小さな取っ手だけにポインターキャプチャをかけ行の残りを生かす分離
行全体ではなく取っ手アイコンだけにpointerdownを付け、setPointerCaptureでその後の移動をその要素に固定すると、同じ行のボタン・テキスト選択・リンクがそのまま生きています。取っ手の外で始まったポインターはドラッグへ昇格しないよう早期にフィルタします。
使いどころ設定項目リスト、ウィジェット順序
setPointerCapturepointerdownCSS custom properties

<main class="stage">
<div class="ho">
<p class="ho__hint">小さな取っ手だけにポインターキャプチャをかけ、行のボタンやリンクはそのまま生きます</p>
<ul class="ho__list" id="hoList">
<li class="ho__row" data-id="0">
<span class="ho__grip" tabindex="-1" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</span>
<span class="ho__txt">通知</span>
<button type="button" class="ho__act">オフ</button>
</li>
<li class="ho__row" data-id="1">
<span class="ho__grip" tabindex="-1" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</span>
<span class="ho__txt">表示</span>
<button type="button" class="ho__act">オン</button>
</li>
<li class="ho__row" data-id="2">
<span class="ho__grip" tabindex="-1" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</span>
<span class="ho__txt">アカウント</span>
<button type="button" class="ho__act">オン</button>
</li>
<li class="ho__row" data-id="3">
<span class="ho__grip" tabindex="-1" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</span>
<span class="ho__txt">セキュリティ</span>
<button type="button" class="ho__act">オフ</button>
</li>
</ul>
<p class="ho__meta"><b class="ho__state" id="hoState">キャプチャ済み</b><span class="ho__unit">オフ</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 240ms;
$easing: cubic-bezier(.2,.8,.2,1);
$color: $subject-blue-deep;
// 파스텔 강조(도구/파스텔.py to_pastel 결과: 채도 .34 · 명도 +.11)
$p-mint: #93d9c0;
@include stage($stage-paper);
.ho {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.ho__hint {
margin: 0;
padding: 0 $sp-1;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-ink, .72);
}
.ho__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: $sp-1;
overflow-y: auto;
}
.ho__row {
--ty: 0px;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-sm;
background: $subject-cream;
box-shadow: $shadow-press;
transform: translateY(var(--ty));
will-change: transform;
}
.ho__row--drag {
background: $stage-paper;
box-shadow: $shadow-raised;
z-index: 2;
position: relative;
}
.ho__grip {
display: grid;
place-items: center;
width: 20px;
height: 20px;
color: rgba($subject-ink, .72);
cursor: grab;
touch-action: none;
}
.ho__grip svg {
display: block;
}
.ho__txt {
flex: 1 1 auto;
min-width: 0;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
color: $subject-ink;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ho__act {
flex: 0 0 auto;
padding: 0 $sp-2;
height: 22px;
border: 0;
border-radius: $r-pill;
background: $p-mint;
color: $subject-ink;
font-weight: 700;
font-size: 10px;
cursor: pointer;
}
.ho__meta {
margin: 0;
padding: 0 $sp-1;
display: flex;
align-items: baseline;
gap: $sp-1;
}
.ho__state {
font-weight: 700;
font-size: 12px;
color: $color;
}
.ho__unit {
font-weight: 400;
font-size: 11px;
color: rgba($subject-ink, .72);
}
@media (max-width: 340px) {
.ho { max-width: 260px; gap: $sp-1; }
.ho__hint { font-size: 10px; }
.ho__txt { font-size: 11px; }
.ho__row { padding: $sp-1; }
.ho__list { max-height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.ho__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ho {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 8px;
}
.ho__hint {
margin: 0;
padding: 0 4px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
.ho__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
}
.ho__row {
--ty: 0px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 8px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(var(--ty));
will-change: transform;
}
.ho__row--drag {
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 2;
position: relative;
}
.ho__grip {
display: grid;
place-items: center;
width: 20px;
height: 20px;
color: rgba(23, 20, 26, 0.72);
cursor: grab;
touch-action: none;
}
.ho__grip svg {
display: block;
}
.ho__txt {
flex: 1 1 auto;
min-width: 0;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
color: #17141a;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ho__act {
flex: 0 0 auto;
padding: 0 8px;
height: 22px;
border: 0;
border-radius: 999px;
background: #93d9c0;
color: #17141a;
font-weight: 700;
font-size: 10px;
cursor: pointer;
}
.ho__meta {
margin: 0;
padding: 0 4px;
display: flex;
align-items: baseline;
gap: 4px;
}
.ho__state {
font-weight: 700;
font-size: 12px;
color: #0a4fe8;
}
.ho__unit {
font-weight: 400;
font-size: 11px;
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.ho {
max-width: 260px;
gap: 4px;
}
.ho__hint {
font-size: 10px;
}
.ho__txt {
font-size: 11px;
}
.ho__row {
padding: 4px;
}
.ho__list {
max-height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.ho__row {
transition: none !important;
transform: none !important;
}
}// 손잡이 전용(04) — pointerdown 이 .ho__grip 위에서만 setPointerCapture 를 걸어 행을 든다.
// 든 행은 목표 y(포인터-시작오프셋)를 그대로 따라가지 않고, rAF 마다 현재 위치를 목표로
// lerp(관성)시켜 --ty 커스텀 프로퍼티에 담아 transform: translateY(var(--ty)) 로 그린다.
// 이웃 행은 든 행의 중점이 자기 중점을 넘는 순간 인덱스를 바꾸고 FLIP 되돌림으로 자리를 잡는다.
(function () {
var list = document.getElementById('hoList');
var stateEl = document.getElementById('hoState');
if (!list || !stateEl) return;
var ROWS = Array.prototype.slice.call(list.children);
var LERP = 0.22;
var drag = null; // { row, startY, offsetInRow, pos, target, raf }
function rowsExcept(row) {
return Array.prototype.slice.call(list.children).filter(function (r) { return r !== row; });
}
// 삽입 대상 역산 — 든 행을 뺀 나머지 중 포인터 y 가 중점보다 위인 첫 행 앞에 놓는다
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);
}
// FLIP: row 를 target 바로 앞(target=null 이면 끝)으로 옮기고, 옮겨간 이웃들만 되돌림 애니메이션
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 240ms cubic-bezier(.2,.8,.2,1)';
s.style.transform = 'translateY(0)';
});
}
});
}
function beginDrag(row, clientY) {
var r = row.getBoundingClientRect();
row.classList.add('ho__row--drag');
drag = { row: row, offsetInRow: clientY - r.top, pos: 0, target: 0 };
tick();
}
function moveDrag(clientY) {
if (!drag) return;
var listRect = list.getBoundingClientRect();
drag.target = clientY - drag.offsetInRow - listRect.top - drag.row.offsetTop;
reflow(drag.row, clientY);
}
function tick() {
if (!drag) return;
drag.pos += (drag.target - drag.pos) * LERP;
drag.row.style.setProperty('--ty', drag.pos.toFixed(2) + 'px');
drag._raf = requestAnimationFrame(tick);
}
function endDrag() {
if (!drag) return;
var row = drag.row;
if (drag._raf) cancelAnimationFrame(drag._raf);
row.classList.remove('ho__row--drag');
row.style.transition = 'transform 220ms cubic-bezier(.2,.8,.2,1)';
row.style.setProperty('--ty', '0px');
setTimeout(function () { row.style.transition = ''; }, 240);
drag = null;
}
function onGripDown(e) {
var grip = e.target.closest('.ho__grip');
if (!grip) return;
stop();
var row = grip.closest('.ho__row');
if (!row) return;
grip.setPointerCapture(e.pointerId);
beginDrag(row, e.clientY);
}
function onGripMove(e) { moveDrag(e.clientY); }
function onGripUp() { endDrag(); }
list.addEventListener('pointerdown', onGripDown);
list.addEventListener('pointermove', onGripMove);
list.addEventListener('pointerup', onGripUp);
list.addEventListener('pointercancel', onGripUp);
// 진짜 버튼은 그대로 클릭된다 — 손잡이가 아니므로 드래그를 시작하지 않는다
list.addEventListener('click', function (e) {
var btn = e.target.closest('.ho__act');
if (!btn) return;
stop();
btn.textContent = btn.textContent === 'オン' ? 'オフ' : 'オン';
});
// 자동 시연 — 실제 pointerdown/move/up 과 같은 함수를 합성 좌표로 호출한다
var playing = true;
function stop() { playing = false; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 홀드 중 목표 위치를 여러 번 바꿔 관성 lerp 가 창 전체에서 계속 수렴하게 한다(수렴 뒤 정지 방지)
function scenario() {
var row0 = ROWS[0];
var r0 = row0.getBoundingClientRect();
beginDrag(row0, r0.top + r0.height / 2);
stateEl.textContent = 'キャプチャ済み';
var r2 = ROWS[2].getBoundingClientRect();
moveDrag(r2.top + r2.height / 2 + 6);
setTimeout(function () {
if (!drag) return;
var r1 = ROWS[1].getBoundingClientRect();
moveDrag(r1.top + r1.height / 2 - 4);
}, 260);
setTimeout(function () {
if (!drag) return;
var r3 = ROWS[3] ? ROWS[3].getBoundingClientRect() : r0;
moveDrag(r3.top + r3.height / 2 + 4);
}, 560);
}
function release() {
endDrag();
stateEl.textContent = 'キャプチャ済み';
}
if (reduce) {
scenario();
release();
} else {
function loopOnce() {
scenario();
setTimeout(release, 820);
}
loopOnce();
setInterval(function () { if (playing) loopOnce(); }, 1700);
}
})();// DragHandleOnly — vanilla 판(run/336/demo/04-drag-handle-only/index.html)
// 의 React 포트. pointerdown 이 손잡이(.grip) 위에서만 setPointerCapture 를 걸어 행을 든다.
// 든 행은 목표 y 를 그대로 따라가지 않고, rAF 마다 현재 위치를 목표로 lerp(관성)시켜
// --ty 커스텀 프로퍼티에 담아 transform: translateY(var(--ty)) 로 그린다. 이웃 행은 든 행의
// 중점이 자기 중점을 넘는 순간 인덱스를 바꾸고 FLIP 되돌림으로 자리를 잡는다.
import { useEffect, useRef } from "react";
import styles from "./DragHandleOnly.module.scss";
export type DragHandleOnlyProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "작은 손잡이에만 포인터캡처를 걸어 행의 버튼·링크는 그대로 살아 있습니다";
type RowDef = { id: number; label: string; act: string };
const ROW_DEFS: RowDef[] = [
{ id: 0, label: "알림", act: "끔" },
{ id: 1, label: "디스플레이", act: "켬" },
{ id: 2, label: "계정", act: "켬" },
{ id: 3, label: "보안", act: "끔" }
];
const GRIP_PATH_BG =
"M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16";
const GRIP_PATH_DOTS =
"M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12";
type Box<T> = { current: T };
type DragState = { row: HTMLElement; offsetInRow: number; pos: number; target: number; raf: number };
export default function DragHandleOnly({
duration = "240ms",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0a4fe8"
}: DragHandleOnlyProps) {
const listRef = useRef<HTMLUListElement | null>(null);
const stateRef = useRef<HTMLElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const list = listRef.current;
const stateEl = stateRef.current;
if (!list || !stateEl) return;
const LERP = 0.22;
const ROWS: HTMLElement[] = Array.prototype.slice.call(list.children);
let drag: DragState | null = null;
function rowsExcept(row: HTMLElement): HTMLElement[] {
return Array.prototype.slice.call(list!.children).filter((r: HTMLElement) => r !== row);
}
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 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 " + duration + " " + easing;
s.style.transform = "translateY(0)";
});
}
});
}
function beginDrag(row: HTMLElement, clientY: number): void {
const r = row.getBoundingClientRect();
row.classList.add(styles.rowDrag);
drag = { row, offsetInRow: clientY - r.top, pos: 0, target: 0, raf: 0 };
tick();
}
function moveDrag(clientY: number): void {
if (!drag) return;
const listRect = list!.getBoundingClientRect();
drag.target = clientY - drag.offsetInRow - listRect.top - drag.row.offsetTop;
reflow(drag.row, clientY);
}
function tick(): void {
if (!drag) return;
drag.pos += (drag.target - drag.pos) * LERP;
drag.row.style.setProperty("--ty", drag.pos.toFixed(2) + "px");
drag.raf = requestAnimationFrame(tick);
rafRef.current = drag.raf;
}
function endDrag(): void {
if (!drag) return;
const row = drag.row;
if (drag.raf) cancelAnimationFrame(drag.raf);
row.classList.remove(styles.rowDrag);
row.style.transition = "transform 220ms cubic-bezier(.2,.8,.2,1)";
row.style.setProperty("--ty", "0px");
setTimeout(() => {
row.style.transition = "";
}, 240);
drag = null;
}
function onGripDown(e: PointerEvent): void {
const grip = (e.target as HTMLElement).closest("." + styles.grip) as HTMLElement | null;
if (!grip) return;
stop();
const row = grip.closest("." + styles.row) as HTMLElement | null;
if (!row) return;
grip.setPointerCapture(e.pointerId);
beginDrag(row, e.clientY);
}
function onGripMove(e: PointerEvent): void {
moveDrag(e.clientY);
}
function onGripUp(): void {
endDrag();
}
list.addEventListener("pointerdown", onGripDown as EventListener);
list.addEventListener("pointermove", onGripMove as EventListener);
list.addEventListener("pointerup", onGripUp as EventListener);
list.addEventListener("pointercancel", onGripUp as EventListener);
function onClick(e: MouseEvent): void {
const btn = (e.target as HTMLElement).closest("." + styles.act) as HTMLButtonElement | null;
if (!btn) return;
stop();
btn.textContent = btn.textContent === "켬" ? "끔" : "켬";
}
list.addEventListener("click", onClick as EventListener);
let playing = true;
function stop(): void {
playing = false;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function scenario(): void {
const row0 = ROWS[0];
const r0 = row0.getBoundingClientRect();
beginDrag(row0, r0.top + r0.height / 2);
stateEl!.textContent = "캡처됨";
const r2 = ROWS[2].getBoundingClientRect();
moveDrag(r2.top + r2.height / 2 + 6);
setTimeout(() => {
if (!drag) return;
const r1 = ROWS[1].getBoundingClientRect();
moveDrag(r1.top + r1.height / 2 - 4);
}, 260);
setTimeout(() => {
if (!drag) return;
const r3 = ROWS[3] ? ROWS[3].getBoundingClientRect() : r0;
moveDrag(r3.top + r3.height / 2 + 4);
}, 560);
}
function release(): void {
endDrag();
stateEl!.textContent = "캡처됨";
}
const timeouts: number[] = [];
let intervalId = 0;
if (reduce) {
scenario();
release();
} else {
function loopOnce(): void {
scenario();
timeouts.push(window.setTimeout(release, 820));
}
loopOnce();
intervalId = window.setInterval(() => {
if (playing) loopOnce();
}, 1700);
}
return () => {
cancelAnimationFrame(rafRef.current);
timeouts.forEach((t) => clearTimeout(t));
if (intervalId) clearInterval(intervalId);
list.removeEventListener("pointerdown", onGripDown as EventListener);
list.removeEventListener("pointermove", onGripMove as EventListener);
list.removeEventListener("pointerup", onGripUp as EventListener);
list.removeEventListener("pointercancel", onGripUp as EventListener);
list.removeEventListener("click", onClick as EventListener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.ho}>
<p className={styles.hint}>{HINT}</p>
<ul className={styles.list} ref={listRef}>
{ROW_DEFS.map((row) => (
<li key={row.id} className={styles.row}>
<span className={styles.grip} tabIndex={-1} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 256 256">
<g fill="currentColor">
<path d={GRIP_PATH_BG} opacity=".2" />
<path d={GRIP_PATH_DOTS} />
</g>
</svg>
</span>
<span className={styles.txt}>{row.label}</span>
<button type="button" className={styles.act}>{row.act}</button>
</li>
))}
</ul>
<p className={styles.meta}>
<b className={styles.state} ref={stateRef} style={{ color }}>캡처됨</b>
<span className={styles.unit}>끔</span>
</p>
</div>
</main>
);
}04 ハンドル専用 — 小さな取っ手だけにポインターキャプチャをかけ行の残りを生かす分離 — 計測 累積面積 34.737% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · ドラッグアンドドロップ 並び替えリスト — 挿入先インデックス計算を重ねた9種
このデモが属する記事
ドラッグアンドドロップ 並び替えリスト — 挿入先インデックス計算を重ねた9種ドラッグアンドドロップ 並び替えは、指を離すたびに座標を計算し直す一枚の層です。ポインター位置から挿入先インデックスを計算し、リスト・カンバン・グリッドが並び替わります。ロングプレス・Alt矢印・端での自動スクロールまで — ドラッグ並び替 この記事のデモをすべて見る


