🗿

Reactドラッグ&ドロップライブラリ、dnd kitのプリセット、sortableの使い方をユースケースから理解する

2023/12/19に公開

これは CastingONE Advent Calendar 2023 19 日目の記事です。

こんにちは!株式会社 CastingONEでフロントエンドエンジニアとして働いている岡本です。

はじめに

https://zenn.dev/castingone_dev/articles/dndkit20231031
こちらの記事で、dnd kit でドラッグ&ドロップの使い方について執筆したのですが、dnd kit にはプリセットで並び替えが簡単に実装できるSortableが提供されています。このSortableの機能も弊社のアプリケーションで使用するので、社内で勉強会を実施し、どのように実装するのかなどをフロントメンバーで学びました。今回は勉強会でやった内容をもとに、Sortableの使い方をユースケースに沿って解説していきます!

Sortable の基本的な使い方

https://docs.dndkit.com/presets/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からSortableContextuseSortableを使います。

  • 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を渡して、setNodeRefattributeslistenerstransformtransitionisDragging を受け取ります。各プロパティの役割は以下のとおりです。

  • setNodeRef
    DOM 要素への参照を設定するために使用されます。これにより、dnd-kitは、Sortable のドラッグアンドドロップにその要素を正確に追跡することができます。
  • attributes
    アクセシビリティや HTML の属性を適切に設定するために使用されます。
  • listeners
    ドラッグ操作の開始や終了するためのマウスやタッチイベントのリスナーを含んでいます。
  • transform
    アイテムがドラッグ操作中にどのように移動するかを定義する CSS 変換のオブジェクトです。
  • transition
    transformプロパティの設定がどのように時間をかけて適用されるかを定義する CSS プロパティです。
  • isDragging
    アイテムが現在ドラッグされているかを示す boolean です。

SortableContext

上述のSortableItemコンポーネントをSortableContextの中で呼び出します。また、SortableContextDndContextが提供するイベントハンドラーや衝突検出などの基本的な機能に依存しており、それらを活用してソート可能なインターフェースを構築できるようになっているため、DndContext内で呼び出す必要があります。

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

DndContextSortableContextそれぞれ@dnd-kit/core@dnd-kit/sortableから呼び出し、DndContextの中でSortableContextを使用し、<SortableItem>を呼び出します。その中にSortableContextitemsプロパティに、並び替えさせたいアイテムの配列を渡します。 注意点としてitemsに渡すものはユニークな値の配列か、上のコードのようにid を含むオブジェクトの配列でなければなりません。 ユニークな値の配列は例えば、並び替え要素の一意のキーがuniqueKeyだとすると、以下のように設定することができます。

App.ts
- <SortableContext items={items}>
+ <SortableContext items={items.map((item) => item.uniqueKey)}>

SortableContextの子要素には、実際に画面上にレンダリングされるものを置きます。つまり、SortableContextitemsは内部的な状態管理をするための ID のリストであり、子要素はそれらの ID に基づいてユーザーに表示される実際のコンテンツになります。

そして、並び替えの処理はDndContextonDragEndイベントで行います。

並び替えの処理
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(ドラッグしていたアイテムがドロップされた先のアイテム)の情報を取得します。activeoverを使い移動前の index と、移動先の index を取得し、それを@dnd-kit/sortablearrayMoveを使い、アイテムの順序を変更します。これで基本的なSortableは実装できます!

ドラッグのつまみ部分を指定する場合

上述のような通常の実装では、アイテムの任意の場所をドラッグして並び替えることができますが、リストやカードを扱うユーザーインターフェースでは、特定の「つまみ部分」をドラッグしてアイテムを掴むよう指定することがよくあります。特にリストやカードのような要素を並び替える際には、ユーザーがどの部分をドラッグすれば良いのかを明確に示すことがユーザビリティを向上させます。dnd kit のSortableでは、この「つまみ部分」をuseSortableから帰ってくるsetActivatorNodeRefを使って簡単に実装することができます!

SortableItem.tsx
+ 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を設定します。また、attributeslistenersはつまみ部分に移動することによって、ドラッグ操作の回転をその部分に限定しています。実装例を置いておくので、つまみ部分でしかドラッグできないことを確かめてみてください!

ドラッグ中のアイテムの見た目を変更する場合

ドラッグ中のアイテムを際立たせるためにスタイルを変えたい場合があると思います。その時には。DragOverlayという機能を使います。DragOverlayを使用すると、 実際のアイテムは元の位置に残ったまま、ドラッグ中のアイテムの「見た目」だけがカーソルに追従され、オーバーレイとして表示されます。 この機能の利点は、ドラッグ中のアイテムの見た目のカスタマイズできること、また。ドラッグが終了した時にアイテムを元の位置に戻すアニメーションなどの複雑な動作を簡単に実装することができます。以下、わかりやすく大袈裟にDragOverlayを実装した例です。

まず、ドラッグ中の見た目のコンポーネントを作成します。

DraggingItem.tsx
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 にします。

SortableItem.tsx
...

  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コンポーネントを呼び出します。

App.tsx
- 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はドロップ時のアニメーションも指定することができます。

App.tsx
- 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をインポートし、それを指定します。stylesdragOverlayが文字通り、オーバーレイのアイテムのドロップ時のstyleが指定でき、activeはドラッグ元のstyleが指定できます。また、durationeasingも指定することができます。この例ではdurationを 1s に設定しています。このように指定しますと、以下のようになります。

codesandbox での実装例も置いておくので、触って確かめてみてください!

その他のよく使いそうな設定項目

上述した以外で Sortable で指定できるよく使う設定項目を書いていきます。\

Collision detection

これは、要素同士の衝突判定の機能です。dnd kit が提供している衝突判定のアルゴリズムが 4 個あります。

  • Rectangle intersection
    DndContext はデフォルトでこのアルゴリズムを使用します。
    このアルゴリズムは、長方形の 4 つの側面の間に隙間がないことを確認することで動作します。
  • Closest center
    アクティブなドラッグ可能アイテムの境界矩形の中心に最も近いドロップ可能なコンテナを検出します。
  • Closest corners
    アクティブなドラッグ可能アイテムの四隅と各ドロップ可能コンテナの四隅との間の距離を測定して、最も近いものを検出します。
  • Pointer within
    マウスポインタが他のドロップ可能コンテナの境界矩形内に含まれている場合にのみ衝突を検出します。

コードは以下のようになります。

App.tsx
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

自動スクロールを一時的または恒久的に無効にするために使用するものです。

App.tsx
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も用意してくれているので、実装される際はそちらも参考にしてみてください!
弊社ではいっしょに働いてくれるエンジニアを募集中です。社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、気軽にご連絡ください!

https://www.wantedly.com/projects/1130967

https://www.wantedly.com/projects/768663

Discussion