陌上人如玉
公子世无双

1.16 拖拽与手势

1.16 拖拽与手势

一句话:页面内的拖拽不要用 HTML5 Drag and Drop API(移动端基本不可用、无法做视觉跟随),而应该用 Pointer Events + setPointerCapture + touch-action 自己实现——核心是「一个 pointerId 贯穿全程、捕获指针、把移动换算成 transform」,再加「拖拽阈值、自动滚动、键盘替代」三件事。

一、三种方案:先排除掉错的那个

方案 机制 适合 致命限制
HTML5 Drag & Drop draggable + dragstart/dragover/drop + dataTransfer ⭐ 文件上传、跨应用/跨窗口拖拽 ⚠️ 移动端(iOS Safari)基本不支持;⚠️ 拖拽图像只是个「半透明快照」,无法做实时跟随与自适应布局;⚠️ 事件序列复杂(dragenter/dragleave 会因「穿过子元素」反复触发)
Pointer Events(推荐) pointerdown/move/up/cancel + setPointerCapture ⭐ 页面内拖拽、排序、调整尺寸、手势 要自己写「阈值、自动滚动、键盘替代」
库(dnd-kit / pragmatic-drag-and-drop / 已停止维护的 react-beautiful-dnd) 封装了上面的一切 生产项目中的「复杂排序」 体积 + 抽象成本
⭐⭐⭐ 「为什么页面内排序不能用 HTML5 DnD」——三条硬伤:

① 【移动端不可用】
   iOS Safari 对 `draggable` 的支持基本等于没有(需要 polyfill,而 polyfill 又不可靠)。
   ⭐ 而移动端恰恰是「排序/拖拽」需求最多的地方。

② 【视觉无法控制】
   HTML5 DnD 拖的是「浏览器生成的半透明快照」——
   你不能让「拖拽中的元素」跟随指针做位移/缩放,也不能「实时重排周围元素」。
   ⭐ 而现代拖拽体验的核心恰恰是「实时反馈」。

③ 【事件序列烦人】
   `dragenter`/`dragleave` 会因为「指针穿过子元素」而反复触发,
   要写一堆「判断是否真的离开」的逻辑。

   ✅ 结论:
     · 「上传文件 / 从桌面拖入」→ 用 HTML5 DnD(它才有 dataTransfer 与 OS 集成)
     · 「页面内排序 / 拖动面板 / 调整尺寸」→ 用 **Pointer Events**

二、Pointer Events:三个必须理解的东西

⭐⭐⭐ ① 【`setPointerCapture`——拖拽不丢的关键】

   el.setPointerCapture(e.pointerId)

   作用:把「这个 pointerId 的后续事件」**全部重定向到 el**——
        即使指针已经移出 el(甚至移出窗口),`pointermove`/`pointerup` 仍然发给 el。
   ⭐ 没有它,你会遇到「鼠标移出元素后拖拽就断了」。
   ✅ 它会在 `pointerup`/`pointercancel` 时**自动释放**(也可以手动 `releasePointerCapture`)。
   ⚠️ 一个元素同一时间只能捕获「一个 pointerId」(多点触控时要注意)。

⭐⭐⭐ ② 【`touch-action`——让「拖拽」与「滚动」不打架】

   .draggable { touch-action: none; }

   作用:告诉浏览器「**这个元素上的默认手势(滚动/缩放/双击放大)由我接管**」。
   ⚠️⚠️ 必须在 **CSS 里静态声明**(不能在 `pointerdown` 里再设)——
        因为「用户手指一动,滚动就已经开始了」,此时再设已经晚了。
   ⚠️ `touch-action: none` 会让「在这个元素上的滚动完全失效」→ 要谨慎:
      · 整块可拖区域设 `none` → 用户在它上面就不能滚页面(体验差)
      · ✅ 更稳的做法:**只给「拖拽手柄」设 `none`**(如列表项右侧的拖拽图标)
      · 或按方向限制:`touch-action: pan-y`(只允许垂直滚动,水平拖拽交给自己)

⭐⭐⭐ ③ 【`pointercancel`——必须处理】

   系统会在「它要接管这次交互」时发 `pointercancel`(如:滚动开始、
   来电、系统手势、浏览器切换标签)。
   ⭐ 收到它必须「当作 pointerup 处理」并清理状态;
     ⚠️ 不处理的话,你的拖拽状态会「卡死」(元素一直跟着鼠标/一直高亮)。
// ✅ Pointer Events 的完整生命周期(含「阈值」与「取消」)
interface DragCallbacks {
  onStart?: (info: PointerInfo) => void;
  onMove?: (info: PointerInfo) => void;
  onEnd?: (info: PointerInfo, cancelled: boolean) => void;
}

interface PointerInfo {
  pointerId: number;
  /** 相对「按下点」的位移 */
  dx: number;
  dy: number;
  /** 当前指针位置(视口坐标) */
  clientX: number;
  clientY: number;
  pointerType: string;
  /** 原始事件(可能需要 preventDefault) */
  event: PointerEvent;
}

