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

50 阅读8分钟

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

一、技术背景

1.1 什么是碰撞检测

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

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

1.2 为什么碰撞检测很重要

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

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

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


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

2.1 问题现象

月度排产页面布局:

┌────────────────────────────────────────────────┐
│                                                │
│  [订单池]                    [日历]            │
│                                                │
│  ┌──────────┐            ┌──┐┌──┐┌──┐        │
│  │ 订单卡片  │            │1 ││2 ││3 │...     │
│  │ 订单卡片  │            └──┘└──┘└──┘        │
│  │ 订单卡片  │                                │
│  └──────────┘                                │
│                                                │
└────────────────────────────────────────────────┘

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

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

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

2.2 问题原因

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

<DndContext collisionDetection={closestCenter}>

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

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

2.3 解决方案

将碰撞检测算法改为 pointerWithin

<DndContext collisionDetection={pointerWithin}>

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

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


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

3.1 pointerWithin

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

判定公式

触发条件:
pointer.x >= rect.left  &&
pointer.x <= rect.right &&
pointer.y >= rect.top   &&
pointer.y <= rect.bottom

技术特点

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

适用场景

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

不适用场景

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

代码示例

import { DndContext, pointerWithin } from '@dnd-kit/core';

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

3.2 closestCenter

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

判定公式

距离计算:
distance = √[(x1-x2)² + (y1-y2)²]

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

技术特点

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

适用场景

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

不适用场景

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

代码示例

import { DndContext, closestCenter } from '@dnd-kit/core';

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

3.3 closestCorners

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

判定公式

判定过程:
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 的区别

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

场景示例:

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

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

技术特点

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

适用场景

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

代码示例

import { DndContext, closestCorners } from '@dnd-kit/core';

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

3.4 rectangleIntersection

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

判定公式

有交集的条件:
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 之间
  • 重叠面积越大优先级越高

适用场景

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

代码示例

import { DndContext, rectangleIntersection } from '@dnd-kit/core';

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

3.5 组合算法(collisionPriorityOrder)

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

判定流程

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

技术特点

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

适用场景

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

代码示例

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 决策流程图

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

4.3 快速选择指南

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

五、常见问题与解决方案

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

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

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

解决方案

// 修改前
<DndContext collisionDetection={closestCenter}>

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

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

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

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

解决方案

// 方案一:直接使用 closestCenter
<DndContext collisionDetection={closestCenter}>

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

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

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

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

解决方案

<DndContext collisionDetection={pointerWithin}>

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

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

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

解决方案

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

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

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

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

解决方案

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

六、进阶用法

6.1 自定义碰撞检测函数

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 动态切换算法

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检测矩形是否有交集有重叠才算
组合算法按优先级依次尝试多种算法先严后松,灵活应对