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 的工作原理:只有当鼠标指针真正进入放置目标的矩形边界内部时,才判定为悬停/放置。
修改后效果:用户拖到空白区域 → 指针不在任何目标内 → over 为 null → 不触发排产。
三、五种碰撞检测算法详解
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 |
检测矩形是否有交集 | 有重叠才算 |
| 组合算法 | 按优先级依次尝试多种算法 | 先严后松,灵活应对 |