function startDrag(el: HTMLElement, e: PointerEvent, callbacks: DragCallbacks, threshold = 5) {
  const startX = e.clientX;
  const startY = e.clientY;
  const pointerId = e.pointerId;

  let started = false;                 // ⭐ 是否已越过阈值(越过才算「真的在拖」)
  let cancelled = false;

  // ⭐ 捕获指针(之后事件都发给 el)
  el.setPointerCapture(pointerId);

  const info = (ev: PointerEvent): PointerInfo => ({
    pointerId,
    dx: ev.clientX - startX,
    dy: ev.clientY - startY,
    clientX: ev.clientX,
    clientY: ev.clientY,
    pointerType: ev.pointerType,
    event: ev,
  });

  const onMove = (ev: PointerEvent) => {
    if (ev.pointerId !== pointerId) return;              // ⭐ 多点触控:只认自己的那个
    const d = info(ev);

    if (!started) {
      // ⭐ 阈值:避免「想点击却触发了拖拽」
      if (Math.hypot(d.dx, d.dy) < threshold) return;
      started = true;
      callbacks.onStart?.(d);
    }
    callbacks.onMove?.(d);
  };

  const cleanup = (ev: PointerEvent, isCancelled: boolean) => {
    el.removeEventListener('pointermove', onMove);
    el.removeEventListener('pointerup', onUp);
    el.removeEventListener('pointercancel', onCancel);
    if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId);
    if (started) callbacks.onEnd?.(info(ev), isCancelled);
  };

  const onUp = (ev: PointerEvent) => { if (ev.pointerId === pointerId) cleanup(ev, false); };
  const onCancel = (ev: PointerEvent) => { cancelled = true; cleanup(ev, true); };   // ⭐ 必须处理

  el.addEventListener('pointermove', onMove);
  el.addEventListener('pointerup', onUp);
  el.addEventListener('pointercancel', onCancel);

  return () => { if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId); };
}

三、完整实现(1):useDrag(单元素拖拽,200 行)

// use-drag.ts —— 可拖拽(含边界限制、键盘、无障碍)
import { useCallback, useRef, useState, useEffect } from 'react';

export interface Position { x: number; y: number }

export interface UseDragOptions {
  /** 初始位置 */
  initial?: Position;
  /** 拖拽前必须移动的像素(避免与「点击」冲突) */
  threshold?: number;
  /** 限制在容器内 */
  bounds?: { left: number; top: number; right: number; bottom: number } | (() => DOMRect | null);
  /** 是否用「受控」位置(不传则内部维护) */
  position?: Position;
  onPositionChange?: (pos: Position, info: { phase: 'start' | 'move' | 'end'; cancelled: boolean }) => void;
  disabled?: boolean;
}

