dnd-kitを使ってリストの並び替えを実装する
始めに
Reactで並び替えの実装をする際に、いくつかのライブラリを調べてみましたが、dnd-kitのsortableを使うのがよさそうだったので、これを使って並び替えを実装した内容を備忘録としてまとめました。
サンプルコード
今回検証で書いたコードは以下のCodeSandboxになります。動作や詳細のコードを見たい方はこちらをご参照ください。
シンプルなSortable
まずは一番シンプルな実装をしたいと思います。先にdnd-kitで使うパッケージをインストールしておきます。
- @dnd-kit/core
- @dnd-kit/modifiers
- @dnd-kit/sortable
- @dnd-kit/utilities
dnd-kitにあるuseSortable
で並び替え可能なコンポーネントにすることができるので、まずはコンポーネントの方を実装します。
useSortable
で渡すべき引数はid
だけで、並び替え対象のユニークなIDを渡します。返り値はたくさんありますが、大きく2つに分かれます。
- 並び替えを始めるつまみ部分に設定するプロパティ
- setActivatorNodeRef(つまみ部分のref)
- attributes(つまみ部分に設定する属性)
- listeners(つまみ部分に設定するリスナー)
- DOM全体に対して設定するプロパティ
- setNodeRef(移動対象となるDOMを指すref)
- transform(移動情報)
- このままだとstyleに代入できないため、
CSS.Transform.toString
を通す必要がある
- このままだとstyleに代入できないため、
- transition(アニメーションのイージング設定)
これらの情報を使うと以下のように実装できます。追加でちょっとだけ工夫しているところとして、z-indexを指定していないと並び替え中にドラッグしているものが下にくる場合があるのでisDragging
フラグを見てz-indexが設定されるようにしています。
import { FC } from "react";
import { clsx } from "clsx";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// サンプルでは type Item = { id: number; text: string; }
import { Item } from "../../types";
import styles from "./SimpleSortableItem.module.scss";
export type SimpleSortableItemProps = {
// idを含む型ならなんでも良い
item: Item;
};
export const SimpleSortableItem: FC<SimpleSortableItemProps> = ({ item }) => {
const {
isDragging,
// 並び替えのつまみ部分に設定するプロパティ
setActivatorNodeRef,
attributes,
listeners,
// DOM全体に対して設定するプロパティ
setNodeRef,
transform,
transition
} = useSortable({ id: item.id });
return (
// DOM全体
<div
ref={setNodeRef}
className={clsx(styles.ItemWrapper, {
// ドラッグ中の項目は一番上に表示して欲しいのでz-indexを設定するCSSを当てる
[styles._active]: isDragging
})}
style={{
transform: CSS.Transform.toString(transform),
transition
}}
>
<div className={styles.Item}>
{/* つまみ部分 */}
<i
ref={setActivatorNodeRef}
className="mdi mdi-drag"
style={{
cursor: isDragging ? "grabbing" : "grab"
}}
{...attributes}
{...listeners}
/>
<div className={styles.Item__content}>{JSON.stringify(item)}</div>
</div>
</div>
);
};
これをDndContext
とSortableContext
の中で呼び出すことで実装完了です。一点だけ注意としてcollisionDetection
にclosestCenter
を入れます。これがないと一番上に項目を移動した際に、リストよりも外に出ると並び変わらず違和感が出てしまいます。
import { FC, useState } from "react";
import { DndContext, closestCenter } from "@dnd-kit/core";
import { arrayMove, SortableContext } from "@dnd-kit/sortable";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { ITEMS } from "../constants";
import { SimpleSortableItem } from "../components/SimpleSortable";
export const SimpleSortablePage: FC = () => {
const [items, setItems] = useState(ITEMS);
return (
<div>
<DndContext
collisionDetection={closestCenter}
onDragEnd={(event) => {
const { active, over } = event;
if (over == null || active.id === over.id) {
return;
}
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
setItems(newItems);
}}
>
<SortableContext items={items}>
{items.map((item) => (
<SimpleSortableItem key={item.id} item={item} />
))}
</SortableContext>
</DndContext>
</div>
);
};
移動方向の制約
オプションとして、DndContextのmodifiers
にrestrictToVerticalAxis
を設定するとドラッグ操作中に縦方向だけの移動に制限をかけることができます。
+import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
export const SimpleSortablePage: FC = () => {
return (
<DndContext
collisionDetection={closestCenter}
+ modifiers={restrictToVerticalAxis}
onDragEnd={(event) => {
// 実装の中身は省略
}}
>
</DndContext>
)
}
制約がない場合
制約がある場合
DragOverlayを使った実装
先ほどまでが最小構成で作った並び替えの実装でした。これでも十分だと思いますが、細かいところでドラッグを外した際にアニメーションされない問題があります。
これを解消するにはドラッグ中の項目を別途描画できるDragOverlay
というものを使います。これを使うと他にもリアルタイムで並び替えは行わずに並び替え先だけ表示させるということもできますので、その辺を紹介したいと思います。
DragOverlayに書き換える
全体のイメージがわかるようにまずは呼び出し側を実装します。onDragStart
時にactiveIdを取得し、activeIdがあるときはDragOverlay
コンポーネントの中でドラッグ中のコンポーネントをrenderします。
import {
DndContext,
DragOverlay
} from "@dnd-kit/core";
export const OverlaySortablePage: FC = () => {
const [activeId, setActiveId] = useState<number | null>(null);
const [items, setItems] = useState(ITEMS);
const activeItem = items.find((item) => item.id === activeId);
return (
// 重要ではないコードは一部省略
<DndContext
onDragStart={(event) => {
// ドラッグ中のIDを保存する
setActiveId(event.active.id as number);
}}
onDragEnd={(event) => {
// ドラッグが終わったのでnullにする
setActiveId(null)
// 並び替え更新処理は省略
}}
>
<SortableContext items={items}>
{items.map((item) => (
<OverlaySortableItem
key={item.id}
item={item}
/>
))}
</SortableContext>
<DragOverlay>
{/* ドラッグ中のものがある場合に対象のものをDragOverlay内でrenderする */}
{activeItem && <OverlaySortableSource item={activeItem} />}
</DragOverlay>
</DndContext>
)
}
ここで注意しなければいけないことは、DragOverlay
の中ではuseSortable
hooksを使ったコンポーネントを含めてはいけないことです。 上のコードの例もSortableContext
内とDragOverlay
内で呼んでいるコンポーネントが異なっています。しかし基本的にはオーバーレイするものと一覧で描画されるコンテンツは同じであるため、上手く使い回されるように設計する必要があります。
まずは一番元となるオーバーレイでも描画されるコンポーネントを実装します。オーバーレイの方にも並び替えするためのつまみ部分が描画されているため、useSortable
にあるプロパティを渡す時と渡さない時の両方に対応できるようにします。
import { FC } from "react";
import {
DraggableAttributes,
DraggableSyntheticListeners
} from "@dnd-kit/core";
import { Item } from "../../types";
import styles from "./OverlaySortableSource.module.scss";
export type OverlaySortableSourceProps = {
item: Item;
// useSortableで受け取るプロパティをつまみ部分に渡すprops
// undefinedのときはオーバーレイで描画している
handlerProps?: {
ref: (element: HTMLElement | null) => void;
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners;
};
};
export const OverlaySortableSource: FC<OverlaySortableSourceProps> = ({
item,
handlerProps
}) => {
return (
<div className={styles.SourceWrapper}>
<div className={styles.Source}>
<i
ref={handlerProps?.ref}
className="mdi mdi-drag"
style={{
cursor: handlerProps ? "grab" : "grabbing"
}}
{...handlerProps?.attributes}
{...handlerProps?.listeners}
/>
<div className={styles.Source__content}>{JSON.stringify(item)}</div>
</div>
</div>
);
};
次にuseSortable
を使ったコンポーネントを先ほどのOverlaySortableSource
を使って実装します。
import { FC } from "react";
import { clsx } from "clsx";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Item } from "../../types";
import { OverlaySortableSource } from "./OverlaySortableSource";
import styles from "./OverlaySortableItem.module.scss";
export type OverlaySortableItemProps = {
item: Item;
};
export const OverlaySortableItem: FC<OverlaySortableItemProps> = ({
item
}) => {
const {
isDragging,
// DOM全体に設定するプロパティ
setNodeRef,
transform,
transition,
// つまみ部分に設定するプロパティ
setActivatorNodeRef,
attributes,
listeners
} = useSortable({
id: item.id
});
return (
<div
ref={setNodeRef}
className={styles.Item}
style={{
opacity: isDragging ? 0 : undefined,
transform: CSS.Transform.toString(transform),
transition
}}
>
<OverlaySortableSource
item={item}
handlerProps={{
ref: setActivatorNodeRef,
attributes,
listeners
}}
/>
</div>
);
};
これで以下のような感じでドラッグを離した瞬間に所定の位置にアニメーションで戻っていくようになります。
ドラッグ中は移動先だけ示すように実装
先ほどの実装ではドラッグ中の項目はリストの方ではopacity: 0
にして敢えて非表示にしていましたが、これを表示させておくことでリアルタイムで並び替えをしない実装にすることもできます。次はドラッグ中は並び替えをせずに移動先だけを示すような実装を紹介したいと思います。
基本的には先ほど実装したコンポーネントに微調整を加えるだけなので、isPreviewMode
というフラグを追加して移動先だけ示すパターンを実装したいと思います。
OverlaySortableSourceの方は変更する必要がないので、まずはOverlaySortableItemの方から以下のような変更を加えます。なお、CSSの実装はcodesandboxの方をご参照ください。
- 挿入先に横棒を表示させたいと思っているため、差し込み対象の項目の上か下か分かるようactiveIndexとoverIndexから算出してフラグとして渡す
- ドラッグ中は元の項目は完全に消さずに半透明にする
- transformによる移動がデフォルトだとドラッグ中なのでその時にtransformが設定されないようにする
- ドラッグ終了時にアニメーションが発火したいので
animateLayoutChanges
の条件を変える
import { FC } from "react";
import { clsx } from "clsx";
-import { useSortable } from "@dnd-kit/sortable";
+import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Item } from "../../types";
import { OverlaySortableSource } from "./OverlaySortableSource";
import styles from "./OverlaySortableItem.module.scss";
export type OverlaySortableItemProps = {
+ isPreviewMode: boolean;
item: Item;
};
export const OverlaySortableItem: FC<OverlaySortableItemProps> = ({
+ isPreviewMode,
item
}) => {
const {
+ isOver,
+ activeIndex,
+ overIndex,
+ isSorting,
isDragging,
// DOM全体に設定するプロパティ
setNodeRef,
transform,
transition,
// つまみ部分に設定するプロパティ
setActivatorNodeRef,
attributes,
listeners
} = useSortable({
id: item.id,
+ animateLayoutChanges: isPreviewMode
+ ? ({ isSorting }) => !isSorting
+ : defaultAnimateLayoutChanges
});
+ const canTransform = !isPreviewMode || !isSorting;
+ /** どっち向きに並び変わるか */
+ const sortDirection =
+ activeIndex > overIndex
+ ? "before"
+ : activeIndex < overIndex
+ ? "after"
+ : null;
+ /** 挿入先を表示するか */
+ const isShowIndicator = isPreviewMode && isOver && sortDirection != null;
+ const opacity = isPreviewMode ? 0.5 : 0;
return (
<div
ref={setNodeRef}
- className={styles.Item}
+ className={clsx(styles.Item, {
+ [styles._active]: isShowIndicator,
+ [styles._before]: sortDirection === "before",
+ [styles._after]: sortDirection === "after"
+ })}
style={{
- opacity: isDragging ? 0 : undefined,
- transform: CSS.Transform.toString(transform),
+ opacity: isDragging ? opacity : undefined,
+ transform: canTransform ? CSS.Transform.toString(transform) : undefined,
transition
}}
>
<OverlaySortableSource
item={item}
handlerProps={{
ref: setActivatorNodeRef,
attributes,
listeners
}}
/>
</div>
);
};
ページコンポーネントの方も微調整します。と言ってもDragOverlayのdropAnimation
部分を少し変えるだけです。オーバーレイを使っている場合は元となった項目が何故か消えてしまうので、その辺のstyleを打ち消すようにしています。(プレビューモードではないときは最初からopacity: 0
で消していたので問題になりませんでした)
import {
DndContext,
DragOverlay,
closestCenter,
+ defaultDropAnimationSideEffects
} from "@dnd-kit/core";
// 省略
export const OverlaySortablePage: FC = () => {
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [activeId, setActiveId] = useState<number | null>(null);
const [items, setItems] = useState(ITEMS);
const activeItem = items.find((item) => item.id === activeId);
return (
<div>
{/* 省略 */}
<DndContext
// 省略
>
<SortableContext items={items}>
{items.map((item) => (
<OverlaySortableItem
key={item.id}
+ isPreviewMode={isPreviewMode}
item={item}
/>
))}
</SortableContext>
<DragOverlay
+ dropAnimation={
+ isPreviewMode
+ ? {
+ sideEffects: defaultDropAnimationSideEffects({
+ styles: {}
+ })
+ }
+ : undefined
+ }
>
{activeItem && <OverlaySortableSource item={activeItem} />}
</DragOverlay>
</DndContext>
</div>
);
};
これで以下のような動きになりました。
終わりに
以上がdnd-kitを使ったリストの並び替えを実装する方法でした。DragOverlay
を使うと割と実装に手間がかかってしまいますが、なくてもそこまで操作感に問題なさそうだったのでシンプルに作る場合は割と気軽に実装できそうだなと思いました。Reactで並び替えの実装をする場合の参考になれたら幸いです。
Discussion