0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

dnd kitでアイテムもカラムもswappableなマルチカラムを実装してみた

Posted at

Reactにてドラッグアンドドロップ(以下DnDと表記します)を実装するライブラリであるdnd kitを用いて、DnDによって

  • カラムどうしの入れ替え
  • 同カラム内のアイテムどうしの入れ替え
  • 異なるカラム間でのアイテムどうしの入れ替え

が可能なマルチカラムを実装します。

dksmc-complete.gif

※ ここでいう入れ替えは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も用意しておきます。

containerChildren.ts
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 ?? [],
});

コンポーネントの作成

とりあえずサクっとItemColumnContainerコンポーネントを作ります。後の実装を考え、これらは(コンテナ・プレゼンテーションパターンにおける)プレゼンテーションコンポーネントとします。

Item
Item.tsx
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
Column.tsx
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
Container.tsx
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を参照して各コンポーネントを配置します。

App.tsx(抜粋)
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>
    );
};

ひとまず以下の画像のようになりました。

dksmc-base.png

これをベースとして、DnD機能を実装していきます。

DndContextの追加

dnd kitでは Draggable コンポーネント(ドラッグする要素)と Droppable コンポーネント(ドラッグ要素を受け付ける要素)を実装することでDnDを実現しますが、それらの Context Provider として親にDndContextを置く必要があります。

加えて、dnd kitで提供される機能はあくまでDnDによる見た目の変化を担うものであり、実際のデータの変化などに関してはDnD終了時に呼び出されるDndContextonDragEndイベントハンドラに記述する必要があります。が、解説の都合で今はundefinedにしておきます。

App.tsx(抜粋)
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フックを使います。使い方としては

  1. useSortableフックを使用して必要なプロパティを取得
  2. 1.の一部を用いて CSS style object を作成
  3. 1.と2.を返す要素のrootに持たせる

といった具合で、これをItemColumnに適用させます。

しかし同じことを2度書くのも芸がないので、共通化してSortableHolderというコンポーネントを作ります。(ネーミングェ…)

SortableHolder.tsx
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;

ItemColumnのラッパーを作成

先ほどのSortableHolderを用いて各コンポーネントのラッパーを作成します。今度は(コンテナ・プレゼンテーションパターンにおける)コンテナコンポーネントとします。

SortableItem.tsx
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には内側に入る各コンポーネントを表す iduseSortableの引数に指定したものと同じ)の配列1を、strategyにはdnd kit側に用意されている変数を指定します。DnDによる並び替えを目的とする場合はデフォルトのrectSortingStrategyで大丈夫ですが、今回は並び替えではなく入れ替えなのでrectSwappingStrategyを指定します。

SortableColumn.tsx
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;

ColumnSortable にしたいのでContainerの内側にもSortableContextを配置します。

App.tsx(抜粋)
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自体はできるようになりましたが、先述の通りDndContextonDragEndを実装するまでは実際のデータの並びは書き換えられないため、DnDが終了するとすぐに開始前の状態に戻ってしまいます。

onDragEndイベントハンドラの引数にDragEndEventイベントパラメータがあり、activeプロパティにドラッグした要素の id が、overプロパティにドロップした要素の id が格納されているので、それを使ってデータの入れ替えを実装します。

入れ替え操作が行数を食うので、まずはフックに切り出しておきます。

useContainerChildren.ts
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を書き換えます。

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を入れ替える際にドラッグしているItemColumnの外側に出てくれません。(入れ替え自体は成功します。)

dksmc-does-not-animate-between-different-columns.gif

どうやらこの状態では Sortable なコンポーネントは直近先祖のSortableContextから出られないようです。2

そこでDragOverlayの出番です。DragOverlayはドラッグする要素を別途レンダーするためのコンポーネントで、これを使用することでDnDの際の見た目をカスタマイズすることができます。(ただし、勿論ながら元々の挙動は消えてしまいます。)

まず、どの要素をDnDしているのかを管理したいのでatomを追加します。

dragTargets.ts
import { atom } from "recoil";

export const activeIdState = atom<string | null>({ key: "activeIdState", default: null });

DndContextonDragStartイベントハンドラでこのactiveIdStateに対象の id をセットし、DragOverlayで参照してコンポーネントをレンダーします。この際、 Sortable なコンポーネントをレンダーするといろいろとややこしくなるため、 Sortable ではないものを用います。(DragOverlayColumnはそのためのただのColumnのラッパーです。)

なおonDragEndactiveIdStateをリセットするのをお忘れなく。

App.tsx(抜粋)
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は先述の通りあくまでドラッグ中の要素を 別途に レンダーするだけなので、元々の要素が残ってしまいます。

流石にこれではみっともないので、SortableItemSortableColumnを修正してドラッグ対象となるものは表示しないようにします。

SortableItem.tsx
+ 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;
SortableColumn.tsx
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どうしの入れ替えのみを検知するために、activeIdoverIdのそれぞれの親Columnを取得するselectorを作ります。

dragTargets.ts
- 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を機能させるためにDndContextonDragOverイベントハンドラを実装します。例によってonDragEndでリセットするのをお忘れなく。

App.tsx(抜粋)
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を書き換えます。

SortableItem.tsx
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;

これでこのようになりました。

dksmc-complete.gif

今回はopacityをいじりましたが、brightnessbox-shadowあたりを変化させてもいいかもしれません。

onDragCancelの実装

これにて完成!…と言いたいところですが、まだ1つ仕事が残っています。

DnDは Escキーを押すことでキャンセル できますが、その際は当然onDragEndが呼び出されません。よってこのままだとactiveIdStateoverIdStateがリセットされず、見た目が崩壊します。

DnDContextonDragcancelイベントハンドラがあるので、これをちょちょいと実装して解決します。

App.tsx(抜粋)
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どうしの入れ替えの際に、ドロップされたほうがアニメーションせずに瞬間移動する

解決策がわかりませんでした…。ご存じの方は教えていただけると幸いです!!

  1. id の順序はコンポーネントの順序と揃っている必要があります。なお id そのものだけではなく「文字列型または数値型のidプロパティを持つオブジェクト」も受け入れるので、コンポーネントに渡すモデルそのものを指定するのも手です。

  2. SortableContextContainerの直下の1つのみにしてしまえば良いと思いきや、ColumnItemが同じSortableContextに属するため(たとえonDragEndで入れ替えを禁じていても)ColumnItemを入れ替えようとするアニメーションが発生してしまいます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?