React 拖曳開發指南:dnd kit 實作拖拉表單全紀錄

dndkit

在前端開發中,「拖拉互動(Drag and Drop)」是提升使用者體驗的常見需求。最近T編在開發「拖拉式表單建構器」專案時,深入研究了如何更有效率、也更容易維護地實作這類較複雜的互動功能。dndKit是一個前端用來實現拖拉功能的library,能夠讓開發者更快速的開發一個拖拉功能的原型。

一、架構設計

整體架構大致如下:從「組件庫(Palette)」把表單組件拖到「畫布(Canvas)」上完成排版,最後再生成一個「預覽表單(Preview)」。

T編的做法是把每一個表單組件都封裝成一個物件,並用一個陣列來保存畫布上的內容與順序;預覽頁則根據這個陣列去 render 出實際表單。

拖拉功能當然可以自行實作,不過也可以直接使用成熟的套件。T編這次選擇的是 Claudéric Demers 開發的 dnd-kit。dnd-kit 功能完整,且支援多種前端框架(Vue、React 等)。本篇會以 React 為例來示範。

二、dnd-kit 套件概念

DnD(Drag and Drop)最核心的兩件事是:

  • Draggable:可被拖拉的元素(Drag)
  • Droppable:可放置的目標區域(Drop)

以表單建構器為例:

  • Drag:表單組件(下拉選單、單行文字等)
  • Drop:放置組件的位置(畫布)

以下會依照官方文件的典型流程,用官方範例程式做簡要示範。

1. 安裝套件(React)

npm install @dnd-kit/react

2. 將組件變成 Draggable(可拖拉)

使用 useDraggable 這個 hook,並傳入一個獨特的 id。這個 hook 會回傳一個 ref,把它綁到要拖拉的元素上即可。

import {useDraggable} from '@dnd-kit/react';

export function Draggable(props) {
  const {ref} = useDraggable({
    id: props.id,
  });

  return <button ref={ref} className="btn">draggable</button>;
}

3. 將組件變成 Droppable(可放置)

使用 useDroppable 這個 hook,並傳入一個獨特的 id。同樣地,這個 hook 會回傳 ref,綁到放置區的元素即可。

import {useDroppable} from '@dnd-kit/react';

function Droppable(props) {
  const {isDropTarget, ref} = useDroppable({
    id: props.id,
  });

  return (
    <div ref={ref}>
      {isDropTarget ? 'Draggable element is over me' : 'Drag something over me'}
    </div>
  );
}

isDropTarget 是 hook 提供的狀態,用來判斷目前是否有 draggable 元素「移動到 droppable 上方」。你可以用它來切換樣式或提示文字。

4. 將組件變成 Sortable(可排序)

若要在畫布內「拖拉排序」,可以使用 useSortable。傳入一個獨特 id,以及目前元素的 index

import {useSortable} from '@dnd-kit/react/sortable';

function Sortable({id, index}) {
  const {ref} = useSortable({id, index});

  return (
    <li ref={ref} className="item">Item {id}</li>
  );
}

export default function App() {
  const items = [1, 2, 3, 4];

  return (
    <ul className="list">
      {items.map((id, index) =>
        <Sortable key={id} id={id} index={index} />
      )}
    </ul>
  );
}

5. 整合:用 DragDropProvider 包起來

完成 draggable / droppable 等組件後,需要用 DragDropProvider 包住最上層,才能在裡面統一監聽拖拉事件。

import {DragDropProvider} from '@dnd-kit/react';
import Draggable from './Draggable';
import Droppable from './Droppable';

function App() {
  const [isDropped, setIsDropped] = useState(false);

  return (
    <DragDropProvider
      onDragEnd={(event) => {
        if (event.canceled) return;

        const {target} = event.operation;
        setIsDropped(target?.id === 'droppable');
      }}
    >
      {!isDropped && <Draggable />}

      <Droppable id="droppable">
        {isDropped && <Draggable />}
      </Droppable>
    </DragDropProvider>
  );
}

常用的事件包含:

  • onBeforeDragStart:拖拉開始前
  • onDragStart:拖拉開始
  • onDragMove:拖拉移動中
  • onDragOver:拖拉經過 droppable 時
  • onDragEnd:拖拉結束

