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