🗂️

dnd kitを使ったかんばんボードの並び替えを実装する

2025/02/28に公開

はじめに

こんにちは、dotDの浅野です。
この記事では、React のドラッグ&ドロップライブラリである dnd kit を使用して、かんばんボードのタスクを並び替える方法を解説します。

完成イメージ

実装するかんばんボードの完成イメージです。

https://demo-dndkit.vercel.app/

使用技術

  • Next.js
  • dnd kit
  • TypeScript

dnd kit とは?

https://dndkit.com/

dnd kit は、シンプルで柔軟性の高いドラッグ&ドロップライブラリ です。
リストの並び替え、ボードの構築、カスタム UI など、多様なユースケースに対応可能で、パフォーマンスやアクセシビリティも考慮した設計 になっています。

環境構築

1. Next.jsのインストール

yarn create next-app --typescript .

2. 必要なライブラリのインストール

yarn add @dnd-kit/core @dnd-kit/sortable

dnd kit を使ったドラッグ&ドロップの実装

コード全体は以下をご確認ください。
https://github.com/asa918yk/demo-dndkit

ボード全体の実装

1. dnd kitのセットアップ

dnd Kit を使用するには、DndContext をラップし、ドラッグ&ドロップを制御します。
本実装では、以下の2つのイベントをハンドリングします。

  • onDragEnd: 同じセグメント内の並び替え
  • onDragOver: 異なるセグメントへの移動
<DndContext
  sensors={sensors}
  collisionDetection={closestCorners}
  onDragEnd={handleDragEnd}
  onDragOver={handleDragOver}
>
  <div className={styleContainerWrapper}>
    {data.map((group, index) => 
      <Segment key={index} segmentItem={group} />
    )}
  </div>
</DndContext>

  • collisionDetection={closestCorners} でドラッグ判定を最適化
  • Segment コンポーネント でセグメントごとにタスクを描画

また、ユーザーの操作を検知する センサー (sensors) も設定します。

const sensors = useSensors(
  useSensor(PointerSensor), // マウス・タッチ操作を検知
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) // キーボード操作をサポート
);

2. データ構造

タスクは data ステートで管理し、各セグメントごとにタスクを保持します。

const [data, setData] = useState<ISegmentItem[]>([
  { id: "segment1", title: "未進行", tasks: [{ id: "item1", title: "Task 1" }, { id: "item4", title: "Task 4" }] },
  { id: "segment2", title: "進行中", tasks: [{ id: "item2", title: "Task 2" }, { id: "item5", title: "Task 5" }] },
  { id: "segment3", title: "完了", tasks: [{ id: "item3", title: "Task 3" }, { id: "item6", title: "Task 6" }] },
]);

ドラッグ時のアイテム判別に使用するためタスクの idセグメントと重複しないようにする 必要があります。

3. ドラッグされたアイテムの所属を特定

タスクがどのセグメントに属しているかを特定するため、findSegment 関数を定義します。

const findSegment = (id: string | null) => {
  if (!id) return null;
  return data.find((segment) => 
    segment.id === id || segment.tasks.some((task) => task.id === id)
  ) ?? null;
};
  • セグメント ID の場合 → そのまま返す
  • タスク ID の場合 → 所属するセグメントを返す

4. 同じセグメント内での並び替え

同じセグメント内の並び替えは、onDragEnd で処理します。

const handleDragEnd = (event: DragEndEvent) => {
  const { active, over } = event;
  const activeSegment = findSegment(String(active.id));
  const overSegment = findSegment(over ? String(over.id) : null);

  if (!activeSegment || !overSegment || activeSegment !== overSegment) return null;

  setData((prevState) => prevState.map((segment) => {
    if (segment.id === activeSegment.id) {
      segment.tasks = arrayMove(segment.tasks, 
        activeSegment.tasks.findIndex((task) => task.id === active.id), 
        activeSegment.tasks.findIndex((task) => task.id === over?.id)
      );
    }
    return segment;
  }));
};

  • arrayMove を使って、同じセグメント内での並び替えを実装
  • 異なるセグメントなら処理しない