/** ⭐ 一个「可拖拽 + 可键盘操作」的元素 */
export function useDrag(options: UseDragOptions = {}) {
  const {
    initial = { x: 0, y: 0 },
    threshold = 5,
    bounds,
    position: controlledPosition,
    onPositionChange,
    disabled = false,
  } = options;

  const [innerPosition, setInnerPosition] = useState(initial);
  const position = controlledPosition ?? innerPosition;

  const ref = useRef(null);
  const stateRef = useRef<{
    pointerId: number;
    startX: number;
    startY: number;
    originX: number;
    originY: number;
    started: boolean;
    lastValid: Position;
  } | null>(null);

  const [dragging, setDragging] = useState(false);

  const resolveBounds = useCallback((): { left: number; top: number; right: number; bottom: number } | null => {
    if (!bounds) return null;
    if (typeof bounds === 'function') {
      const rect = bounds();
      if (!rect) return null;
      return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
    }
    return bounds;
  }, [bounds]);

  /** 把「目标位置」钳制到边界内 */
  const clamp = useCallback((pos: Position): Position => {
    const b = resolveBounds();
    const el = ref.current;
    if (!b || !el) return pos;

    const elRect = el.getBoundingClientRect();
    const width = elRect.width;
    const height = elRect.height;

    // ⭐ 用「元素的左上角」做约束(简化模型:假设元素位置就是 transform 位移)
    return {
      x: Math.min(Math.max(pos.x, b.left), b.right - width),
      y: Math.min(Math.max(pos.y, b.top), b.bottom - height),
    };
  }, [resolveBounds]);

  const commit = useCallback(
    (pos: Position, phase: 'start' | 'move' | 'end', cancelled: boolean) => {
      const clamped = clamp(pos);
      if (controlledPosition) onPositionChange?.(clamped, { phase, cancelled });
      else {
        setInnerPosition(clamped);
        onPositionChange?.(clamped, { phase, cancelled });
      }
    },
    [clamp, controlledPosition, onPositionChange]
  );

  const onPointerDown = useCallback(
    (e: React.PointerEvent) => {
      if (disabled) return;
      if (e.button !== 0 && e.pointerType === 'mouse') return;         // ⭐ 只处理左键(鼠标)
      const el = ref.current;
      if (!el) return;

      // ⭐ 捕获指针(事件重定向到 el,移出元素也不丢)
      el.setPointerCapture(e.pointerId);

      stateRef.current = {
        pointerId: e.pointerId,
        startX: e.clientX,
        startY: e.clientY,
        originX: position.x,
        originY: position.y,
        started: false,
        lastValid: position,
      };
    },
    [disabled, position.x, position.y]
  );

  const onPointerMove = useCallback(
    (e: React.PointerEvent) => {
      const s = stateRef.current;
      if (!s || e.pointerId !== s.pointerId) return;

      const dx = e.clientX - s.startX;
      const dy = e.clientY - s.startY;

      if (!s.started) {
        if (Math.hypot(dx, dy) < threshold) return;                    // ⭐ 阈值
        s.started = true;
        setDragging(true);
        commit({ x: s.originX + dx, y: s.originY + dy }, 'start', false);
        return;
      }

      const clamped = clamp({ x: s.originX + dx, y: s.originY + dy });
      s.lastValid = clamped;
      commit(clamped, 'move', false);
    },
    [threshold, commit, clamp]
  );

  const finish = useCallback(
    (cancelled: boolean) => {
      const s = stateRef.current;
      if (!s) return;
      const el = ref.current;
      if (el && el.hasPointerCapture(s.pointerId)) el.releasePointerCapture(s.pointerId);

      if (s.started) {
        commit(cancelled ? s.lastValid : s.lastValid, 'end', cancelled);
        setDragging(false);
      }
      stateRef.current = null;
    },
    [commit]
  );

  const onPointerUp = useCallback((e: React.PointerEvent) => {
    const s = stateRef.current;
    if (s && e.pointerId === s.pointerId) finish(false);
  }, [finish]);

  const onPointerCancel = useCallback((e: React.PointerEvent) => {
    const s = stateRef.current;
    if (s && e.pointerId === s.pointerId) finish(true);                 // ⭐ 系统接管
  }, [finish]);
  /** ⭐ 键盘操作(无障碍:WCAG 要求「拖拽必须有键盘替代」) */
  const onKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (disabled) return;
      const step = e.shiftKey ? 20 : 2;                                // ⭐ Shift 加速
      let next: Position | null = null;

      switch (e.key) {
        case 'ArrowLeft':  next = { x: position.x - step, y: position.y }; break;
        case 'ArrowRight': next = { x: position.x + step, y: position.y }; break;
        case 'ArrowUp':    next = { x: position.x, y: position.y - step }; break;
        case 'ArrowDown':  next = { x: position.x, y: position.y + step }; break;
        case 'Home':       next = { x: (resolveBounds()?.left ?? 0), y: position.y }; break;
        default: return;
      }

      e.preventDefault();                                              // ⭐ 阻止页面滚动
      commit(next, 'move', false);
      commit(next, 'end', false);
    },
    [disabled, position, commit, resolveBounds]
  );

  /** ⭐ 返回「展开到元素上的 props」 */
  const dragHandleProps = {
    ref,
    onPointerDown,
    onPointerMove,
    onPointerUp,
    onPointerCancel,
    onKeyDown,
    tabIndex: disabled ? -1 : 0,                                       // ⭐ 可聚焦(键盘可用)
    role: 'button' as const,
    'aria-roledescription': '可拖拽元素',
    'aria-label': '可用方向键移动位置',
    style: {
      // ⭐⭐ 关键:让浏览器把「这个元素上的手势」交给我们(必须在 CSS 里静态声明)
      touchAction: 'none',
      cursor: dragging ? 'grabbing' : 'grab',
      // ⭐ 用 transform 做位移(只走合成,见 5.17)
      transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
      userSelect: 'none' as const,
      willChange: dragging ? 'transform' : undefined,
    } as React.CSSProperties,
  };

  return { dragHandleProps, position, dragging, setPosition: setInnerPosition };
}
// ✅ 用法:一个「可拖拽 + 边界限制 + 键盘可操作」的卡片
export function DraggableCard() {
  const containerRef = useRef(null);

  const { dragHandleProps, position, dragging } = useDrag({
    initial: { x: 24, y: 24 },
    // ⭐ 边界用函数(每次拖拽时实时读取容器尺寸)
    bounds: () => containerRef.current?.getBoundingClientRect() ?? null,
  });

  return (
    
拖我(或用方向键)
x {position.x.toFixed(0)} / y {position.y.toFixed(0)}
); }

四、完整实现(2):useSortable(列表排序,260 行)

// use-sortable.ts —— 垂直列表排序(含自动滚动 + 键盘替代)
import { useCallback, useRef, useState } from 'react';

interface SortableOptions {
  /** 当前顺序的 id 列表(受控) */
  ids: string[];
  onReorder: (nextIds: string[]) => void;
  /** 拖拽手柄的选择器(没有手柄则整项可拖) */
  handleSelector?: string;
  /** 触发自动滚动的边缘距离 */
  autoScrollEdge?: number;
  /** 自动滚动最大速度(px/帧) */
  autoScrollMaxSpeed?: number;
  disabled?: boolean;
}

