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);
},
};
}

实现

请点击『展开代码』以查看代码。
import React, { createContext, useContext, useState, useRef, useEffect, useCallback } from 'react';

// ===== 1. DRAG AND DROP CONTEXT =====
// This context provides all the necessary state and functions for the D&D system.
const DndContext = createContext(null);

export function DndProvider({ children, onDragStart, onDragEnd }) {
  // State for active drag
  const [active, setActive] = useState(null);
  const [over, setOver] = useState(null);

  // Mouse event tracking
  const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });

  // Registries for draggables and droppables
  const draggablesRef = useRef(new Map());
  const droppablesRef = useRef(new Map());

  // Fix for Stale Closure - ref holds latest state and props
  const stateRef = useRef({ active, over, onDragEnd });

  // Update ref with latest state and props on every render
  useEffect(() => {
    stateRef.current = { active, over, onDragEnd };
  });

  // Collision detection function
  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;
  };

  // Event handlers with useCallback to prevent stale closures
  const handleMouseMove = React.useCallback((event) => {
    setMousePosition({ x: event.clientX, y: event.clientY });

    // Find what we're hovering over
    const over = findDroppableUnderPoint(event.clientX, event.clientY);
    setOver(over);
  }, []);

  const handleMouseUp = React.useCallback(() => {
    // Get latest state from ref
    const { active, over, onDragEnd } = stateRef.current;

    // Call the onDragEnd callback
    if (onDragEnd && active) {
      onDragEnd({ active, over });
    }

    // Reset all state variables
    setActive(null);
    setOver(null);
    setDragOffset({ x: 0, y: 0 });

    // Remove global event listeners
    window.removeEventListener('mousemove', handleMouseMove);
    window.removeEventListener('mouseup', handleMouseUp);
  }, [handleMouseMove]);

  const handleMouseDown = (event, draggableId, data) => {
    // 1. Get the element and calculate offset
    const draggableElement = draggablesRef.current.get(draggableId)?.element;
    if (!draggableElement) return;

    const rect = draggableElement.getBoundingClientRect();

    // 2. Set active item
    setActive({ id: draggableId, data });

    // 3. Calculate initial offset (where within the element they clicked)
    setDragOffset({
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    });

    // 4. Set initial mouse position
    setMousePosition({ x: event.clientX, y: event.clientY });

    // 5. Call onDragStart callback
    if (onDragStart) {
      onDragStart({ active: { id: draggableId, data } });
    }

    // 6. Add global mouse event listeners
    window.addEventListener('mousemove', handleMouseMove);
    window.addEventListener('mouseup', handleMouseUp);

    // Prevent text selection while dragging
    event.preventDefault();
  };

  // Registration functions
  const registerDraggable = (id, element, data) => {
    draggablesRef.current.set(id, { element, data });
  };

  const unregisterDraggable = (id) => {
    draggablesRef.current.delete(id);
  };

  const registerDroppable = (id, element, data) => {
    droppablesRef.current.set(id, { element, data });
  };

  const unregisterDroppable = (id) => {
    droppablesRef.current.delete(id);
  };

  const contextValue = {
    active,
    over,
    mousePosition,
    dragOffset,
    handleMouseDown,
    registerDraggable,
    unregisterDraggable,
    registerDroppable,
    unregisterDroppable,
  };

  return (
    <DndContext.Provider value={contextValue}>
      {children}
    </DndContext.Provider>
  );
}

// ===== 2. DRAGGABLE HOOK =====
// This hook provides the necessary props to make a component draggable.
export function useDraggable({ id, data }) {
  const context = useContext(DndContext);
  if (!context) throw new Error('useDraggable must be used within a DndProvider');

  const nodeRef = useRef(null);

  // Register and unregister the draggable element with the context
  useEffect(() => {
    if (nodeRef.current) {
      context.registerDraggable(id, nodeRef.current, data);
    }
    return () => context.unregisterDraggable(id);
  }, [id, data, context]);

  // Check if this specific item is the one currently being dragged
  const isDragging = context.active?.id === id;

  // Calculate the CSS transform to move the element with the mouse
  const transform = isDragging ? {
    x: context.mousePosition.x - context.dragOffset.x,
    y: context.mousePosition.y - context.dragOffset.y,
  } : null;

  return {
    setNodeRef: (node) => {
      nodeRef.current = node;
    },
    attributes: {
      'data-draggable-id': id,
    },
    listeners: {
      onMouseDown: (event) => {
        context.handleMouseDown(event, id, data);
      },
    },
    isDragging,
    transform,
  };
}