5. 異なるセグメント間の移動

異なるセグメント間でタスクを移動させる場合、onDragOver で処理します。

const handleDragOver = (event: DragOverEvent) => {
  const { active, over, delta } = event;
  const activeSegment = findSegment(String(active.id));
  const overSegment = findSegment(over ? String(over.id) : null);

  if (!activeSegment || !overSegment || activeSegment === overSegment) return null;

  setData((prevState) => {
    const activeItems = activeSegment.tasks;
    const overItems = overSegment.tasks;
    const activeIndex = activeItems.findIndex((task) => task.id === active.id);
    const overIndex = overItems.findIndex((task) => task.id === over?.id);

    const newIndex = () => {
      const putOnLastItem = overIndex === overItems.length - 1 && delta.y > 0;
      return overIndex >= 0 ? overIndex + (putOnLastItem ? 1 : 0) : overItems.length + 1;
    };

    return prevState.map((s) => {
      if (s.id === activeSegment.id) {
        s.tasks = activeItems.filter((t) => t.id !== active.id);
      } else if (s.id === overSegment.id) {
        s.tasks = [
          ...overItems.slice(0, newIndex()), 
          activeItems[activeIndex], 
          ...overItems.slice(newIndex())
        ];
      }
      return s;
    });
  });
};

  • 異なるセグメントにドロップされたら移動
  • delta.y > 0 を使い、最後のカードの下にドロップした場合の処理を考慮

セグメント(列)のコンポーネント実装

Segment コンポーネントは、かんばんボードの各列 を表し、Dnd Kit を使用してタスクのドラッグ&ドロップを可能にする実装です。
各セグメント(列)は タスクのリスト を持ち、ドラッグ&ドロップ時の並び替えをサポートします。

1. useDroppable によるドロップ領域の設定

const { setNodeRef } = useDroppable({ id: segmentItem.id });
  • setNodeRefdiv に渡すことで、この列がドラッグ&ドロップのターゲット であることを dnd kit に伝えます。

2. SortableContext による並び替え制御

<SortableContext id={segmentItem.id} items={segmentItem.tasks} strategy={rectSortingStrategy}>
  • SortableContext を使い、列内のタスクが並び替え可能であることを定義 します。
  • rectSortingStrategyタスクを縦に並び替えるための指定 です。

タスク(アイテム)のコンポーネント実装

Task コンポーネントは、かんばんボードの各タスク を表し、dnd kit を使用して ドラッグ&ドロップで並び替え可能 にする実装です。

1. useSortable によるドラッグ可能な設定

const { attributes, listeners, setNodeRef, transform } = useSortable({ id: taskItem.id });
  • useSortabletaskItem.id を渡し、タスクごとにドラッグ&ドロップ可能な要素 として設定します。
  • 各プロパティの役割:
    • setNodeRef:要素を dnd kit に関連付ける
    • attributes:ドラッグ可能な要素としての属性を追加
    • listeners:ドラッグ操作を開始するためのリスナー
    • transform:ドラッグ中の 位置情報 を取得

2. transform を適用し、ドラッグ時の動きをスムーズにする

const style = {
  transform: CSS.Transform.toString(transform),
};
  • transformCSS.Transform.toString で変換し、ドラッグ時にスムーズに移動 させます。

3. タスクの描画

<div ref={setNodeRef} style={style} {...attributes} {...listeners} className={styleItem}>
  <h3>{taskItem.title}</h3>
</div>
  • setNodeRefdiv に設定し、ドラッグ可能な要素として認識 させます。
  • attributeslisteners を適用し、ドラッグ操作が可能に なります。

まとめ

  • dnd kit を使用してかんばんボードの並び替えを実装する方法を解説しました。
  • より高度なカスタマイズ(カラムの並び替えなど)も可能です。詳しくは公式ドキュメントをご確認ください。

https://docs.dndkit.com/


弊社では事業創造にチャレンジする仲間を募集中です。
ぜひ採用サイトをご覧いただければ幸いです。

https://dotd-inc.com/ja/careers

dotD Tech Blog

Discussion