dnd-kit 碰撞检测算法:你的订单为什么自己"跑"到了 1 号?

dnd-kit 碰撞检测算法:你的订单为什么自己"跑"到了 1 号?

一、技术背景

1.1 什么是碰撞检测

在 dnd-kit 拖拽库中,碰撞检测(Collision Detection)负责判断拖拽元素与放置目标之间的空间关系,核心解决两个问题:

  • 悬停判定 :拖拽过程中,当前是否悬停在某个放置目标上(决定 isOver 状态)
  • 放置判定 :拖拽结束时,应该放置到哪个目标(决定 over.id 值)

1.2 为什么碰撞检测很重要

不同的碰撞检测算法会产生截然不同的交互结果:

场景 严格算法结果 宽松算法结果
拖到空白区域 不触发任何操作 可能触发最近目标的放置
目标很小 难以精准放置 容易触发放置
目标之间距离近 精确选择目标 可能选错目标

选择错误的算法会导致用户体验问题,甚至产生严重 Bug。


二、真实案例:月度排产 Bug

2.1 问题现象

月度排产页面布局:

css 复制代码
┌────────────────────────────────────────────────┐
│                                                │
│  [订单池]                    [日历]            │
│                                                │
│  ┌──────────┐            ┌──┐┌──┐┌──┐        │
│  │ 订单卡片  │            │1 ││2 ││3 │...     │
│  │ 订单卡片  │            └──┘└──┘└──┘        │
│  │ 订单卡片  │                                │
│  └──────────┘                                │
│                                                │
└────────────────────────────────────────────────┘

用户从订单池拖出订单,拖到订单池左侧空白区域松手。

预期:订单退回订单池,不触发排产。

实际:订单被排产到了每月的 1 号。

2.2 问题原因

原代码使用 closestCenter 碰撞检测算法:

tsx 复制代码
<DndContext collisionDetection={closestCenter}>

closestCenter 的工作原理:计算拖拽元素中心与所有放置目标中心的距离,选择距离最小的目标。

由于日历 1 号格子距离订单池最近,即使用户拖到空白区域,算法仍然返回 1 号作为放置目标。

2.3 解决方案

将碰撞检测算法改为 pointerWithin

tsx 复制代码
<DndContext collisionDetection={pointerWithin}>

pointerWithin 的工作原理:只有当鼠标指针真正进入放置目标的矩形边界内部时,才判定为悬停/放置。

修改后效果:用户拖到空白区域 → 指针不在任何目标内 → overnull → 不触发排产。


三、五种碰撞检测算法详解

3.1 pointerWithin

原理:检测鼠标指针坐标是否位于放置目标的矩形边界内部。

判定公式

arduino 复制代码
触发条件:
pointer.x >= rect.left  &&
pointer.x <= rect.right &&
pointer.y >= rect.top   &&
pointer.y <= rect.bottom

技术特点

  • 只关注鼠标指针位置,忽略拖拽元素大小
  • 最严格的判定方式
  • 不会误判到空白区域

适用场景

  • 放置目标区域较大(用户容易精准进入)
  • 存在空白区域(需要防止误操作)
  • 需要精确控制放置行为

不适用场景

  • 放置目标区域很小(用户难以精准操作)
  • 需要辅助吸附的交互体验

代码示例

tsx 复制代码
import { DndContext, pointerWithin } from '@dnd-kit/core';

<DndContext collisionDetection={pointerWithin}>
  {/* 拖拽内容 */}
</DndContext>

3.2 closestCenter

原理:计算拖拽元素中心点与所有放置目标中心点的欧几里得距离,选择距离最小的目标。

判定公式

markdown 复制代码
距离计算:
distance = √[(x1-x2)² + (y1-y2)²]

判定过程:
1. 获取拖拽元素中心坐标 (dragCenterX, dragCenterY)
2. 遍历所有放置目标,获取各自中心坐标
3. 计算每个目标的距离
4. 返回距离最小的目标

技术特点

  • 基于距离的"就近吸附"
  • 用户不需要精准操作
  • 可能误判到空白区域附近的目标

适用场景

  • 放置目标较小(需要辅助吸附)
  • 放置目标之间距离较远(不会混淆)
  • 需要宽松的交互体验

不适用场景

  • 存在空白区域(可能误判到最近目标)
  • 放置目标之间距离较近(可能选错)
  • 需要精确放置的场景

代码示例

tsx 复制代码
import { DndContext, closestCenter } from '@dnd-kit/core';

<DndContext collisionDetection={closestCenter}>
  {/* 拖拽内容 */}
</DndContext>

3.3 closestCorners

原理:计算拖拽元素四个角与放置目标四个角之间的最小距离。

判定公式

sql 复制代码
判定过程:
1. 获取拖拽元素四个角坐标:
   corners_drag = [(left,top), (right,top), (left,bottom), (right,bottom)]

2. 获取放置目标四个角坐标:
   corners_drop = [(left,top), (right,top), (left,bottom), (right,bottom)]