export function useSortable(options: SortableOptions) {
  const {
    ids, onReorder,
    handleSelector,
    autoScrollEdge = 48,
    autoScrollMaxSpeed = 12,
    disabled = false,
  } = options;

  const containerRef = useRef(null);
  const [activeId, setActiveId] = useState(null);
  const [overIndex, setOverIndex] = useState(-1);

  /** 拖拽内部状态 */
  const stateRef = useRef<{
    pointerId: number;
    activeId: string;
    startY: number;
    itemRects: Array<{ id: string; top: number; height: number }>;
    currentIndex: number;
    started: boolean;
    scrollRaf: number;
    lastClientY: number;
  } | null>(null);

  /** ⭐ 记录「拖拽开始时」所有项的位置(后续用「指针 Y 落在哪一项的区间」判断目标位置) */
  const captureItemRects = useCallback(() => {
    const container = containerRef.current;
    if (!container) return [];

    // ⭐ 批量读(避免布局抖动,见 1.14)
    const items = Array.from(container.querySelectorAll('[data-sortable-item]'));
    const rects = items.map((el) => {
      const r = el.getBoundingClientRect();
      return { id: el.dataset.sortableItem!, top: r.top, height: r.height };
    });
    return rects;
  }, []);

  /** ⭐ 自动滚动:指针靠近容器边缘时滚动容器 */
  const ensureAutoScroll = useCallback((clientY: number) => {
    const state = stateRef.current;
    const container = containerRef.current;
    if (!state || !container) return;

    state.lastClientY = clientY;

    if (state.scrollRaf) return;                                     // 已在滚动中

    const tick = () => {
      const s = stateRef.current;
      const c = containerRef.current;
      if (!s || !c) { return; }

      const rect = c.getBoundingClientRect();
      const distTop = s.lastClientY - rect.top;
      const distBottom = rect.bottom - s.lastClientY;

      let delta = 0;
      if (distTop < autoScrollEdge) {
        delta = -Math.round(((autoScrollEdge - distTop) / autoScrollEdge) * autoScrollMaxSpeed);
      } else if (distBottom < autoScrollEdge) {
        delta = Math.round(((autoScrollEdge - distBottom) / autoScrollEdge) * autoScrollMaxSpeed);
      }

      if (delta !== 0) {
        c.scrollTop += delta;
        updateOverIndex(s.lastClientY);                              // ⭐ 滚动后要重新算目标位置
        s.scrollRaf = requestAnimationFrame(tick);
      } else {
        s.scrollRaf = 0;
      }
    };

    state.scrollRaf = requestAnimationFrame(tick);
  }, [autoScrollEdge, autoScrollMaxSpeed]);
  /** ⭐ 根据「指针的 Y 坐标」算出「应该插入到第几项」 */
  const updateOverIndex = useCallback((clientY: number) => {
    const s = stateRef.current;
    const container = containerRef.current;
    if (!s || !container) return;

    // ⭐ 关键:拖拽会改变 DOM,所以「每次都用『当前 DOM 的 rect』重算」
    const items = Array.from(container.querySelectorAll('[data-sortable-item]'));
    const rects = items.map((el) => el.getBoundingClientRect());

    let index = rects.length;
    for (let i = 0; i < rects.length; i++) {
      const r = rects[i]!;
      // ⭐ 用「项的中心线」作为判据(比「边界」更符合直觉)
      if (clientY < r.top + r.height / 2) { index = i; break; }
    }

    if (index !== s.currentIndex) {
      s.currentIndex = index;
      setOverIndex(index);
      // ⭐ 实时重排(让用户看到「拖到哪儿」)
      const without = ids.filter((id) => id !== s.activeId);
      const next = [...without.slice(0, index), s.activeId, ...without.slice(index)];
      if (next.join(',') !== ids.join(',')) onReorder(next);
    }
  }, [ids, onReorder]);

  const onPointerDown = useCallback(
    (e: React.PointerEvent, id: string) => {
      if (disabled) return;
      if (e.button !== 0 && e.pointerType === 'mouse') return;

      const itemEl = (e.target as HTMLElement).closest('[data-sortable-item]');
      if (!itemEl) return;

      // ⭐ 如果配了「手柄」,只有从手柄按下才算拖拽
      if (handleSelector && !(e.target as HTMLElement).closest(handleSelector)) return;

      itemEl.setPointerCapture(e.pointerId);

      stateRef.current = {
        pointerId: e.pointerId,
        activeId: id,
        startY: e.clientY,
        itemRects: captureItemRects(),
        currentIndex: ids.indexOf(id),
        started: false,
        scrollRaf: 0,
        lastClientY: e.clientY,
      };
      setActiveId(id);
    },
    [disabled, handleSelector, captureItemRects, ids]
  );

  const onPointerMove = useCallback(
    (e: React.PointerEvent) => {
      const s = stateRef.current;
      if (!s || e.pointerId !== s.pointerId) return;

      if (!s.started) {
        if (Math.abs(e.clientY - s.startY) < 4) return;               // ⭐ 阈值(纵向)
        s.started = true;
      }

      updateOverIndex(e.clientY);
      ensureAutoScroll(e.clientY);
    },
    [updateOverIndex, ensureAutoScroll]
  );

  const finish = useCallback((_cancelled: boolean) => {
    const s = stateRef.current;
    if (!s) return;
    const container = containerRef.current;
    if (container) {
      const el = container.querySelector(`[data-sortable-item="${CSS.escape(s.activeId)}"]`);
      if (el?.hasPointerCapture(s.pointerId)) el.releasePointerCapture(s.pointerId);
    }
    if (s.scrollRaf) cancelAnimationFrame(s.scrollRaf);

    stateRef.current = null;
    setActiveId(null);
    setOverIndex(-1);
  }, []);

  const onPointerUp = useCallback((e: React.PointerEvent) => {
    const s = stateRef.current;
    if (s && e.pointerId === s.pointerId) finish(false);
  }, [finish]);

  const onPointerCancel = useCallback((e: React.PointerEvent) => {
    const s = stateRef.current;
    if (s && e.pointerId === s.pointerId) finish(true);               // ⭐ 系统接管(如滚动开始)
  }, [finish]);
  /** ⭐ 键盘替代:Space 拾起 → 方向键移动 → Space 放下(WCAG 要求) */
  const [keyboardGrabbed, setKeyboardGrabbed] = useState(null);

  const onKeyDown = useCallback(
    (e: React.KeyboardEvent, id: string) => {
      if (disabled) return;
      const index = ids.indexOf(id);

      if (e.key === ' ' || e.key === 'Enter') {
        e.preventDefault();
        setKeyboardGrabbed((cur) => (cur === id ? null : id));
        return;
      }

      if (keyboardGrabbed !== id) return;

      let nextIndex = -1;
      if (e.key === 'ArrowUp') nextIndex = Math.max(0, index - 1);
      else if (e.key === 'ArrowDown') nextIndex = Math.min(ids.length - 1, index + 1);
      else return;

      e.preventDefault();
      if (nextIndex === index) return;
      const next = ids.slice();
      next.splice(index, 1);
      next.splice(nextIndex, 0, id);
      onReorder(next);
      // ⭐ 移动后保持焦点(并把焦点移到新位置的元素上)
      requestAnimationFrame(() => {
        const el = containerRef.current?.querySelector(
          `[data-sortable-item="${CSS.escape(id)}"]`
        );
        el?.focus();
      });
    },
    [disabled, ids, keyboardGrabbed, onReorder]
  );

  /** 给「每一项」展开的 props */
  const getItemProps = useCallback(
    (id: string) => {
      const index = ids.indexOf(id);
      const isActive = activeId === id;
      return {
        'data-sortable-item': id,
        tabIndex: disabled ? -1 : 0,
        role: 'option' as const,
        'aria-selected': isActive,
        'aria-grabbed': keyboardGrabbed === id,                        // ⭐ 旧 API,但被广泛支持
        'aria-posinset': index + 1,
        'aria-setsize': ids.length,
        onPointerDown: (e: React.PointerEvent) => onPointerDown(e, id),
        onPointerMove,
        onPointerUp,
        onPointerCancel,
        onKeyDown: (e: React.KeyboardEvent) => onKeyDown(e, id),
        style: {
          // ⚠️ 整项设 none 会让「在这个列表项上的滚动失效」→ 有手柄时只给手柄设
          touchAction: handleSelector ? 'auto' : ('none' as const),
          userSelect: 'none' as const,
          cursor: isActive ? 'grabbing' : 'grab',
          // ⭐ 拖拽中的项「抬起」(视觉反馈,只改 transform/box-shadow)
          transform: isActive ? 'scale(1.02)' : undefined,
          boxShadow: isActive ? '0 8px 24px rgba(0,0,0,.18)' : undefined,
          zIndex: isActive ? 10 : undefined,
          position: isActive ? ('relative' as const) : undefined,
        } as React.CSSProperties,
      };
    },
    [ids, activeId, keyboardGrabbed, disabled, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onKeyDown, handleSelector]
  );

  /** 给「手柄」展开的 props(有手柄时,只有手柄触发拖拽) */
  const getHandleProps = useCallback(
    (id: string) => ({
      'data-sortable-handle': true,
      'aria-hidden': true,
      style: {
        // ⭐⭐ 手柄上必须设 touch-action: none(否则手指一动就变成滚动)
        touchAction: 'none' as const,
        cursor: 'grab',
        userSelect: 'none' as const,
      } as React.CSSProperties,
      onPointerDown: (e: React.PointerEvent) => onPointerDown(e, id),
      onPointerMove,
      onPointerUp,
      onPointerCancel,
    }),
    [onPointerDown, onPointerMove, onPointerUp, onPointerCancel]
  );

  return {
    containerRef,
    activeId,
    overIndex,
    keyboardGrabbed,
    getItemProps,
    getHandleProps,
    /** ⭐ 容器 props */
    containerProps: {
      ref: containerRef,
      role: 'listbox' as const,
      'aria-label': '可排序列表(拖拽或用空格 + 方向键调整顺序)',
      style: { touchAction: handleSelector ? 'auto' : ('none' as const) } as React.CSSProperties,
    },
  };
}
// ✅ 用法:可排序列表(配合 FLIP 动画,见 1.15)
export function SortableDemo() {
  const [ids, setIds] = useState(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']);
  const labels: Record = {
    a: '苹果', b: '香蕉', c: '橙子', d: '西瓜', e: '葡萄', f: '草莓', g: '芒果', h: '荔枝',
  };

  const sortable = useSortable({
    ids,
    onReorder: setIds,
    handleSelector: '[data-sortable-handle]',      // ⭐ 用「手柄」拖拽,列表本身仍可滚动
  });

  const flipRef = useFlip(ids.join(','));   // FLIP 让「重排」有过渡

  return (
    
{ sortable.containerRef.current = node; // ⭐ 同时满足 useSortable 与 useFlip flipRef.current = node; }} > {ids.map((id) => (
{/* ⭐ 拖拽手柄(只有它设 touch-action: none) */} ⠿ {labels[id]} {/* ⭐ 大字号的按钮替代方案(键盘/无障碍最省心的做法) */}
))}
); }
⭐⭐⭐ 这个排序实现里有五个「必须做对」的点:

