Reactにてドラッグアンドドロップ(以下DnDと表記します)を実装するライブラリであるdnd kitを用いて、DnDによって
- カラムどうしの入れ替え
- 同カラム内のアイテムどうしの入れ替え
- 異なるカラム間でのアイテムどうしの入れ替え
が可能なマルチカラムを実装します。
※ ここでいう入れ替えは2つの要素の交換を表します。1つの要素を特定の箇所に挿入する並び替えに関してはググるといくつか記事がヒットするので、そちらにお任せします…。
使用パッケージ
ViteおよびReactのバージョンはnpm create vite@latest
コマンドでインストールされるものを使います。その他は記事執筆時点での最新です。
パッケージ | バージョン |
---|---|
vite | 6.0.5 |
react | 18.3.1 |
recoil | 0.7.7 |
tailwindcss | 3.4.17 |
clsx | 2.1.1 |
@dnd-kit/core | 6.3.1 |
@dnd-kit/sortable | 10.0.0 |
@dnd-kit/utilities | 3.2.2 |
dnd kitのインストール
ルートディレクトリで以下を実行します。今回は要素同士の並び替えを実装したいので、sortable
パッケージもインストールします。
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
(他のパッケージのインストールに関しては本記事の主内容から逸れるため省略させていただきます。)
状態管理
今回は状態管理をRecoilに任せます。勿論どの状態管理ライブラリでも大差ないので、他のものをお使いの方は適宜読み替えてください。
どのカラム内にどのアイテムがあるのかを管理するために以下のatom
を作り、初期状態を適当にdefault
にぶち込みます。
さらに指定したheader
を持つカラムのitems
を返すselectorFamily
も用意しておきます。
export const containerChildrenState = atom<ReadonlyArray<{ header: string; items: string[] }>>({
key: "containerChildrenState",
default: [
{ header: "A", items: ["A1", "A2", "A3", "A4"] },
{ header: "B", items: ["B1", "B2"] },
{ header: "C", items: ["C1", "C2", "C3"] },
],
});
export const columnChildrenSelector = selectorFamily<readonly string[], string>({
key: "columnChildrenSelector",
get:
(header: string) =>
({ get }) =>
get(containerChildrenState).find((column) => column.header === header)?.items ?? [],
});
コンポーネントの作成
とりあえずサクっとItem
、Column
、Container
コンポーネントを作ります。後の実装を考え、これらは(コンテナ・プレゼンテーションパターンにおける)プレゼンテーションコンポーネントとします。
Item
type ItemProps = {
readonly labelText: string;
readonly className?: string;
};
const Item = ({ labelText, className }: ItemProps): JSX.Element => {
return <div className={`w-full flex items-center p-2 ${className}`}>{labelText}</div>;
};
export default Item;
Column
import { type ReactNode } from "react";
type ColumnProps = {
readonly children: ReactNode;
readonly header: string;
};
const Column = ({ children, header: labelText }: ColumnProps): JSX.Element => {
return (
<div className="grow flex flex-col gap-y-3 p-3 bg-slate-200">
<div>{labelText}</div>
<div className="grow flex flex-col gap-y-3 ">{children}</div>
</div>
);
};
export default Column;
Container
import { type ReactNode } from "react";
type ContainerProps = {
readonly children: ReactNode;
};
const Container = ({ children }: ContainerProps): JSX.Element => {
return <div className="flex gap-x-3">{children}</div>;
};
export default Container;
先ほどのatom
を参照して各コンポーネントを配置します。
const App = (): JSX.Element => {
const columns = useRecoilValue(containerChildrenState);
return (
<div className="w-full p-5">
<Container>
{columns.map((column) => (
<Column key={column.header} header={column.header}>
{column.items.map((item) => (
<Item key={item} labelText={item} className={getItemBgColor(item)} />
))}
</Column>
))}
</Container>
</div>
);
};
ひとまず以下の画像のようになりました。
これをベースとして、DnD機能を実装していきます。
DndContext
の追加
dnd kitでは Draggable コンポーネント(ドラッグする要素)と Droppable コンポーネント(ドラッグ要素を受け付ける要素)を実装することでDnDを実現しますが、それらの Context Provider として親にDndContext
を置く必要があります。
加えて、dnd kitで提供される機能はあくまでDnDによる見た目の変化を担うものであり、実際のデータの変化などに関してはDnD終了時に呼び出されるDndContext
のonDragEnd
イベントハンドラに記述する必要があります。が、解説の都合で今はundefined
にしておきます。
const App = (): JSX.Element => {
const columns = useRecoilValue(containerChildrenState);
return (
<div className="w-full p-5">
+ <DndContext>
<Container>
{columns.map((column) => (
<Column key={column.header} header={column.header}>
{column.items.map((item) => (
<Item key={item} labelText={item} className={getItemBgColor(item)} />
))}
</Column>
))}
</Container>
+ </DndContext>
</div>
);
};
useSortable
dnd kitにおいてはuseDraggable
フックにて Draggable コンポーネントを、useDroppable
フックにて Droppable コンポーネントを作成できますが、今回作る入れ替え可能な要素のように Draggable かつ Droppable なコンポーネントにはuseSortable
フックを使います。使い方としては
-
useSortable
フックを使用して必要なプロパティを取得 - 1.の一部を用いて CSS style object を作成
- 1.と2.を返す要素のrootに持たせる
といった具合で、これをItem
とColumn
に適用させます。
しかし同じことを2度書くのも芸がないので、共通化してSortableHolder
というコンポーネントを作ります。(ネーミングェ…)
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { type CSSProperties, type ReactNode } from "react";
type SortableHolderProps = {
readonly children: ReactNode;
readonly id: string;
readonly className?: string;
};
const SortableHolder = ({ children, id, className }: SortableHolderProps): JSX.Element => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition
} = useSortable({ id });
const style: CSSProperties = {
// CSSはWeb APIのインターフェースではなく@dnd-kit/utilitiesパッケージにある変数のほう
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={className}
>
{children}
</div>
);
};
export default SortableHolder;
Item
とColumn
のラッパーを作成
先ほどのSortableHolder
を用いて各コンポーネントのラッパーを作成します。今度は(コンテナ・プレゼンテーションパターンにおける)コンテナコンポーネントとします。
import Item from "./Item";
import SortableHolder from "../sortableHolder";
import { getItemBgColor } from "../../util";
type SortableItemProps = {
readonly labelText: string;
};
const SortableItem = ({ labelText }: SortableItemProps): JSX.Element => {
return (
<SortableHolder id={labelText}>
<Item labelText={labelText} className={getItemBgColor(labelText)} />
</SortableHolder>
);
};
export default SortableItem;
Sortable なコンポーネントはSortableContext
の内部に置く必要があるため、Column
の内側に配置します。items
には内側に入る各コンポーネントを表す id(useSortable
の引数に指定したものと同じ)の配列1を、strategy
にはdnd kit側に用意されている変数を指定します。DnDによる並び替えを目的とする場合はデフォルトのrectSortingStrategy
で大丈夫ですが、今回は並び替えではなく入れ替えなのでrectSwappingStrategy
を指定します。
import { rectSwappingStrategy, SortableContext } from "@dnd-kit/sortable";
import { useRecoilValue } from "recoil";
import Column from "./Column";
import SortableHolder from "../sortableHolder";
import SortableItem from "../item/SortableItem";
import { columnChildrenSelector } from "../../models/containerChildren";
type SortableColumnProps = {
readonly header: string;
};
const SortableColumn = ({ header }: SortableColumnProps): JSX.Element => {
const items = useRecoilValue(columnChildrenSelector(header));
return (
<SortableHolder id={header} className="flex-1">
<Column header={header}>
<SortableContext items={items} strategy={rectSwappingStrategy}>
{items.map((labelText) => (
<SortableItem key={labelText} labelText={labelText} />
))}
</SortableContext>
</Column>
</SortableHolder>
);
};
export default SortableColumn;
Column
も Sortable にしたいのでContainer
の内側にもSortableContext
を配置します。
const App = (): JSX.Element => {
const columns = useRecoilValue(containerChildrenState);
return (
<div className="w-full p-5">
<DndContext>
<Container>
- {columns.map((column) => (
- <Column key={column.header} header={column.header}>
- {column.items.map((item) => (
- <Item key={item} labelText={item} className={getItemBgColor(item)} />
- ))}
- </Column>
- ))}
+ <SortableContext items={columns.map((column) => column.header)} strategy={rectSwappingStrategy}>
+ {columns.map((column) => (
+ <SortableColumn key={column.header} header={column.header} />
+ ))}
+ </SortableContext>
</Container>
</DndContext>
</div>
);
};
onDragEnd
の実装
この状態でDnD自体はできるようになりましたが、先述の通りDndContext
のonDragEnd
を実装するまでは実際のデータの並びは書き換えられないため、DnDが終了するとすぐに開始前の状態に戻ってしまいます。
onDragEnd
イベントハンドラの引数にDragEndEvent
イベントパラメータがあり、active
プロパティにドラッグした要素の id が、over
プロパティにドロップした要素の id が格納されているので、それを使ってデータの入れ替えを実装します。
入れ替え操作が行数を食うので、まずはフックに切り出しておきます。
import { useRecoilCallback } from "recoil";
import { containerChildrenState } from "./containerChildren";
const toSwapped = <T>(arr: readonly T[], index1: number, index2: number): T[] => {
const newArray = [...arr];
[newArray[index1], newArray[index2]] = [newArray[index2], newArray[index1]];
return newArray;
};
export const useContainerChildren = () => {
const swap = useRecoilCallback(({ set, snapshot }) => (id1: string, id2: string) => {
const columns = snapshot.getLoadable(containerChildrenState).getValue();
const headerIndex1 = columns.findIndex((column) => column.header === id1);
const headerIndex2 = columns.findIndex((column) => column.header === id2);
if (headerIndex1 >= 0 && headerIndex2 >= 0) {
// Columnどうしの入れ替え
set(containerChildrenState, (prev) => toSwapped(prev, headerIndex1, headerIndex2));
} else {
// Itemどうしの入れ替え
const parentColumn1 = columns.find((column) => column.items.includes(id1));
const parentColumn2 = columns.find((column) => column.items.includes(id2));
if (parentColumn1 == null || parentColumn2 == null) return;
if (parentColumn1 === parentColumn2) {
// 同Column内
set(containerChildrenState, (prev) =>
prev.map((column) =>
column === parentColumn1
? {
header: column.header,
items: toSwapped(column.items, column.items.indexOf(id1), column.items.indexOf(id2)),
}
: column,
),
);
} else {
// 異Column間
set(containerChildrenState, (prev) =>
prev.map((column) =>
column === parentColumn1
? { header: column.header, items: column.items.map((item) => (item === id1 ? id2 : item)) }
: column === parentColumn2
? {
header: column.header,
items: column.items.map((item) => (item === id2 ? id1 : item)),
}
: column,
),
);
}
}
});
return { swap };
};
これを用いてApp.tsx
を書き換えます。
const App = (): JSX.Element => {
const columns = useRecoilValue(containerChildrenState);
+ const { swap } = useContainerChildren();
+ const handleDragEnd = (e: DragEndEvent): void => {
+ const activeId = e.active.id.toString();
+ const overId = e.over?.id?.toString();
+ if (overId == null) return;
+
+ // 特定の要素のみ入れ替えを禁止する場合はここで早期returnさせます。
+ // 試しにA1とB1の入れ替えを禁止してみます。
+ if ((activeId === "A1" && overId === "B1") || (activeId === "B1" && overId === "A1")) return;
+
+ swap(activeId, overId);
+ };
return (
<div className="w-full p-5">
- <DndContext>
+ <DndContext onDragEnd={handleDragEnd}>
<Container>
<SortableContext items={columns.map((column) => column.header)} strategy={rectSwappingStrategy}>
{columns.map((column) => (
<SortableColumn key={column.header} header={column.header} />
))}
</SortableContext>
</Container>
</DndContext>
</div>
);
};
DragOverlay
の実装
これでDnDによる入れ替えができるようになりましたが、異なるColumn
間のItem
を入れ替える際にドラッグしているItem
がColumn
の外側に出てくれません。(入れ替え自体は成功します。)
どうやらこの状態では Sortable なコンポーネントは直近先祖のSortableContext
から出られないようです。2
そこでDragOverlay
の出番です。DragOverlay
はドラッグする要素を別途レンダーするためのコンポーネントで、これを使用することでDnDの際の見た目をカスタマイズすることができます。(ただし、勿論ながら元々の挙動は消えてしまいます。)
まず、どの要素をDnDしているのかを管理したいのでatom
を追加します。
import { atom } from "recoil";
export const activeIdState = atom<string | null>({ key: "activeIdState", default: null });
DndContext
のonDragStart
イベントハンドラでこのactiveIdState
に対象の id をセットし、DragOverlay
で参照してコンポーネントをレンダーします。この際、 Sortable なコンポーネントをレンダーするといろいろとややこしくなるため、 Sortable ではないものを用います。(DragOverlayColumn
はそのためのただのColumn
のラッパーです。)
なおonDragEnd
でactiveIdState
をリセットするのをお忘れなく。
const App = (): JSX.Element => {
const columns = useRecoilValue(containerChildrenState);
const { swap } = useContainerChildren();
+ const [activeId, setActiveId] = useRecoilState(activeIdState);
+
+ const handleDragStart = (e: DragStartEvent) => {
+ setActiveId(e.active.id.toString());
+ };
const handleDragEnd = (e: DragEndEvent): void => {
+ setActiveId(null);
+
const activeId = e.active.id.toString();
const overId = e.over?.id?.toString();
if (overId == null) return;
// 特定の要素のみ入れ替えを禁止する場合はここで早期returnさせます。
// 試しにA1とB1の入れ替えを禁止してみます。
if ((activeId === "A1" && overId === "B1") || (activeId === "B1" && overId === "A1")) return;
swap(activeId, overId);
};
return (
<div className="w-full p-5">
- <DndContext onDragEnd={handleDragEnd}>
+ <DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Container>
<SortableContext items={columns.map((column) => column.header)} strategy={rectSwappingStrategy}>
{columns.map((column) => (
<SortableColumn key={column.header} header={column.header} />
))}
</SortableContext>
+ <DragOverlay>
+ {activeId != null &&
+ (columns.some((column) => column.header === activeId) ? (
+ <DragOverlayColumn header={activeId} />
+ ) : (
+ <Item labelText={activeId} className={getItemBgColor(activeId)} />
+ ))}
</DragOverlay>
</Container>
</DndContext>
</div>
);
};
これでItem
が親Column
から巣立つことができましたが、分身が発生してしまいます。DragOverlay
は先述の通りあくまでドラッグ中の要素を 別途に レンダーするだけなので、元々の要素が残ってしまいます。
流石にこれではみっともないので、SortableItem
とSortableColumn
を修正してドラッグ対象となるものは表示しないようにします。
+ import clsx from "clsx";
+ import { useRecoilValue } from "recoil";
import Item from "./Item";
import SortableHolder from "../sortableHolder";
+ import { activeIdState } from "../../models/dragTargets";
import { getItemBgColor } from "../../util";
type SortableItemProps = {
readonly labelText: string;
};
const SortableItem = ({ labelText }: SortableItemProps): JSX.Element => {
+ const isDragActive = useRecoilValue(activeIdState) === labelText;
return (
- <SortableHolder id={labelText}>
+ <SortableHolder id={labelText} className={clsx(isDragActive && "opacity-0")}>
<Item labelText={labelText} className={getItemBgColor(labelText)} />
</SortableHolder>
);
};
export default SortableItem;
import { rectSwappingStrategy, SortableContext } from "@dnd-kit/sortable";
+ import { clsx } from "clsx";
import { useRecoilValue } from "recoil";
import Column from "./Column";
import SortableHolder from "../sortableHolder";
import SortableItem from "../item/SortableItem";
import { columnChildrenSelector } from "../../models/containerChildren";
+ import { activeIdState } from "../../models/dragTargets";
type SortableColumnProps = {
readonly header: string;
};
const SortableColumn = ({ header }: SortableColumnProps): JSX.Element => {
const items = useRecoilValue(columnChildrenSelector(header));
+ const isDragActive = useRecoilValue(activeIdState) === header;
return (
- <SortableHolder id={header} className="flex-1">
+ <SortableHolder id={header} className={clsx("flex-1", isDragActive && "opacity-0")}>
<Column header={header}>
<SortableContext items={items} strategy={rectSwappingStrategy}>
{items.map((labelText) => (
<SortableItem key={labelText} labelText={labelText} />
))}
</SortableContext>
</Column>
</SortableHolder>
);
};
export default SortableColumn;
異Column
間のItem
どうしの入れ替えをわかりやすく
現状、Column
どうしの入れ替えと同Column
内のItem
どうしの入れ替えに関してはドラッグ中に入れ替えアニメーションがされるため「入れ替え可能なんだな」とユーザーに思ってもらいやすい一方で、異Column
間のItem
どうしの入れ替えに関しては入れ替えアニメーションが無いために直感的に入れ替え可能に見えないという問題があります。
よって、異Column
間のItem
どうしの入れ替えの際に Droppable の見た目を少し変えることでこれを解決します。
まずはどの Droppable がターゲットになっているかを状態管理したいので、atom
を追加します。さらに異Column
間のItem
どうしの入れ替えのみを検知するために、activeId
とoverId
のそれぞれの親Column
を取得するselector
を作ります。
- import { atom } from "recoil";
+ import { atom, selector } from "recoil";
+ import { containerChildrenState } from "./containerChildren";
export const activeIdState = atom<string | null>({ key: "activeIdState", default: null });
+ export const overIdState = atom<string | null>({ key: "overIdState", default: null });
+ export const activeParentIdSelector = selector<string | null>({
+ key: "activeParentIdSelector",
+ get: ({ get }) => {
+ const activeId = get(activeIdState);
+ return get(containerChildrenState)?.find((column) => column.items.includes(activeId ?? ""))?.header ?? null;
+ },
+});
+ export const overParentIdSelector = selector<string | null>({
+ key: "overParentIdSelector",
+ get: ({ get }) => {
+ const overId = get(overIdState);
+ return get(containerChildrenState)?.find((column) => column.items.includes(overId ?? ""))?.header ?? null;
+ },
+});
次に、追加したoverIdState
を機能させるためにDndContext
のonDragOver
イベントハンドラを実装します。例によってonDragEnd
でリセットするのをお忘れなく。
const App = (): JSX.Element => {
const columns = useRecoilValue(containerChildrenState);
const { swap } = useContainerChildren();
const [activeId, setActiveId] = useRecoilState(activeIdState);
+ const [, setOverId] = useRecoilState(overIdState);
const handleDragStart = (e: DragStartEvent) => {
setActiveId(e.active.id.toString());
};
+ const handleDragOver = (e: DragOverEvent) => {
+ setOverId(e.over?.id?.toString() ?? null);
+ };
const handleDragEnd = (e: DragEndEvent): void => {
setActiveId(null);
+ setOverId(null);
const activeId = e.active.id.toString();
const overId = e.over?.id?.toString();
if (overId == null) return;
// 特定の要素のみ入れ替えを禁止する場合はここで早期returnさせます。
// 試しにA1とB1の入れ替えを禁止してみます。
if ((activeId === "A1" && overId === "B1") || (activeId === "B1" && overId === "A1")) return;
swap(activeId, overId);
};
return (
<div className="w-full p-5">
- <DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
+ <DndContext onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
<Container>
<SortableContext items={columns.map((column) => column.header)} strategy={rectSwappingStrategy}>
{columns.map((column) => (
<SortableColumn key={column.header} header={column.header} />
))}
</SortableContext>
</Container>
<DragOverlay>
{activeId != null &&
(columns.some((column) => column.header === activeId) ? (
<DragOverlayColumn header={activeId} />
) : (
<Item labelText={activeId} className={getItemBgColor(activeId)} />
))}
</DragOverlay>
</DndContext>
</div>
);
};
最後に、SortableItem
を書き換えます。
import { clsx } from "clsx";
import { useRecoilValue } from "recoil";
import Item from "./Item";
import SortableHolder from "../sortableHolder";
- import { activeIdState } from "../../models/dragTargets";
+ import { activeIdState, activeParentIdSelector, overIdState, overParentIdSelector } from "../../models/dragTargets";
import { getItemBgColor } from "../../util";
type SortableItemProps = {
readonly labelText: string;
};
const SortableItem = ({ labelText }: SortableItemProps): JSX.Element => {
const isDragActive = useRecoilValue(activeIdState) === labelText;
+ const isDragOver = useRecoilValue(overIdState) === labelText;
+ const isDraggingBetweenColumns = useRecoilValue(activeParentIdSelector) !== useRecoilValue(overParentIdSelector);
return (
- <SortableHolder id={labelText} className={clsx(isDragActive && "opacity-0")}>
+ <SortableHolder
+ id={labelText}
+ className={clsx(isDragActive && "opacity-0", isDragOver && isDraggingBetweenColumns && "opacity-50")}
+ >
<Item labelText={labelText} className={getItemBgColor(labelText)} />
</SortableHolder>
);
};
export default SortableItem;
これでこのようになりました。
今回はopacity
をいじりましたが、brightness
やbox-shadow
あたりを変化させてもいいかもしれません。
onDragCancel
の実装
これにて完成!…と言いたいところですが、まだ1つ仕事が残っています。
DnDは Escキーを押すことでキャンセル できますが、その際は当然onDragEnd
が呼び出されません。よってこのままだとactiveIdState
やoverIdState
がリセットされず、見た目が崩壊します。
DnDContext
にonDragcancel
イベントハンドラがあるので、これをちょちょいと実装して解決します。
const App = (): JSX.Element => {
const columns = useRecoilValue(containerChildrenState);
const { swap } = useContainerChildren();
const [activeId, setActiveId] = useRecoilState(activeIdState);
const [, setOverId] = useRecoilState(overIdState);
const handleDragStart = (e: DragStartEvent) => {
setActiveId(e.active.id.toString());
};
const handleDragOver = (e: DragOverEvent) => {
setOverId(e.over?.id?.toString() ?? null);
};
+ const handleDragCancel = () => {
+ setActiveId(null);
+ setOverId(null);
+ };
const handleDragEnd = (e: DragEndEvent): void => {
setActiveId(null);
setOverId(null);
const activeId = e.active.id.toString();
const overId = e.over?.id?.toString();
if (overId == null) return;
// 特定の要素のみ入れ替えを禁止する場合はここで早期returnさせます。
// 試しにA1とB1の入れ替えを禁止してみます。
if ((activeId === "A1" && overId === "B1") || (activeId === "B1" && overId === "A1")) return;
swap(activeId, overId);
};
return (
<div className="w-full p-5">
- <DndContext onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
+ <DndContext
+ onDragStart={handleDragStart}
+ onDragOver={handleDragOver}
+ onDragCancel={handleDragCancel}
+ onDragEnd={handleDragEnd}
+ >
<Container>
<SortableContext items={columns.map((column) => column.header)} strategy={rectSwappingStrategy}>
{columns.map((column) => (
<SortableColumn key={column.header} header={column.header} />
))}
</SortableContext>
</Container>
<DragOverlay>
{activeId != null &&
(columns.some((column) => column.header === activeId) ? (
<DragOverlayColumn header={activeId} />
) : (
<Item labelText={activeId} className={getItemBgColor(activeId)} />
))}
</DragOverlay>
</DndContext>
</div>
);
};
これにてようやく完成です。お疲れ様でした!
Github
ここまでのコードをまとめたリポジトリを公開しています。よろしければご覧ください。
未解決課題
異Column
間のItem
どうしの入れ替えの際に、ドロップされたほうがアニメーションせずに瞬間移動する
解決策がわかりませんでした…。ご存じの方は教えていただけると幸いです!!