3. 计算所有角点组合距离(4×4=16种):
   min_distance = min(distance(corners_drag[i], corners_drop[j]))

4. 选择最小距离对应的目标

与 closestCenter 的区别

当两个矩形斜向排列时,角点距离判定更准确:

css 复制代码
场景示例:

   ┌─────┐
   │  A  │
   └─────┘
         ╲
          ╲ 拖拽元素在此
           ╲
            [📦]

closestCenter:计算 A 中心到包裹中心的距离
closestCorners:计算 A 右下角到包裹左上角的距离(判定更准确)

技术特点

  • 比 closestCenter 更精细
  • 对角接近更敏感
  • 适合矩形元素之间的拖拽

适用场景

  • 拖拽元素和放置目标都是矩形
  • 需要比 closestCenter 更精细的判定

代码示例

tsx 复制代码
import { DndContext, closestCorners } from '@dnd-kit/core';

<DndContext collisionDetection={closestCorners}>
  {/* 拖拽内容 */}
</DndContext>

3.4 rectangleIntersection

原理:检测拖拽元素矩形与放置目标矩形是否有交集(重叠区域)。

判定公式

scss 复制代码
有交集的条件:
drag.left < drop.right   &&
drag.right > drop.left   &&
drag.top < drop.bottom   &&
drag.bottom > drop.top

交集面积计算:
intersectionWidth = min(drag.right, drop.right) - max(drag.left, drop.left)
intersectionHeight = min(drag.bottom, drop.bottom) - max(drag.top, drop.top)
intersectionArea = intersectionWidth × intersectionHeight

技术特点

  • 基于面积重叠判定
  • 严格程度介于 pointerWithin 和 closestCenter 之间
  • 重叠面积越大优先级越高

适用场景

  • 拖拽元素较大
  • 放置目标区域较大
  • 需要基于"重叠程度"判断的场景

代码示例

tsx 复制代码
import { DndContext, rectangleIntersection } from '@dnd-kit/core';

<DndContext collisionDetection={rectangleIntersection}>
  {/* 拖拽内容 */}
</DndContext>

3.5 组合算法(collisionPriorityOrder)

原理:按优先级顺序依次应用多个碰撞检测算法,返回第一个有结果的目标。

判定流程

markdown 复制代码
1. 执行第一个算法
2. 如果返回结果不为空,立即返回
3. 如果返回结果为空,执行下一个算法
4. 重复直到有结果或所有算法执行完毕

技术特点

  • 兼顾精确性和易用性
  • 灵活性最高
  • 适合复杂场景

适用场景

  • 复杂布局,需要兼顾多种情况
  • 既想精确控制,又想提供友好体验
  • 不确定选择哪种单一算法时

代码示例

tsx 复制代码
import {
  DndContext,
  pointerWithin,
  closestCenter,
  collisionPriorityOrder
} from '@dnd-kit/core';

// 先尝试精确检测,失败后尝试就近吸附
const customDetection = collisionPriorityOrder([
  pointerWithin,
  closestCenter,
]);

<DndContext collisionDetection={customDetection}>
  {/* 拖拽内容 */}
</DndContext>

四、算法对比与选择

4.1 算法对比表

算法 检测维度 严格程度 误判风险 适用场景
pointerWithin 指针位置 最严格 最低 大目标、精确放置、有空白区域
closestCenter 中心距离 较宽松 较高 小目标、辅助吸附、无空白区域
closestCorners 角点距离 中等 中等 矩形元素、精细判定
rectangleIntersection 矩形重叠 中等 中等 大元素、重叠判定
组合算法 多维度 可配置 可控 复杂场景、不确定时

4.2 决策流程图

markdown 复制代码
                    开始选择算法
                         │
                         ▼
               ┌─────────────────┐
               │ 是否存在空白区域?│
               └────────┬────────┘
                        │
                   是   │   否
                        │           ┌─────────────────┐
                        ▼           │ 放置目标是否很小?│
               ┌─────────────────┐  └────────┬────────┘
               │ pointerWithin   │           │
               └─────────────────┘      是   │   否
                                                 │
                                                 ▼
                                        ┌─────────────────┐
                                        │ closestCenter   │
                                        │ 或组合算法       │
                                        └────────┬────────┘
                                                 │
                                                 ▼
                                        ┌─────────────────┐
                                        │ 目标之间是否距离近?│
                                        └────────┬────────┘
                                                 │
                                            是   │   否
                                                 │           ┌─────────────────┐
                                                 ▼           │ closestCenter   │
                                        ┌─────────────────┐  └─────────────────┘
                                        │ pointerWithin   │
                                        └─────────────────┘

4.3 快速选择指南

场景特征 推荐算法 原因
有空白区域 pointerWithin 防止误判到空白区域附近的目标
目标很小 closestCenter 或组合 提供辅助吸附,降低操作难度
目标之间距离近 pointerWithin 精确判定,防止选错目标
拖拽元素很大 rectangleIntersection 发挥大元素优势,更容易触发
不确定/复杂场景 组合算法 兼顾多种情况