// ===== 3. DROPPABLE HOOK =====
// This hook provides the necessary props to make a component a droppable area.
export function useDroppable({ id, data }) {
  const context = useContext(DndContext);
  if (!context) throw new Error('useDroppable must be used within a DndProvider');

  const nodeRef = useRef(null);

  // Register and unregister the droppable element with the context
  useEffect(() => {
    if (nodeRef.current) {
      context.registerDroppable(id, nodeRef.current, data);
    }
    return () => context.unregisterDroppable(id);
  }, [id, data, context]);

  // Check if a dragged item is currently over this droppable area
  const isOver = context.over?.id === id;

  return {
    setNodeRef: (node) => {
      nodeRef.current = node;
    },
    isOver,
  };
}

// ===== 4. SORTABLE HOOK (COMBINES DRAGGABLE + DROPPABLE) =====
// A convenience hook for items that are both draggable and a drop target.
export 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);
    },
  };
}

// ===== 5. DEMO COMPONENTS =====

// A sortable item component that uses the useSortable hook.
function SortableItem({ item, index }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    isDragging,
    isOver,
  } = useSortable({
    id: item.id,
    data: { item, index },
  });

  // This is the placeholder that stays in the list.
  // When dragging, it becomes invisible but still occupies space.
  const placeholderStyle = {
    visibility: isDragging ? 'hidden' : 'visible',
    // The drop indicator is a line on top of the item being hovered over.
    border: '1px solid #ddd',
    borderTop: isOver ? '2px solid #2196f3' : '1px solid #ddd',
    // Default styling
    padding: '12px',
    margin: '4px 0',
    borderRadius: '4px',
    backgroundColor: 'white',
    transition: 'border-top-color 0.2s ease',
  };

  // This is the visual element that follows the mouse.
  // It only appears when dragging.
  const dragOverlayStyle = {
    position: 'fixed',
    top: transform?.y ?? 0,
    left: transform?.x ?? 0,
    width: '580px', // Keep width consistent when dragging
    zIndex: 1000,
    // This is the key change: it prevents the overlay from blocking mouse events.
    pointerEvents: 'none',
    // Visual feedback for the user
    boxShadow: '0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23)',
    cursor: 'grabbing',
    userSelect: 'none',
    // Default styling
    padding: '12px',
    margin: '4px 0',
    borderRadius: '4px',
    backgroundColor: 'white',
    border: '1px solid #ddd',
  };

  return (
    <>
      {/* The placeholder element */}
      <div
        ref={setNodeRef}
        style={placeholderStyle}
        {...attributes}
        {...listeners}
      >
        <strong>{item.name}</strong>
        <br />
        <small>{item.email}</small>
      </div>

      {/* The drag overlay, rendered only when dragging */}
      {isDragging && (
        <div style={dragOverlayStyle}>
          <strong>{item.name}</strong>
          <br />
          <small>{item.email}</small>
        </div>
      )}
    </>
  );
}

// Main Demo App
export default function MiniDndDemo() {
  const [items, setItems] = useState([
    { id: '1', name: 'John Smith', email: '[email protected]' },
    { id: '2', name: 'Jane Doe', email: '[email protected]' },
    { id: '3', name: 'Bob Johnson', email: '[email protected]' },
    { id: '4', name: 'Alice Brown', email: '[email protected]' },
    { id: '5', name: 'Charlie Davis', email: '[email protected]' },
  ]);

  const handleDragStart = (event) => {
    if (event.active) {
      console.log('Drag started:', event.active.data.item.name);
    }
  };

  const handleDragEnd = (event) => {
    const { active, over } = event;

    // If active is null, it means the drag ended without a valid start.
    if (!active) {
        return;
    }

    console.log(`Drag ended. Item: ${active.data.item.name}, Over: ${over?.data.item.name || 'nothing'}`);

    // If there's no droppable area or the item is dropped on itself, do nothing.
    if (!over || active.id === over.id) {
      return;
    }

    // Reorder the items array based on the drag and drop result
    setItems((currentItems) => {
      const oldIndex = currentItems.findIndex((item) => item.id === active.id);
      const newIndex = currentItems.findIndex((item) => item.id === over.id);

      // Create a new array to avoid direct mutation
      const newItems = Array.from(currentItems);
      // Remove the item from its old position
      const [removed] = newItems.splice(oldIndex, 1);
      // Insert the item into its new position
      newItems.splice(newIndex, 0, removed);

      return newItems;
    });
  };

  return (
    <div style={{ fontFamily: 'sans-serif', padding: '20px', maxWidth: '600px', margin: '40px auto',  borderRadius: '20px', boxShadow: '0 2px 10px rgba(0,0,0,0.1)' }}>
      <DndProvider onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
        <div style={{ marginTop: '20px' }}>
          <div style={{ cursor: 'grab' }}>
            {items.map((item, index) => (
              <SortableItem key={item.id} item={item} index={index} />
            ))}
          </div>
        </div>
      </DndProvider>
    </div>
  );
}

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