① 【拖拽开始时「记录一次」所有项的位置,之后实时重算】
   - 拖拽中「DOM 会被重排」,所以每次算「目标位置」都必须
     用「当前 DOM 的 rect」(而不是「开始时缓存的那份」)
   - 用「项的中心线」判断插入位置(比「边界」更符合直觉)

② 【实时重排(边拖边看)】
   ⭐ 用户拖到哪儿,列表就立刻重排到哪儿 → 这才是现代拖拽体验。
   (配合 FLIP 动画,重排本身也是平滑的,见 1.15)

③ 【自动滚动】
   指针靠近容器上下边缘时按距离线性调速(`scrollTop += delta`),
   ⭐ 且「滚动后要重新计算目标位置」(否则滚动时目标不动 → 抖)。

④ 【键盘替代(WCAG 要求)】
   「空格拾起 → 方向键移动 → 空格放下」,⭐ 并提供「上移/下移按钮」
   (按钮是最省心、最容易被屏幕阅读器理解的方案)。

⑤ 【`touch-action` 的取舍】
   整项设 `none` → 用户在列表项上不能滚动页面(体验差);
   ⭐ 所以「**用拖拽手柄**,只给手柄设 `none`」是最佳实践。

五、它的坑

// ① 用 HTML5 DnD 做「页面内排序」(移动端直接不可用 + 无法视觉跟随)
// ❌ 用于排序 // ✅ Pointer Events // ② 忘了 setPointerCapture(鼠标移出元素后拖拽就断) el.addEventListener('pointermove', ...); // ⚠️ 只在元素内有效 // ✅ el.setPointerCapture(e.pointerId) // ③ 忘了在 CSS 里静态声明 touch-action(第一次触摸变成滚动) el.style.touchAction = 'none'; // 在 pointerdown 里设置 // ❌ 太晚 // ✅ CSS: .draggable { touch-action: none; } // ④ 整块拖拽区域设 touch-action: none(用户不能滚动页面) // ✅ 只给「拖拽手柄」设 none // ⑤ 不处理 pointercancel(状态卡死:元素一直跟着鼠标 / 一直高亮) // ✅ pointercancel 当作 pointerup 处理并清理 // ⑥ 没有拖拽阈值(想点击却触发了拖拽) pointerdown → 立刻进入拖拽态 // ⚠️ // ✅ 移动超过 5~10px 才认为「真的在拖」 // ⑦ 多点触控时「多个 pointerId 混用」 // ⚠️ 第二根手指按下会覆盖状态 // ✅ 每个事件都判断 `e.pointerId === myPointerId` // ⑧ 用 `mousedown/mousemove`(移动端无效、且不支持笔/触控) // ✅ Pointer Events(统一鼠标/触摸/笔) // ⑨ 用 `left/top` 做拖拽位移(每帧重排) el.style.left = `${x}px`; // ❌ // ✅ `transform: translate3d(...)` // ⑩ 拖拽中「每次 pointermove 都读 rect」(布局抖动 + 掉帧) onPointerMove = (e) => { const r = el.getBoundingClientRect(); ... } // ⚠️ 高频 // ✅ 拖拽开始时「缓存读数」,移动中只做数学计算 // ⑪ 自动滚动的 rAF 没有取消(滚动停不下来) // ✅ finish 时 `cancelAnimationFrame` // ⑫ 滚动了但「目标位置没重算」(拖拽时元素抖动 / 位置不动) // ✅ 滚动后重新计算目标 index // ⑬ 拖拽中「改变了元素尺寸」(如加了 padding/border)导致测量错位 // ⚠️ transform 不影响布局,所以「用 transform 做位移」是最稳的 // ✅ 视觉反馈(缩放/阴影)也用「不影响布局」的属性(transform/box-shadow/opacity) // ⑭ 忘了「阻止文本选中」(拖拽时选中一堆文字) // ✅ `user-select: none` // ⑮ 拖拽结束时「没有 settle 到最终位置」(元素停在半个位置上) // ✅ `end` 阶段把位置对齐到「目标项的位置」(列表排序)或「合法边界」(自由拖拽) // ⑯ 没有「拒绝无效放置」的反馈(拖到不允许的区域也默默放下) // ✅ 高亮「可放置区域」/ 用 `not-allowed` 光标 / 回弹动画 // ⑰ 拖拽与「滚动容器」冲突(在可滚动的列表里拖拽很别扭) // ⚠️ 长按 500ms 才进入拖拽(移动端惯例)可以缓解 // ✅ 或用「拖拽手柄」明确区分「拖这段是拖拽,其余地方是滚动」 // ⑱ 没有「长按」判定(移动端:用户想滚动列表,结果开始拖拽) // ✅ 移动端可加「长按 300~500ms 后进入拖拽」 // 或用「手柄模式」(最稳,推荐) // ⑲ 无障碍缺失(只用鼠标拖拽 → 键盘/屏幕阅读器用户无法排序) // ⭐ WCAG 要求「拖拽操作必须有替代方式」 // ✅ 「空格拾起 + 方向键移动 + 空格放下」+ 「上移/下移按钮」 // + `aria-roledescription="可拖拽元素"` + 操作后播报(`aria-live`) // ⑳ 拖拽中的「焦点/滚动位置」丢失 // ⚠️ DOM 重排后焦点可能跑到 body // ✅ 重排后 `el.focus()`(本实现里做了) // ㉑ iframe / 跨域 场景下「指针事件被 iframe 截获」 // ⚠️ 指针移入 iframe 后,父页面收不到 pointermove // ✅ 在拖拽期间给 iframe 加 `pointer-events: none` 覆盖层 // ㉒ 拖拽「跨出窗口」后事件丢失(指针出了浏览器) // ⭐ `setPointerCapture` 只能捕获「同一个文档内」的移动; // 跨出窗口时依赖 `pointerup` 前的最后一次位置 // ✅ 监听 `window.blur` 或加「离开窗口即结束拖拽」的兜底 // ㉓ 用 `transform: scale()` 做「抬起」反馈但影响了测量 // ⚠️ `getBoundingClientRect` 会包含 transform 的缩放 → 后续测量被污染 // ✅ 「抬起」用 box-shadow + z-index(不改变几何),或把 scale 放在内层元素 // ㉔ 拖拽时「页面其他地方也在响应」(比如 hover 效果乱闪) // ✅ 拖拽进行中给 body 加一个类(`body.is-dragging * { pointer-events: none }` // 或 `body.is-dragging { cursor: grabbing }`)
// ⑳ 的完整处理(重排后恢复焦点 + 播报)
function announce(message: string) {
  let el = document.getElementById('a11y-live');
  if (!el) {
    el = document.createElement('div');
    el.id = 'a11y-live';
    el.setAttribute('aria-live', 'polite');
    el.setAttribute('aria-atomic', 'true');
    Object.assign(el.style, {
      position: 'absolute', width: 1, height: 1, overflow: 'hidden',
      clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap',
    });
    document.body.appendChild(el);
  }
  el.textContent = message;
}

