dnd-kitで複数列のDnD並び替えを実装する
はじめに
今後Reactで普及していくドラッグアンドドロップ(以下DnD)ライブラリといえばdnd-kitでしょう(根拠なし)。
カスタマイズ性に優れる当ライブラリでは、DnDによる並び替えを実装するためのパッケージはコアな機能のパッケージとは別になっています。
並び替えのドキュメントを見てみるといろいろできそうです。
そこに複数のSortableContext
の説明もありますが、なぜかサンプルコードがありません。
サンプルコードを書くほどでもないということなのでしょうか。。。
ここにありました
そこでこの記事では、複数列のDnD領域を持ち、自由に要素を並び替えできる、カンバンのようなdnd-kitサンプルコードを共有します。
デモ
サンプルコード
重要な部分
以下ではサンプルコードのうち重要な部分を説明します。
containerId
まず重要となるのは列を識別するためのIDでしょう。
SortableContext
のid
は、指定されなければ自動的に一意な値を設定してくれます。
このid
の値はonDragOver
やonDragEnd
でactive
、over
のdata
を通してアクセスできるようになります。
したがって、以下のように列のIDをSortableContext
のid
に渡します。
<SortableContext
id={String(props.containerId)}
items={props.items}
strategy={verticalListSortingStrategy}
>
UniqueIdentifier
はstring
かnumber
ですが、SortableContext
のid
はstring
のみなので、String
でcontainerId
をstring
にしています。
CollisionDetection
次に重要となるのは衝突判定の方法です。
ドキュメントによれば、いくつか用意されている衝突判定の方法のうち、角っこ方式がリストの並び替えに適しているようです。
1列の場合はclosestCorners
をそのまま使えばうまくいきます。
しかし、複数列の場合は以下のようにうまくいきません。
隣の列の要素に吸い寄せられ、最も近い列に要素を入れることができません。
これを回避するため、以下のように角っこ方式を利用した独自のアルゴリズムを定義します。
- 最も近い列を取得する
- その列の要素のうち最も近いものを返す
実装は以下の通りです。
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;
};
最も近い列に要素がない場合は、その列自体を返すようにしています。
更新
先ほども少し触れましたが、containerId
がdata
を通して渡されます。
ついでにitems
や要素のitems
内のindex
も渡されるので、これらの情報を使ってステートの更新を行います。
SortableContext
とuseSortable
によって同一列の並び替えのアニメーションは実装されているようなので、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],
]))
}
おまけ
useSortable
はactive
を返してくれるので、opacity: props.itemId === active?.id ? 0 : 1
というように要素のスタイルを設定することでオーバーレイだけを表示することができます。
hidden
やdispley: none
は要素が吹っ飛んでしまってうまくいきませんでした。
Discussion