💈

dnd-kitで複数列のDnD並び替えを実装する

2024/02/19に公開

はじめに

今後Reactで普及していくドラッグアンドドロップ(以下DnD)ライブラリといえばdnd-kitでしょう(根拠なし)。

カスタマイズ性に優れる当ライブラリでは、DnDによる並び替えを実装するためのパッケージはコアな機能のパッケージとは別になっています。
並び替えのドキュメントを見てみるといろいろできそうです。
そこに複数のSortableContextの説明もありますが、なぜかサンプルコードがありません。
サンプルコードを書くほどでもないということなのでしょうか。。。

ここにありました

そこでこの記事では、複数列のDnD領域を持ち、自由に要素を並び替えできる、カンバンのようなdnd-kitサンプルコードを共有します。

デモ

サンプルコード

https://codesandbox.io/p/devbox/dnd-kit-multiple-columns-wx7pp8

重要な部分

以下ではサンプルコードのうち重要な部分を説明します。

containerId

まず重要となるのは列を識別するためのIDでしょう。

SortableContextidは、指定されなければ自動的に一意な値を設定してくれます。
このidの値はonDragOveronDragEndactiveoverdataを通してアクセスできるようになります。

したがって、以下のように列のIDをSortableContextidに渡します。

<SortableContext
  id={String(props.containerId)}
  items={props.items}
  strategy={verticalListSortingStrategy}
>

UniqueIdentifierstringnumberですが、SortableContextidstringのみなので、StringcontainerIdstringにしています。

CollisionDetection

次に重要となるのは衝突判定の方法です。

ドキュメントによれば、いくつか用意されている衝突判定の方法のうち、角っこ方式がリストの並び替えに適しているようです。

1列の場合はclosestCornersをそのまま使えばうまくいきます。
しかし、複数列の場合は以下のようにうまくいきません。

隣の列の要素に吸い寄せられ、最も近い列に要素を入れることができません。

これを回避するため、以下のように角っこ方式を利用した独自のアルゴリズムを定義します。

  1. 最も近い列を取得する
  2. その列の要素のうち最も近いものを返す

実装は以下の通りです。

const detectCollision: CollisionDetection = (args) => {
  const cornerCollisions = closestCorners(args);
  
  const closestContainer = cornerCollisions.find((c) => {
    return itemsMap.has(c.id);
  });
  
  if (typeof closestContainer === "undefined") {
    return cornerCollisions;
  }
  
  const collisions = cornerCollisions.filter(({ data }) => {
    if (typeof data === "undefined") {
      return false;
    }
    
    const droppableData = data.droppableContainer?.data?.current as
      | SortableData
      | undefined;
    if (typeof droppableData === "undefined") {
      return false;
    }
    
    const { containerId } = droppableData.sortable;
    
    return closestContainer.id === containerId;
  });
  
  if (collisions.length === 0) {
    return [closestContainer];
  }
  
  return collisions;
};

最も近い列に要素がない場合は、その列自体を返すようにしています。

更新

先ほども少し触れましたが、containerIddataを通して渡されます。
ついでにitemsや要素のitems内のindexも渡されるので、これらの情報を使ってステートの更新を行います。

SortableContextuseSortableによって同一列の並び替えのアニメーションは実装されているようなので、onDragOverでは要素の列間の移動を、onDragEndでは要素の同一列内の移動を行います。

具体的には以下のようになります。

interface Data {
  containerId: UniqueIdentifier
  items: Items
  index: number
}

const getFromData = (active: Active): Data => {
  const activeData = active.data.current as SortableData
  return activeData.sortable
}

const getToData = (over: Over): Data => {
  const items = itemsMap.get(over.id)
  if (typeof items === 'undefined') {
    const overData = over.data.current as SortableData
    return overData.sortable
  }
  else {
    return {
      containerId: over.id,
      items,
      index: NaN,
    }
  }
}

const getData = (event: { active: Active, over: Over | null }) => {
  const { active, over } = event;
  if (over === null) {
    return null
  }

  if (active.id === over.id) {
    return null
  }

  return {
    from: getFromData(active),
    to: getToData(over),
  }
}

const handleDragStart = (event: DragStartEvent) => {
  setActiveId(event.active.id)
}

const handleDragOver = (event: DragOverEvent) => {
  const data = getData(event)
  if (data == null) {
    return
  }

  const { from, to } = data

  if (from.containerId === to.containerId) {
    return
  }

  const item = from.items[from.index]

  const newFromItems = arrayRemove(from.items, from.index)
  const newToItems = arrayInsert(to.items, to.index, item)

  setItemsMap(new Map([
    ...itemsMap.entries(),
    [from.containerId, newFromItems],
    [to.containerId, newToItems],
  ]))
}

const handleDragEnd = (event: DragEndEvent) => {
  const data = getData(event)
  if (data == null) {
    return
  }

  const { from, to } = data

  const newFromItems = arrayMove(from.items, from.index, to.index)

  setItemsMap(new Map([
    ...itemsMap.entries(),
    [from.containerId, newFromItems],
  ]))
}

おまけ

useSortableactiveを返してくれるので、opacity: props.itemId === active?.id ? 0 : 1というように要素のスタイルを設定することでオーバーレイだけを表示することができます。
hiddendispley: noneは要素が吹っ飛んでしまってうまくいきませんでした。

Discussion