// 移动后:
onReorder(next);
announce(`已移动到第 ${nextIndex + 1} 位,共 ${next.length} 项`);
// ㉑㉒ 的完整处理(拖拽期间「屏蔽 iframe」+「跨窗口兜底」)
useEffect(() => {
  if (!activeId) return;

  // ⭐ 拖拽期间把页面里的 iframe 设为「不接收指针事件」
  const iframes = Array.from(document.querySelectorAll('iframe'));
  iframes.forEach((f) => f.style.setProperty('pointer-events', 'none', 'important'));

  document.body.classList.add('is-dragging');

  // ⭐ 窗口失焦 → 结束拖拽(避免「状态卡死」)
  const onBlur = () => finish(true);
  window.addEventListener('blur', onBlur);

  return () => {
    iframes.forEach((f) => f.style.removeProperty('pointer-events'));
    document.body.classList.remove('is-dragging');
    window.removeEventListener('blur', onBlur);
  };
}, [activeId, finish]);

/* CSS */
// body.is-dragging { cursor: grabbing; user-select: none; }
// body.is-dragging * { cursor: grabbing !important; }

六、面试延伸

  1. 「页面内的拖拽为什么不用 HTML5 Drag and Drop API?」

三条硬伤:① ⭐ 移动端(iOS Safari)基本不支持——而「排序/拖拽」的需求恰恰在移动端最多(需要 polyfill,而 polyfill 不可靠);② ⭐ 视觉无法控制——HTML5 DnD 拖的是「浏览器生成的半透明快照」,你不能让拖拽中的元素跟随指针做位移/缩放,也不能实时重排周围元素,而现代拖拽体验的核心正是「实时反馈」;③ 事件序列烦人——dragenter/dragleave 会因「指针穿过子元素」反复触发,要写一堆「是否真的离开」的判断。结论:「文件上传 / 从桌面拖入 / 跨应用拖放」用 HTML5 DnD(它才有 dataTransfer 与 OS 集成),「页面内排序 / 拖动面板 / 调整尺寸」用 Pointer Events。

  1. 「setPointerCapture 有什么用?」

