Reactドラッグ&ドロップライブラリ、dnd kitのプリセット、sortableの使い方をユースケースから理解する
これは CastingONE Advent Calendar 2023 19 日目の記事です。
こんにちは!株式会社 CastingONEでフロントエンドエンジニアとして働いている岡本です。
はじめに
こちらの記事で、dnd kit でドラッグ&ドロップの使い方について執筆したのですが、dnd kit にはプリセットで並び替えが簡単に実装できるSortable
が提供されています。このSortable
の機能も弊社のアプリケーションで使用するので、社内で勉強会を実施し、どのように実装するのかなどをフロントメンバーで学びました。今回は勉強会でやった内容をもとに、Sortable
の使い方をユースケースに沿って解説していきます!
Sortable の基本的な使い方
Sortable
プリセットはdnd kit
が提供しているドラッグアンドドロップの基本的な機能を拡張子、リストなどのアイテムを並び替えるためのソート操作に特化した API と機能を提供する一連のツールです。
以下のSortable
で必要なパッケージをインストールしてください。
- @dnd-kit/core
- @dnd-kit/sortable
- @dnd-kit/modifiers
- @dnd-kit/utilities
yarn add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @dnd-kit/modifiers
それでは、Sortable の簡単な使い方を以下の codesandbox の実装を見ながら解説していきます。
そして、@dnd-kit/core
からDndContext
、@dnd-kit/sortable
からSortableContext
とuseSortable
を使います。
-
useSortable
並び替え可能な要素にドラッグアンドドロップの機能を付与することができるカスタムフックです。このフックを使うことで、要素がdraggable
になり、その要素の状態や属性(ドラッグ中かどうかなど)を管理することができます。 -
DndContext
DndContext
の説明はこの記事(リンクにするニダ!)で説明しているので、詳細な説明は割愛しますが、ドラッグアンドドロップ操作に関連するイベントハンドラー、センサー、衝突検出アルゴリズムなどの全体的な設定を提供します。 -
SortableContext
並び替え可能な要素のコレクションを管理するプロバイダーです。
useSortable
まずは、useSortable
を使って並び替え可能な要素のコンポーネントを作っていきます。
import { useSortable } from "@dnd-kit/sortable";
import { FC } from "react";
import { CSS } from "@dnd-kit/utilities";
import { Box } from "@mui/material";
type Props = {
id: string;
name: string;
};
export const SortableItem: FC<Props> = ({ id, name }) => {
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({
id,
});
return (
<div
ref={setNodeRef}
{...attributes}
{...listeners}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<Box
sx={{
border: "1px solid black",
p: 2,
display: "flex",
alignItems: "center",
bgcolor: "white",
cursor: isDragging ? "grabbing" : "grab",
}}
>
<Box sx={{ ml: 2 }}>{name}</Box>
</Box>
</div>
);
};
useSortable
を呼び出し、引数にユニークな値のid
を渡して、setNodeRef
、 attributes
、 listeners
、 transform
、 transition
、 isDragging
を受け取ります。各プロパティの役割は以下のとおりです。
-
setNodeRef
DOM 要素への参照を設定するために使用されます。これにより、dnd-kit
は、Sortable のドラッグアンドドロップにその要素を正確に追跡することができます。 -
attributes
アクセシビリティや HTML の属性を適切に設定するために使用されます。 -
listeners
ドラッグ操作の開始や終了するためのマウスやタッチイベントのリスナーを含んでいます。 -
transform
アイテムがドラッグ操作中にどのように移動するかを定義する CSS 変換のオブジェクトです。 -
transition
transform
プロパティの設定がどのように時間をかけて適用されるかを定義する CSS プロパティです。 -
isDragging
アイテムが現在ドラッグされているかを示す boolean です。
SortableContext
上述のSortableItem
コンポーネントをSortableContext
の中で呼び出します。また、SortableContext
はDndContext
が提供するイベントハンドラーや衝突検出などの基本的な機能に依存しており、それらを活用してソート可能なインターフェースを構築できるようになっているため、DndContext
内で呼び出す必要があります。
import { SortableContext, arrayMove } from "@dnd-kit/sortable";
import "./styles.css";
import { DndContext } from "@dnd-kit/core";
import { useState } from "react";
import { SortableItem } from "./components/SortableItem";
import { SortableItemProp } from "./type/sortable";
import { Stack, Box } from "@mui/material";
const INITIAL_ITEMS = [
{ id: crypto.randomUUID(), name: "ソータブルアイテム A" },
{ id: crypto.randomUUID(), name: "ソータブルアイテム B" },
{ id: crypto.randomUUID(), name: "ソータブルアイテム C" },
{ id: crypto.randomUUID(), name: "ソータブルアイテム D" },
{ id: crypto.randomUUID(), name: "ソータブルアイテム E" }
];
export default function App() {
const [items, setItems] = useState<SortableItemProp[]>(INITIAL_ITEMS);
return (
<div className="App">
<Box sx={{ padding: 1 }}>
<Box sx={{ p: 2, border: "2px solid black" }}>
<DndContext
onDragEnd={(event) => {
const { active, over } = event;
if (over == null) {
return;
}
if (active.id !== over.id) {
setItems((items) => {
const oldIndex = items.findIndex(
(item) => item.id === active.id
);
const newIndex = items.findIndex(
(item) => item.id === over.id
);
return arrayMove(items, oldIndex, newIndex);
});
}
}}
>
<SortableContext items={items}>
<Stack spacing={2}>
{items.map((item) => (
<SortableItem id={item.id} name={item.name} key={item.id} />
))}
</Stack>
</SortableContext>
</DndContext>
</Box>
</Box>
</div>
);
}
DndContext
とSortableContext
それぞれ@dnd-kit/core
と@dnd-kit/sortable
から呼び出し、DndContext
の中でSortableContext
を使用し、<SortableItem>
を呼び出します。その中にSortableContext
のitems
プロパティに、並び替えさせたいアイテムの配列を渡します。 注意点としてitems
に渡すものはユニークな値の配列か、上のコードのようにid
を含むオブジェクトの配列でなければなりません。 ユニークな値の配列は例えば、並び替え要素の一意のキーがuniqueKey
だとすると、以下のように設定することができます。
- <SortableContext items={items}>
+ <SortableContext items={items.map((item) => item.uniqueKey)}>
SortableContext
の子要素には、実際に画面上にレンダリングされるものを置きます。つまり、SortableContext
のitems
は内部的な状態管理をするための ID のリストであり、子要素はそれらの ID に基づいてユーザーに表示される実際のコンテンツになります。
そして、並び替えの処理はDndContext
のonDragEnd
イベントで行います。
import { SortableContext, arrayMove } from "@dnd-kit/sortable";
...
<DndContext
onDragEnd={(event) => {
const { active, over } = event;
if (over == null) {
return;
}
if (active.id !== over.id) {
setItems((items) => {
const oldIndex = items.findIndex(
(item) => item.id === active.id
);
const newIndex = items.findIndex(
(item) => item.id === over.id
);
return arrayMove(items, oldIndex, newIndex);
});
}
}}
>
ドラッグ操作が終了するとonDragEnd
が呼び出されます。イベントオブジェクトevent
を引数として受け取り、イベントオブジェクトからactive
(ドラッグされたアイテム)とover
(ドラッグしていたアイテムがドロップされた先のアイテム)の情報を取得します。active
、over
を使い移動前の index と、移動先の index を取得し、それを@dnd-kit/sortable
のarrayMove
を使い、アイテムの順序を変更します。これで基本的なSortable
は実装できます!
ドラッグのつまみ部分を指定する場合
上述のような通常の実装では、アイテムの任意の場所をドラッグして並び替えることができますが、リストやカードを扱うユーザーインターフェースでは、特定の「つまみ部分」をドラッグしてアイテムを掴むよう指定することがよくあります。特にリストやカードのような要素を並び替える際には、ユーザーがどの部分をドラッグすれば良いのかを明確に示すことがユーザビリティを向上させます。dnd kit のSortable
では、この「つまみ部分」をuseSortable
から帰ってくるsetActivatorNodeRef
を使って簡単に実装することができます!
+ import DragHandleIcon from "@mui/icons-material/DragHandle"; // つまみのアイコン
export const SortableItem: FC<Props> = ({ id, name }) => {
const {
...
isDragging,
+ setActivatorNodeRef
} = useSortable({
id
})
return (
<div
ref={setNodeRef}
- {...attributes}
- {...listeners}
>
<Box
sx={{
...
bgcolor: "white",
- cursor: isDragging ? "grabbing" : "grab"
}}
>
{/* つまみ部分 */}
+ <Box
+ ref={setActivatorNodeRef}
+ {...attributes}
+ {...listeners}
+ sx={{
+ display: "flex",
+ alignItems: "center",
+ cursor: isDragging ? "grabbing" : "grab"
+ }}
+ >
+ <DragHandleIcon />
+ </Box>
...
)
}
setActivatorNodeRef
はドラッグ操作を開始する特定の部分に対する参照を設定するために使用します。setActivatorNodeRef
を適用した要素は、ユーザーがその要素をクリックまたはタッチした時にのみドラッグ操作を開始する「ハンドル」として機能します。アイテム全体にはsetNodeRef
を設定し、つまみ部分にはsetActivatorNodeRef
を設定します。また、attributes
とlisteners
はつまみ部分に移動することによって、ドラッグ操作の回転をその部分に限定しています。実装例を置いておくので、つまみ部分でしかドラッグできないことを確かめてみてください!
ドラッグ中のアイテムの見た目を変更する場合
ドラッグ中のアイテムを際立たせるためにスタイルを変えたい場合があると思います。その時には。DragOverlay
という機能を使います。DragOverlay
を使用すると、 実際のアイテムは元の位置に残ったまま、ドラッグ中のアイテムの「見た目」だけがカーソルに追従され、オーバーレイとして表示されます。 この機能の利点は、ドラッグ中のアイテムの見た目のカスタマイズできること、また。ドラッグが終了した時にアイテムを元の位置に戻すアニメーションなどの複雑な動作を簡単に実装することができます。以下、わかりやすく大袈裟にDragOverlay
を実装した例です。
まず、ドラッグ中の見た目のコンポーネントを作成します。
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import { Box } from "@mui/material";
export const DraggingItem = () => {
return (
<Box
sx={{
bgcolor: "lightblue",
width: "80px",
height: "80px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "grabbing"
}}
>
<DragIndicatorIcon />
</Box>
);
};
また、ドラッグ中はオーバーレイ元のアイテムは見せたくないので、opacity
を 0 にします。
...
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition
}}
>
<Box
sx={{
border: "1px solid black",
p: 2,
display: "flex",
alignItems: "center",
bgcolor: "white",
+ opacity: isDragging ? 0 : 1
}}
>
...
</Box>
</div>
);
};
そして、DndContext
内でDragOverlay
を呼び出し、その中で、先ほど作ったDraggingItem
コンポーネントを呼び出します。
- import { DndContext } from "@dnd-kit/core";
+ import { DndContext, DragOverlay } from "@dnd-kit/core";
export default function App() {
return (
<div className="App">
<Box sx={{ padding: 1 }}>
<Box sx={{ p: 2, border: "2px solid black" }}>
<DndContext
onDragEnd={(event) => {
...
}}
>
<SortableContext items={items} strategy={rectSortingStrategy}>
...
</SortableContext>
+ <DragOverlay>
+ <DraggingItem />
+ </DragOverlay>
</DndContext>
</Box>
</Box>
</div>
);
}
このように実装することで画像のような挙動になります。
また、DragOverlay
はドロップ時のアニメーションも指定することができます。
- import { DndContext, DragOverlay } from "@dnd-kit/core";
+ import { DndContext, DragOverlay, defaultDropAnimationSideEffects } from "@dnd-kit/core";
...
export default function App() {
<DragOverlay
+ dropAnimation={{
+ sideEffects: defaultDropAnimationSideEffects({
+ styles: {
+ active: {
+ opacity: "0"
+ },
+ dragOverlay: {
+ opacity: "0",
+ transition: "opacity 1s"
+ }
+ }
+ }),
+ duration: 1000
+ }}
>
<DraggingItem />
</DragOverlay>
}
dropAnimation
プロパティのsideEffects
に@dnd-kit/core
にあるdefaultDropAnimationSideEffects
をインポートし、それを指定します。styles
のdragOverlay
が文字通り、オーバーレイのアイテムのドロップ時のstyle
が指定でき、active
はドラッグ元のstyle
が指定できます。また、duration
やeasing
も指定することができます。この例ではduration
を 1s に設定しています。このように指定しますと、以下のようになります。
codesandbox での実装例も置いておくので、触って確かめてみてください!
その他のよく使いそうな設定項目
上述した以外で Sortable
で指定できるよく使う設定項目を書いていきます。\
Collision detection
これは、要素同士の衝突判定の機能です。dnd kit が提供している衝突判定のアルゴリズムが 4 個あります。
-
Rectangle intersection
DndContext
はデフォルトでこのアルゴリズムを使用します。
このアルゴリズムは、長方形の 4 つの側面の間に隙間がないことを確認することで動作します。 -
Closest center
アクティブなドラッグ可能アイテムの境界矩形の中心に最も近いドロップ可能なコンテナを検出します。 -
Closest corners
アクティブなドラッグ可能アイテムの四隅と各ドロップ可能コンテナの四隅との間の距離を測定して、最も近いものを検出します。 -
Pointer within
マウスポインタが他のドロップ可能コンテナの境界矩形内に含まれている場合にのみ衝突を検出します。
コードは以下のようになります。
import {
DndContext,
+ pointerWithin
} from "@dnd-kit/core";
export default function App() {
return (
<div className="App">
<Box
sx={{
p: 2
}}
>
<DndContext
+ collisionDetection={pointerWithin}
>
...
);
}
また、衝突判定のアルゴリズムは独自に作ることもできます!
Modifiers
これは、移動座標を動的に変更するための機能です。こちらも dnd kit が提供しているmodifies
が 6 個あります。
-
restrictToHorizontalAxis
水平軸のみに動きを制限 -
restrictToVerticalAxis
垂直軸のみに動きを制限 -
restrictToWindowEdges
ドラッグ可能なアイテムがウィンドウ(ブラウザのビューポート)の端に到達したときに、それ以上進まないように制限 -
restrictToParentElement
ドラッグ可能なアイテムの親要素内での動きを制限 -
restrictToFirstScrollableAncestor
ドラッグ可能なアイテムの最初のスクロール可能な祖先内での動きを制限 -
createSnapModifier
与えられたグリッドサイズにスナップする修飾子を作成する関数
コードは以下のようになります。
import { DndContext } from "@dnd-kit/core";
+ import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
...省略
export default function App() {
return (
<div className="App">
<Box
sx={{
p: 2
}}
>
<DndContext
+ modifiers={[restrictToHorizontalAxis]}
onDragEnd={(event) => {
...
}}
>
</DndContext>
... 省略
autoScroll
自動スクロールを一時的または恒久的に無効にするために使用するものです。
export default function App() {
return (
<div className="App">
<Box
sx={{
p: 2
}}
>
<DndContext
+ autoScroll
onDragEnd={(event) => {
const { over } = event;
if (over == null) {
return;
}
setDropCount((x) => x + 1);
}}
>
</DndContext>
... 省略
これらの設定の動きは以下の codesandbox で色々設定できるようにしたので触って確かめてみてください!
終わりに
以上が、dnd kit のプリセットであるSortable
の基本的な使い方でした。dnd kit
はこのSortable
の基本的な使い方から応用的なものまでを確認することができるStorybookも用意してくれているので、実装される際はそちらも参考にしてみてください!
弊社ではいっしょに働いてくれるエンジニアを募集中です。社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、気軽にご連絡ください!
Discussion