🦧

Next.js + dnd kit でドラッグ&ドロップ実装

2022/11/14に公開

はじめに

Next.js 13(TypeScript)でドラッグ&ドロップを実装するにあたり、dnd kitというライブラリを採用しました。
https://dndkit.com
以前はreact-beautiful-dndを使ってましたが、以下の問題が発生しました。

今回はdnd-kitを使って、ドラッグ&ドロップでマルチカラムでソート可能なComponentを作っていきます。

概要(dnd kitとは)

  • React用の、ドラック&ドロップ可能なコンポーネントを実装するためのライブラリ.
  • 軽量でパフォーマンスが高く、拡張可能であり、かつアクセシビリティも担保している.
  • 実装する上での観点としては、ドラッグ&ドロップをコンポーネントではなく用意されている hooks で実装するのが大きな特徴.
  • 公式でStorybook が公開されており、縦並びのリストや横並びのリスト、グリッドレイアウトのリスト等様々なユースケースに対応する実装を見ることができる.

https://github.com/clauderic/dnd-kit
https://5fc05e08a4a65d0021ae0bf2-goamnjvxea.chromatic.com/?path=/story/core-draggable-hooks-usedraggable--basic-setup

今回実装したもの

https://dndkit-sample.vercel.app/

導入

Next.js(typeScript)

https://nextjs.org/docs#automatic-setup

Tailwind CSS

https://tailwindcss.com/docs/guides/nextjs

dnd kit

npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

@dnd-kit/core: ドラッグ&ドロップのメイン
@dnd-kit/sortable: リソースの順番を管理しソート可能にしてくれる
@dnd-kit/utilities: 実装の上で便利なユーティリティを使うことができる

実際のコード

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

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

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

Item.tsx
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 という引数を取り、これは activeover の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/utilitiesCSSを使用.
  • CSS:transformの値はobjectで返ってくるため、object から string に変換する手間を省くためのユーティリティ

Drag Overlay

Container.tsx
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>
    )
SortableContainer.tsx
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はこちら
https://github.com/kodaishiotsuki/dndkit-sample

Discussion