它把「某个 pointerId 的后续事件全部重定向到该元素」——即使指针已经移出这个元素(甚至移出窗口),pointermove/pointerup 仍然发给它。⭐ 没有它,就会遇到「鼠标移出元素后拖拽直接断掉」这个经典问题(因为事件不再发给你了)。用法:在 pointerdown 里 el.setPointerCapture(e.pointerId);它在 pointerup/pointercancel 时自动释放(也可手动 releasePointerCapture)。⚠️ 一个元素同一时间只能捕获一个 pointerId(多点触控要自己管理)。

  1. 「touch-action 为什么必须在 CSS 里静态声明?」

因为 touch-action 决定的是「浏览器是否处理该元素上的默认手势(滚动/缩放)」——用户在触摸屏上「手指一动」,浏览器就立刻决定「这是滚动还是交给页面」。如果你在 pointerdown 里再设置,已经晚了(滚动已经开始,pointercancel 已经发出)。⭐ 所以必须静态写在 CSS 里。另一个关键取舍:touch-action: none 会让「在这个元素上的滚动完全失效」→ 如果整块可拖区域都设 none,用户在它上面就不能滚页面(体验差)。✅ 最佳实践是「用拖拽手柄,只给手柄设 none」,或按方向限制(touch-action: pan-y 只允许垂直滚动)。

  1. 「pointercancel 是什么?为什么必须处理?」