三、開始實作:拖拉式表單建構器

接下來進入本次專案的實作重點。T編會附上部分程式碼與影片,協助理解整體流程。

1. 建立可拖拉的表單組件(Palette Item)

function PaletteItem({ type, label }: { type: FormFieldType; label: string }) {
  const id = `palette-${type}`;
  const { ref } = useDraggable({
    id,
    data: { type, source: "palette" as const }, // <--- 攜帶來源與型別
  });

  return (
    <Paper
      sx={{
        p: 2,
        cursor: "grab",
        "&:active": { cursor: "grabbing" },
        display: "flex",
        alignItems: "center",
        gap: 1,
      }}
      ref={ref}
    >
      <NotesIcon />
      <Typography variant="body2">{label}</Typography>
    </Paper>
  );
}

這裡使用 useDraggable 來讓組件具備拖拉能力。T編額外帶入 data,目的是「攜帶這次拖拉的資訊」。

在後續的畫布中會有兩種可以被拖動的元素:

  • 從組件庫拖進畫布的新組件
  • 已經在畫布上的組件(用於排序)

因此T編用 source 來區分拖拉來源,以便在事件處理(例如 onDragOver / onDragEnd)時,套用不同的邏輯。

2. 建立畫布(Canvas)

畫布本質上就是一個 droppable 區域,因此做法和前面 useDroppable 的示範一致:把畫布容器變成可放置區即可。

3. 讓畫布上的組件可排序(Sortable)

為了讓畫布上的組件可以自由調整順序,使用 useSortable

export default function SortableFieldItem({
  field,
  index,
  isSelected,
  onSelect,
  onDelete,
  onChange,
}: SortableFieldItemProps) {
  const { isDragging, ref, handleRef, sourceRef, targetRef } = useSortable({
    id: field.id,
    index,
  });
}

這裡T編額外使用了 handleRefsourceReftargetRef,原因如下。

handleRef:限制「只能拖拉把手」才能排序

{/* 拖拉把手:只有此區可拖動排序 */}
<IconButton
  ref={handleRef}
  sx={{
    cursor: "grab",
    color: "text.secondary",
    "&:active": { cursor: "grabbing" },
  }}
  aria-label="拖動排序"
>
  <DragIndicatorIcon fontSize="small" />
</IconButton>

T編不希望整個組件都能被拖動,而是只允許在特定的拖拉把手區域觸發排序。dnd-kit 提供handleRef 來滿足這個需求:只要把 handleRef 綁到把手元素上即可。

sourceRef / targetRef:同時支援「拖拉排序」與「插入新組件」因此這個項目本身同時是 Draggable 也是 Droppable。官方文件也有提到,搭配 useSortable 時可以使用這些 ref 來處理類似情境。因為設計的畫布項目同時需要具備兩種能力:

  • 已在畫布上的項目可以被拖動來排序(Draggable)
  • 從組件庫拖進來的新組件,也可以「丟到某個項目上方」以控制插入位置(Droppable)

4. 預覽表單(Preview)

最後別忘了把最上層用 DragDropProvider 包起來。T編另外寫了兩個自訂監聽函式,主要用來處理「排序」與「插入位置」相關的邏輯。

<DragDropProvider onDragEnd={handleDragEnd} onDragOver={handleDragOver}>
  {內容省略...}
</DragDropProvider>

完成後,把欄位陣列 fields 傳入預覽頁。

export default function FormPreview({ fields, formTitle }: FormPreviewProps) {

預覽頁只要把 fields map 成你需要的 UI,就能產出最終表單。

四、結語

這次用 dnd-kit 實作拖拉式表單建構器,T編覺得最關鍵的核心是:

  • 把「來源(Palette)」與「畫布(Canvas)」的資料流切清楚
  • 用 data 來區分拖拉來源
  • 搭配 useDroppable 建立放置區
  • 用 useSortable 處理畫布內排序與插入位置

以上就是 dnd-kit 介紹與本次前端拖拉式表單實作的整理。

Loading

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *