Writing

可拖拽组件 (Drag and Drop)

文章發表於

前言

可拖拽组件 (Drag and Drop; DnD) 是网页常见的交互组件,从可拖拽数据表到任务墙等等,本文不会有非常复杂的实现方式,而是介绍如何实现一个简单的拖拽组件,主要目的是让读者了解其背后的原理。

可拖拽组件的三大核心概念

首先我们将介绍可拖拽组件的三大要素,也是组件的核心概念。

  1. 协调控制层 (Coordination Layer)
  2. 可拖拽组件 (Draggable)
  3. 可放置组件 (Droppable)
Context Provider
|
┌──────────┐
↓ ↓
Draggables Droppables
↓ ↓
(Register) (Register)
↓ ↓
(Events) (Collision)

协调控制层 (Coordination Layer)

协调控制层是拖拽组件的核心,它负责追踪「目前是否有项目正在被拖拽」、「拖拽的是哪个元素」、「游标/触控坐标在哪里」,以及「拖拽的元素是否与可放置组件碰撞」。

可拖拽组件 (Draggable)

表示可以被拖拽的元素,像是数据表里面的每一笔数据,或是任务墙里面的每一个任务。

可放置组件 (Droppable)

表示可以接收拖拽物的区域,数据表的每一列同时是一个可拖拽组件也是可放置组件,任务墙的每一个任务同时是一个可拖拽组件也是可放置组件。

实现要点

以上方演示来举例,每个项目都同时是可拖拽组件也是可放置组件,因此它们都会注册到协调控制层中,并且在拖拽的过程中,控制层会监听 mousemove 事件,去计算拖拽组件是否与可放置组件碰撞,如果是,则会显示相对应的视觉提示 (像是叠加在项目上方的蓝色实线),最后当拖拽完成 mouseup 时就会执行相应的逻辑 (像是重新排序项目)。

监听事件

同时根据上方的描述大概可窥探需要监听的事件如下:

  • mousedown 检测开始拖拽
    • 当前正在拖拽的项目 (active)
    • 游标与整体元素之间的坐标偏移量 (dragOffset)
    • 调用 onDragStart 事件
  • mousemove
    • 拖拽中,重新计算游标/触控坐标 (mousePosition)
    • 并侦测是否与可放置组件碰撞 (over),如果碰撞则显示视觉提示
  • mouseup
    • 拖拽完成,调用 onDragEnd 事件
    • 移除碰撞侦测的视觉提示
    • 执行相应的逻辑 (像是重新排序项目)
    • 重设协调控制层的状态

碰撞侦测

这也是我们为什么需要计算拖拽组件的坐标,因为我们需要知道拖拽组件的坐标,才能知道它是否与可放置组件碰撞。

const findDroppableUnderPoint = (x, y) => {
const currentActiveId = stateRef.current.active?.id;
const droppables = Array.from(droppablesRef.current.entries()).reverse();
for (const [id, droppable] of droppables) {
const { element, data } = droppable;
if (element && currentActiveId !== id) {
const rect = element.getBoundingClientRect();
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
return { id, data };
}
}
}
return null;
};

将拖拽元素拖到目标区域内,只要拖拽元素中心点与目标区域上、下、左、右的边界有碰撞,就可以看到碰撞检测的结果为 TRUE,反之则为 FALSE

useDraggable

useDraggable 主要是处理拖拽元素的逻辑,像是侦测是否正在拖拽、计算拖拽元素的坐标、注册拖拽元素到协调控制层。

function useDraggable({ id, data }) {
const context = useContext(DndContext);
const nodeRef = useRef(null);
useEffect(() => {
if (nodeRef.current) {
context.registerDraggable(id, nodeRef.current, data);
}
return () => context.unregisterDraggable(id);
}, [id, data, context]);
const isDragging = context.active?.id === id;
const transform = isDragging ? {
x: context.mousePosition.x - context.dragOffset.x,
y: context.mousePosition.y - context.dragOffset.y,
} : null;
return {
setNodeRef: (node) => {
nodeRef.current = node;
},
listeners: {
onMouseDown: (event) => {
context.handleMouseDown(event, id, data);
},
},
isDragging,
transform,
};
}

useDroppable

useDroppable 只需要执行两件事,第一件事是注册可放置组件,第二件事是侦测拖拽元素是否与可放置组件碰撞。

function useDroppable({ id, data }) {
const context = useContext(DndContext);
const nodeRef = useRef(null);
useEffect(() => {
if (nodeRef.current) {
context.registerDroppable(id, nodeRef.current, data);
}
return () => context.unregisterDroppable(id);
}, [id, data, context]);
const isOver = context.over?.id === id;
return {
setNodeRef: (node) => {
nodeRef.current = node;
},
isOver,
};
}

useSortable

useSortable 组件是将 useDraggable 组件与 useDroppable 结合在一起的 hook,主要是因为多数时候我们会需要一个组件同时是可拖拽组件也是可放置组件,这时候这个 hook 就非常好用。

function useSortable({ id, data }) {
const draggable = useDraggable({ id, data });
const droppable = useDroppable({ id, data });
return {
...draggable,
isOver: droppable.isOver,
// Combine the setNodeRef functions from both hooks
setNodeRef: (node) => {
draggable.setNodeRef(node);
droppable.setNodeRef(node);
},
};
}

实现

请点击『展开代码』以查看代码。
export default function App() {
  return <h1>Hello world</h1>
}

如果您喜欢这篇文章,请点击下方按钮分享给更多人,这将是对笔者创作的最大支持和鼓励。
Buy me a coffee