它表示「系统要接管这次交互」——典型场景:浏览器判定为滚动、来电、系统手势(如从边缘滑动)、切到别的标签页、指针设备断开。⭐ 必须当作 pointerup 处理(清理拖拽状态、释放捕获、复位样式);不处理的后果是「状态卡死」——元素一直跟着鼠标、一直高亮、一直禁止滚动。实践中:把 pointercancel 与 pointerup 指向同一个 finish(cancelled) 函数,并在里面区分「是否已真正开始拖拽」。

  1. 「拖拽为什么要设阈值?」

为了区分「点击」与「拖拽」:如果不设阈值,用户「想点一下」但手指/鼠标抖了 1px,就会进入拖拽态——于是「点击事件被吞掉」「元素开始跟着动」。常见做法是「移动超过 5~10px 才认为真的在拖」(触摸端因为手指抖动更大,可以用更大阈值,或改用长按 300~500ms 进入拖拽)。⭐ 另一个相关细节:移动端用「长按进入拖拽」是行业惯例(因为「短按拖动」与「滚动」天然冲突),但如果用了「拖拽手柄」,就不需要长按了(手柄已经明确表达了意图)。

  1. 「拖拽排序怎么实现『实时重排』?」

核心是「边拖边算目标位置并立即更新顺序」:① 拖拽开始时记录「所有项的位置」(⭐ 只记录一次);② 每次 pointermove 时,用 「指针的 Y 坐标」与「当前 DOM 中各项的中心线」比较,算出「应该插入到第几项」——⭐ 必须用「当前 DOM 的 rect」(因为拖拽中 DOM 已经被重排过,用开始时缓存的位置会算错);③ 算出新顺序后立即 onReorder(nextIds),让列表实时重排(配合 FLIP 让重排有过渡,见 1.15);④ 自动滚动——指针靠近容器上下边缘时按距离线性调 scrollTop,⚠️ 并且滚动后要重新计算目标位置(否则滚动时目标不动,会抖)。

  1. 「拖拽的无障碍怎么做?」

⭐ WCAG 明确要求「拖拽操作必须有非拖拽的替代方式」。三层做法:① 键盘拖拽——「空格/Enter 拾起 → 方向键移动 → 空格/Enter 放下」(这是 dnd-kit 等库的标准模式);② ⭐ 更省心的是「上移/下移按钮」——它最容易被屏幕阅读器理解,且不依赖「模式」(对认知障碍用户也友好);③ 语义与播报——role="listbox" + role="option" + aria-posinset/aria-setsize,并用 aria-live="polite" 的隐藏区域播报「已移动到第 N 位,共 M 项」;操作后恢复焦点(DOM 重排后焦点容易掉到 body)。

  1. 「拖拽有什么性能陷阱?」

四个:① ⭐ 在 pointermove 里读布局属性(getBoundingClientRect)——pointermove 频率可达每秒上百次,每次都读会触发强制同步布局(见 1.14)→ ✅ 拖拽开始时缓存读数,移动中只做数学计算;② 用 left/top 做位移——每帧重排 → ✅ 用 transform: translate3d()(只走合成,见 5.17);③ 自动滚动用 setInterval → ✅ 用 requestAnimationFrame(跟帧、且能被取消);④ 「抬起」反馈用 scale 会污染后续的 getBoundingClientRect(它包含 transform)→ ✅ 用 box-shadow + z-index 做「抬起」,或把 scale 放在内层元素上。

一句话速记

页面内拖拽不用 HTML5 DnD(移动端不可用 + 无法视觉跟随 + dragenter/leave 反复触发),用 Pointer Events;三个必须理解的:⭐ setPointerCapture(pointerId)(事件重定向到元素,指针移出也不丢)、⭐ touch-action: none 必须写在 CSS 里(动态设太晚;⚠️ 整块设会禁用滚动 → 只给拖拽手柄设)、⭐ pointercancel 必须当作 pointerup 处理(系统接管,不处理会状态卡死);实现要点是「一个 pointerId 贯穿全程(多点触控要判断) + 拖拽阈值 5~10px(区分点击) + 缓存读数(pointermove 里不读布局) + transform 位移」;排序要「用当前 DOM 的 rect 与项的中心线实时算目标位置并立即 onReorder(配合 FLIP 做过渡)+ 自动滚动(rAF + 滚动后重算目标)」;无障碍必须做(WCAG 要求):「空格拾起 + 方向键移动 + 空格放下」或者上移/下移按钮(最省心)+ aria-live 播报 + 操作后恢复焦点;跨 iframe/跨窗口要兜底(拖拽期间给 iframe pointer-events: none、window.blur 结束拖拽)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 1.16 拖拽与手势

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