Next.js + dnd kit でドラッグ&ドロップ実装
はじめに
Next.js 13(TypeScript)でドラッグ&ドロップを実装するにあたり、dnd kitというライブラリを採用しました。 以前はreact-beautiful-dndを使ってましたが、以下の問題が発生しました。
- Reactバージョン18以降でインストールできない.
https://github.com/atlassian/react-beautiful-dnd/issues/2426 - メンテナンス、サポート終了
https://github.com/eltociear/react-beautiful-dnd#️-maintenance--support
今回はdnd-kitを使って、ドラッグ&ドロップでマルチカラムでソート可能なComponentを作っていきます。
概要(dnd kitとは)
- React用の、ドラック&ドロップ可能なコンポーネントを実装するためのライブラリ.
- 軽量でパフォーマンスが高く、拡張可能であり、かつアクセシビリティも担保している.
- 実装する上での観点としては、ドラッグ&ドロップをコンポーネントではなく用意されている hooks で実装するのが大きな特徴.
- 公式でStorybook が公開されており、縦並びのリストや横並びのリスト、グリッドレイアウトのリスト等様々なユースケースに対応する実装を見ることができる.
今回実装したもの
導入
Next.js(typeScript)
Tailwind CSS
dnd kit
npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
@dnd-kit/core
: ドラッグ&ドロップのメイン
@dnd-kit/sortable
: リソースの順番を管理しソート可能にしてくれる
@dnd-kit/utilities
: 実装の上で便利なユーティリティを使うことができる
実際のコード
import React, { useState } from "react";
import {
DndContext,
DragOverlay,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
UniqueIdentifier,
DragStartEvent,
DragOverEvent,
DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import SortableContainer from "./SortableContainer";
import Item from "./Item";
const Contaienr = () => {
// ドラッグ&ドロップでソート可能なリスト
const [items, setItems] = useState<{
[key: string]: string[];
}>({
container1: ["A", "B", "C"],
container2: ["D", "E", "F"],
container3: ["G", "H", "I"],
container4: [],
});
//リストのリソースid(リストの値)
const [activeId, setActiveId] = useState<UniqueIdentifier>();
// ドラッグの開始、移動、終了などにどのような入力を許可するかを決めるprops
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
//各コンテナ取得関数
const findContainer = (id: UniqueIdentifier) => {
if (id in items) {
return id;
}
return Object.keys(items).find((key: string) =>
items[key].includes(id.toString())
);
};
// ドラッグ開始時に発火する関数
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
//ドラッグしたリソースのid
const id = active.id.toString();
setActiveId(id);
};
//ドラッグ可能なアイテムがドロップ可能なコンテナの上に移動時に発火する関数
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
//ドラッグしたリソースのid
const id = active.id.toString();
//ドロップした場所にあったリソースのid
const overId = over?.id;
if (!overId) return;
// ドラッグ、ドロップ時のコンテナ取得
// container1,container2,container3,container4のいずれかを持つ
const activeContainer = findContainer(id);
const overContainer = findContainer(over?.id);
if (
!activeContainer ||
!overContainer ||
activeContainer === overContainer
) {
return;
}
setItems((prev) => {
// 移動元のコンテナの要素配列を取得
const activeItems = prev[activeContainer];
// 移動先のコンテナの要素配列を取得
const overItems = prev[overContainer];
// 配列のインデックス取得
const activeIndex = activeItems.indexOf(id);
const overIndex = overItems.indexOf(overId.toString());
let newIndex;
if (overId in prev) {
// We're at the root droppable of a container
newIndex = overItems.length + 1;
} else {
const isBelowLastItem = over && overIndex === overItems.length - 1;
const modifier = isBelowLastItem ? 1 : 0;
newIndex = overIndex >= 0 ? overIndex + modifier : overItems.length + 1;
}
return {
...prev,
[activeContainer]: [
...prev[activeContainer].filter((item) => item !== active.id),
],
[overContainer]: [
...prev[overContainer].slice(0, newIndex),
items[activeContainer][activeIndex],
...prev[overContainer].slice(newIndex, prev[overContainer].length),
],
};
});
};
// ドラッグ終了時に発火する関数
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
//ドラッグしたリソースのid
const id = active.id.toString();
//ドロップした場所にあったリソースのid
const overId = over?.id;
if (!overId) return;
// ドラッグ、ドロップ時のコンテナ取得
// container1,container2,container3,container4のいずれかを持つ
const activeContainer = findContainer(id);
const overContainer = findContainer(over?.id);
if (
!activeContainer ||
!overContainer ||
activeContainer !== overContainer
) {
return;
}
// 配列のインデックス取得
const activeIndex = items[activeContainer].indexOf(id);
const overIndex = items[overContainer].indexOf(overId.toString());
if (activeIndex !== overIndex) {
setItems((items) => ({
...items,
[overContainer]: arrayMove(
items[overContainer],
activeIndex,
overIndex
),
}));
}
setActiveId(undefined);
};
return (
<div className="flex flex-row mx-auto">
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{/* SortableContainer */}
<SortableContainer
id="container1"
items={items.container1}
label="container1"
/>
<SortableContainer
id="container2"
label="container2"
items={items.container2}
/>
<SortableContainer
id="container3"
label="container3"
items={items.container3}
/>
<SortableContainer
id="container4"
label="container4"
items={items.container4}
/>
{/* DragOverlay */}
<DragOverlay>{activeId ? <Item id={activeId} /> : null}</DragOverlay>
</DndContext>
</div>
);
};
export default Contaienr;
import { useDroppable } from "@dnd-kit/core";
import { rectSortingStrategy, SortableContext } from "@dnd-kit/sortable";
import SortableItem from "./SortableItem";
const SortableContainer = ({
id,
items,
label,
}: {
id: string;
items: string[];
label: string;
}) => {
const { setNodeRef } = useDroppable({
id,
});
return (
<div className="w-[calc(33%-5px)]">
<h3 className="text-xl font-bold text-center">{label}</h3>
<SortableContext id={id} items={items} strategy={rectSortingStrategy}>
<div
ref={setNodeRef}
className="w-full border-2 border-gray-500/75 p-5 mt-2 rounded-md"
>
{items.map((id: string) => (
<SortableItem key={id} id={id} />
))}
</div>
</SortableContext>
</div>
);
};
export default SortableContainer;
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { UniqueIdentifier } from "@dnd-kit/core";
import Item from "./Item";
const SortableItem = ({ id }: { id: UniqueIdentifier }) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
{...attributes}
{...listeners}
>
<Item id={id} />
</div>
);
};
export default SortableItem;
import { UniqueIdentifier } from "@dnd-kit/core";
const Item = ({ id }: { id: UniqueIdentifier }) => {
return (
<div className="w-full h-[50px] flex items-center justify-center my-2.5 border border-black rounded-lg">
{id}
</div>
);
};
export default Item;
DndContext
内がドラッグ&ドロップの管理対象、SortableContext
内がソートの管理対象です。useSortable
で用意した値を埋め込んだDOMが実際にドラッグ&ドロップするアイテムになります。ソート後の配列はonDragEndのDragEndEventから取得できます。
基本的に移動中のアイテムの管理や、順序の管理などはid: UniqueIdentifier
が基準になります。UniqueIdentifierはstringかnumberを許容する@dnd-kit/core提供の型です。
DndContext
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragStart}
onDragStart={handleDragEnd}
onDragOver={handleDragOver}
>
...
</DndContext>
ドラッグ&ドロップを行うためのContextです。SorableContextや各種操作対象はこのDndContextの配下にある必要があります。
Sensors
- sensorsはドラッグの開始、移動、終了などにどのような入力を許可するかを決めるprops.
- useSensors()を使って複数のsensorを登録.
- 各sensorはuseSensor()を使って生成します。カスタムsensorも利用可能.
- PointerSensorでポインター操作を、KeyboardSensorでキーボード操作を許可.
- KeyboardSensorでcoordinateGetterをsortableKeyboardCoordinatesに設定していますが、これは矢印キーを押した際の動きを設定することが出来ます.
- sortableKeyboardCoordinatesを設定することで矢印キー押下時に隣のアイテムと入れ替えしてくれるようになります.
collisionDetection
- collisionDetectionはアイテム同士の衝突検知の位置を決定.
- closestCenterはアイテムDOMの中央を示す.
closestCenter:ドラッグしているアイテムと対象のアイテムの中央が交差すると、順番を入れ替えたという判定になる.
onDragStart , onDragEnd , onDragOver
- onDragStartとonDragEndはそれぞれドラッグの開始と終了時に発火する関数.
- onDragOverはドラッグ可能なアイテムがドロップ可能なコンテナー上へ移動時に発火する関数.
- DragStartEvent,DragEndEvent,DragOverEventという引数を受け取る.
DndContext は
event
という引数を取り、これはactive
とover
の2つの値を取ります.
active
:動かしたコンポーネントの移動開始時の状態
over
:移動終了時の状態
また、配列操作を簡単にするためのユーティリティとしてarrayMove
という関数が用意されています.
SortableContext
<SortableContext
items={items}
strategy={rectSortingStrategy}
>
...
</SortableContext>
-
ソートを行うためのContextです.
-
ソートをする対象はこの
SortableContext
の配下にある必要があります. -
items
はソート対象のリソースの配列です. -
リソースはobjectで表現し、string型でユニークなidをkeyとして持っている必要があります.
-
strategy
はどのようなリストをソート対象にするかを示します. -
rectSortingStrategy
:マルチカラム -
horizontalListSortingStrategy
:横一列 -
verticalListSortingStrategy
:縦一列
useSortable
const SortableItem = ({ id }: { id: UniqueIdentifier }) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
{...attributes}
{...listeners}
>
<Item id={id} />
</div>
);
};
-
useSortable
はドラッグ&ドロップの実装に必要ないくつかのプロパティを返します. - 5つのプロパティを使っていますが、この5つは全て必須です.
-
attributes
listeners
setNodeRef
はDnDさせるコンポーネントに直接渡します. -
transform
transition
はドラッグ&ドロップの移動とアニメーションをCSSで行うためのプロパティです.(styleとしてコンポーネントに渡す必要あり) -
transform
に値を渡す際に@dnd-kit/utilities
のCSS
を使用. -
CSS
:transform
の値はobjectで返ってくるため、object から string に変換する手間を省くためのユーティリティ
Drag Overlay
const [activeId, setActiveId] = useState<UniqueIdentifier>();
return(
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{/* SortableContainer */}
<SortableContainer
id="container1"
items={items.container1}
label="container1"
/>
<SortableContainer
id="container2"
label="container2"
items={items.container2}
/>
<SortableContainer
id="container3"
label="container3"
items={items.container3}
/>
<SortableContainer
id="container4"
label="container4"
items={items.container4}
/>
{/* DragOverlay */}
<DragOverlay>{activeId ? <Item id={activeId} /> : null}</DragOverlay>
</DndContext>
)
const { setNodeRef } = useDroppable({ id });
return(
<SortableContext id={id} items={items} strategy={rectSortingStrategy}>
<div ref={setNodeRef}>
...
</div>
</SortableContext>
)
-
DragOverlay
コンポーネントは、ドラッグ可能なオーバーレイを描画する方法を提供します. - ドラッグ中にコンテナから別のコンテナに移動する必要がある場合は、
DragOverlay
コンポーネントを使用することを強くお勧めします. - これにより、ドラッグ可能なアイテムはドラッグ中に元のコンテナからアンマウントされ、ドラッグ オーバーレイに影響を与えずに別のコンテナにマウントし直すことができます.
-
useDroppable
が正しく動作するためには、ドロップ可能にしようとするHTML要素にsetNodeRefプロパティが付与されている必要があります.
まとめ
Reactのドラッグ&ドロップライブラリ dnd-kitの使い方について解説しました。
公式ドキュメントには他にも色々な機能が説明されていますので是非ご確認ください。
プロダクトのgit hubはこちら
Discussion