五、常见问题与解决方案

5.1 问题:拖到空白区域却触发了放置

现象:用户拖到空白区域松手,系统却判定为放置到某个目标。

原因 :使用了 closestCenter,算法自动选择了最近的目标。

解决方案

tsx 复制代码
// 修改前
<DndContext collisionDetection={closestCenter}>

// 修改后
<DndContext collisionDetection={pointerWithin}>

5.2 问题:目标太小,难以触发放置

现象:用户拖拽元素到目标附近,但很难精准进入目标区域。

原因 :使用了 pointerWithin,目标区域太小。

解决方案

tsx 复制代码
// 方案一:直接使用 closestCenter
<DndContext collisionDetection={closestCenter}>

// 方案二:组合算法(推荐)
const customDetection = collisionPriorityOrder([
  pointerWithin,
  closestCenter,
]);
<DndContext collisionDetection={customDetection}>

5.3 问题:目标之间距离近,容易选错

现象:用户想放到目标 A,却放到了相邻的目标 B。

原因 :使用了 closestCenter,A 和 B 距离相近,算法选错了。

解决方案

tsx 复制代码
<DndContext collisionDetection={pointerWithin}>

5.4 问题:拖拽元素很大,容易误触发

现象:拖拽元素很大,用户只是想移动位置,却不小心触发了放置。

原因 :使用了 closestCenter,大元素的中心容易"接近"目标。

解决方案

tsx 复制代码
// pointerWithin 只看指针位置,忽略元素大小
<DndContext collisionDetection={pointerWithin}>

5.5 问题:拖拽元素很大,想更容易触发

现象:拖拽元素很大,但目标很小,用户很难精准放置。

原因 :使用了 pointerWithin,只看指针位置,大元素优势没发挥。

解决方案

tsx 复制代码
// rectangleIntersection 基于重叠判定,大元素更容易触发
<DndContext collisionDetection={rectangleIntersection}>

六、进阶用法

6.1 自定义碰撞检测函数

tsx 复制代码
import type { CollisionDetection } from '@dnd-kit/core';
import { pointerWithin, closestCenter } from '@dnd-kit/core';

const customDetection: CollisionDetection = (args) => {
  const { active, droppableRects } = args;

  // 第一步:尝试精确检测
  const pointerCollisions = pointerWithin(args);
  if (pointerCollisions.length > 0) {
    return pointerCollisions;
  }

  // 第二步:根据拖拽元素类型决定后续策略
  const activeData = active.data.current;

  if (activeData?.type === 'important-item') {
    // 重要元素:必须精确放置,不使用就近吸附
    return [];
  }

  // 普通元素:过滤掉某些目标后,再尝试就近检测
  const filteredRects = droppableRects.filter((rect) => {
    return rect.id !== 'excluded-target';
  });

  return closestCenter({
    ...args,
    droppableRects: filteredRects,
  });
};

<DndContext collisionDetection={customDetection}>
  {/* 拖拽内容 */}
</DndContext>

6.2 动态切换算法

tsx 复制代码
import { useState } from 'react';
import { pointerWithin, closestCenter } from '@dnd-kit/core';

function MyComponent() {
  const [isDragging, setIsDragging] = useState(false);

  // 拖拽中用严格算法,初始状态用宽松算法
  const collisionDetection = isDragging
    ? pointerWithin
    : closestCenter;

  return (
    <DndContext
      collisionDetection={collisionDetection}
      onDragStart={() => setIsDragging(true)}
      onDragEnd={() => setIsDragging(false)}
    >
      {/* 拖拽内容 */}
    </DndContext>
  );
}

七、总结

算法 核心原理 一句话总结
pointerWithin 检测指针是否在目标矩形内 鼠标进去了才算
closestCenter 计算中心点欧几里得距离 自动选择最近的
closestCorners 计算四个角的最小距离 比中心距离更精细
rectangleIntersection 检测矩形是否有交集 有重叠才算
组合算法 按优先级依次尝试多种算法 先严后松,灵活应对
相关推荐
qq_316837757 小时前
npm run tauri build Downloading下载超时
前端·npm·node.js
w_t_y_y7 小时前
VUE3(二)VUE2和VUE3区别
前端·javascript·vue.js
T-shmily7 小时前
使用svg图标
前端·css
阿明在折腾7 小时前
在浏览器里实现 PDF 合并与拆分:pdf-lib 实战指南
前端·javascript
米高梅狮子8 小时前
03.OpenStack使用
linux·前端·云原生·容器·架构·kubernetes·openstack
时光不负努力8 小时前
手写三大核心:Promise、Event Bus、深拷贝
前端
星栈8 小时前
被Leptos弹窗逼疯后,我搞了一套零Props方案
前端·前端框架·全栈
不是山谷.:.8 小时前
Axios的【接口防抖 + 请求失败重试 + 弱网提示】三合一高阶版封装
前端·javascript·vue.js·笔记·elementui·typescript
超绝大帅哥8 小时前
babel降级|>, Object.groupBy
前端·javascript