👯‍♂️

【Next.jsとReactのドラッグ&ドロップ】dnd-kitの解説多めでわかりやすい使い方

2024/04/10に公開

はじめに

参考にさせていただいた記事と同じ状況でreact-beautiful-dndを使用していたのですが、Reactバージョン18以降では調整しないと使用できない&ライブラリの最終更新が2024年時点で3年前なのでこの機会にdnd-kitを使用することにしました。

今回はユーザーがマウスを使用してドラッグ&ドロップする場合(TypeScriptでの記述)について説明します。
https://github.com/atlassian/react-beautiful-dnd
https://zenn.dev/kodaishoituki/articles/0e1c6109ae838e
https://zenn.dev/takuty/articles/2e375cb349bfc4

完成イメージ

右の↕️でドラッグ&ドロップができるようにしています。

解説多めなので読みづらいですが。下記の手順の①〜⑥を順に記述していただければ完成イメージと同じものが作成できると思います。

最後の方で枠をはみ出した場合に下記のように警告を表示する内容も記述しています。

dnd kitとは

React用のドラッグ&ドロップ可能なコンポーネントを実装するためのライブラリです。
ドラッグ&ドロップ機能を持つコンポーネントを作成することができます。
https://dndkit.com/

① インストール

下記コマンドでライブラリをインストールしてください。

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/modifiers

@dnd-kit/core

ドラッグ可能な要素とドロップエリアの設定など、基本的なドラッグ&ドロップ機能を提供しています。

@dnd-kit/sortable

このパッケージは、リストやグリッド内の要素をドラッグ&ドロップで並び替えるための機能を提供しています。
ユーザーが項目の順序をドラッグ&ドロップで変更できるリストやグリッドを作成する場合に使用します。

@dnd-kit/modifiers

ドラッグ操作中に適用できる修正子のコレクションです。例えば、特定の軸に沿ったドラッグのみを許可する、ドラッグエリアの外にはみ出さないように制限するなど、ドラッグ操作に追加の制約やカスタマイズを加えたい場合に使用するそうです。

② コンポーネントの作成

まず、SortComponentというドラッグ&ドロップ機能を持つコンポーネントを作成します。
コンポーネントの名前は自由に決めてください。

sort-component.tsx
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { memo } from 'react';
import styles from './sort-component.module.css';

interface SortComponentProps {
  id: number;
  children: React.ReactNode;
}

export const SortComponent = memo(({ id, children }: SortComponentProps) => {
  const {
    attributes,
    setActivatorNodeRef,
    listeners,
    setNodeRef,
    transform,
    transition,
  } = useSortable({ id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  return (
    <div ref={setNodeRef} style={style} className={styles['sortItem']}>
      <div>{children}</div>
      <span
        ref={setActivatorNodeRef}
        className={styles['dragHandle']}
        {...listeners}
        {...attributes}
      >
        ↕️
      </span>
    </div>
  );
});

SortComponent.displayName = 'SortComponent';
sort-component.module.css
.sortItem {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 2rem;
  border: solid 1px black;
  padding: 0.5em 1em;
}

.dragHandle {
  margin-left: 10px;
  border-radius: 4px;
  background: #dcdcdc;
  padding-bottom: 2px;
  cursor: grab;
}

useSortableフック

並び替え可能な要素のドラッグ&ドロップ機能を管理します。idを引数として受け取り、並び替え操作に必要ないくつかのプロパティと関数を提供しています。

attributes

アクセシビリティ属性やHTML属性を含むオブジェクトで、ドラッグ操作に必要です。

setActivatorNodeRef

ドラッグ操作をアクティベートする要素に関連付けるためのRefセッティング関数です。

listeners

ドラッグイベントリスナーを含むオブジェクトで、ドラッグ操作を制御します。

setNodeRef

コンポーネントのルート要素に関連付けるためのRefセッティング関数です。

transform

ドラッグ操作による要素の移動を表しているオブジェクトです。CSS.Transform.toString(transform)を通じてCSSのtransformプロパティの値に変換されています。

transition

要素の移動にアニメーション効果を加えるために使用されています。

ref={setActivatorNodeRef}

この属性が付与された要素をドラッグすることで、要素全体のドラッグ操作を開始できます。

③ DndContext

DndContextは、ドラッグ&ドロップ操作の全体的なコンテキストや設定を提供するコンテナです。
先ほど作成したドラッグ&ドロップを有効にするコンポーネントをDndContextでラップしてください。これにより、ドラッグ&ドロップイベントが管理されます。

page.tsx
'use client';

import { SortComponent } from '@/components/sort/sort-component';
import {
  DndContext,
} from '@dnd-kit/core';
import {
  SortableContext,
  arrayMove,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useEffect, useState } from 'react';
import styles from './page.module.css';

interface Item {
  id: number;
  content: string;
}

export default function App() {
  const [items, setItems] = useState<Item[]>([]);

  useEffect(() => {
    setItems([
      { id: 1, content: 'Item1' },
      { id: 2, content: 'Item2' },
      { id: 3, content: 'Item3' },
    ]);
  }, []);

  return (
    <main className={styles.main}>
      <DndContext
      >
          {items.map((item) => (
            <SortComponent key={item.id} id={item.id}>
              {/* SortComponentへのchildrenとしてアイテム名を渡しています */}
              <div key={item.id}> {item.content}</div>
            </SortComponent>
          ))}
      </DndContext>
    </main>
  );
}
page.module.css
.main {
  background: #fff;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 6rem;
  min-height: 100vh;
}

クライアントサイドでのみitemsを設定している理由

Next.jsのサーバーサイドレンダリング(SSR)環境では、ページの初回ロード時にはサーバー側でHTMLが生成され、その後クライアント側(ブラウザ)でJavaScriptが実行されます。

この過程で、特に動的なコンテンツやIDを持つ要素については、クライアントサイドで生成される内容がサーバーサイドで生成されたマークアップと一致しない場合、Reactから不一致に関する警告が出されることがあります。

dnd-kitを使用したドラッグ&ドロップ機能では、要素に割り当てられる動的なIDなどがクライアントサイドでのみ正しく生成され、サーバーサイドレンダリング時にはこれらのIDが適切に扱われないことがあります。

これは、サーバー側でレンダリングされた際のDOMとクライアントサイドでのレンダリング後のDOMの間で一致しないことが原因で、
Warning: Prop aria-describedby did not match. Server: "DndDescribedBy-0" Client: "DndDescribedBy-1
のような警告がコンソールに表示されます。
https://github.com/clauderic/dnd-kit/issues/926

このような警告を避け、SSRとクライアントサイドレンダリングの一貫性を保つために、useEffectフックを使用してクライアントサイドでのみ特定の状態(このケースではitems)を設定します。

useEffectはコンポーネントがマウントされた後(クライアントサイドでのレンダリングが完了した後)にのみ実行されるため、この方法によりサーバーサイドレンダリング時にはitemsが設定されず、クライアントサイドでJavaScriptが実行された際に初めてitemsが設定されます。これにより、サーバーとクライアント間でのHTMLの不一致を避けることができます。

ちなみに下記の内容の話です。

useEffect(() => {
  setItems([
    { id: 1, content: 'Item1' },
    { id: 2, content: 'Item2' },
    { id: 3, content: 'Item3' },
  ]);
}, []);

このアプローチは、動的なコンテンツやIDを扱う際にSSRを利用しているReactアプリケーション(特にNext.jsなど)で一般的に推奨される方法の一つです。

④ sensors

ユーザーがアイテムをドラッグするための入力方法(マウス、タッチスクリーン、キーボードなど)を検出し、その入力に基づいてドラッグ&ドロップの操作を開始、管理するための仕組みです。

今回はDndContextコンポーネントに sensorsプロパティを渡すことで、PointerSensorを使用してドラッグ&ドロップ操作を検出しています。

page.tsx
import {
  DndContext,
  PointerSensor,
  useSensor,
  useSensors,
} from '@dnd-kit/core';import { SortComponent } from '../sort-component';

function App() {
  const [items, setItems] = useState<Item[]>([]);

  useEffect(() => {
    setItems([
      { id: 1, content: 'Item1' },
      { id: 2, content: 'Item2' },
      { id: 3, content: 'Item3' },
    ]);
  }, []);

const sensors = useSensors(
      useSensor(PointerSensor),
    );

  return (
    <DndContext
 sensors={sensors}>
      <SortUnit key={id} id={id}>
                </SortUnit>
    </DndContext>
  );
}

PointerSensor

マウスやタッチ操作などのポインティングデバイスによるインタラクションを検出するセンサーです。ユーザーがマウスでアイテムをクリック&ドラッグしたり、タッチスクリーンを使ってアイテムをタップ&ドラッグしたりする動作を検出し、それに応じてドラッグ操作を開始します。

KeyboardSensor

キーボード操作によるドラッグ&ドロップインタラクションを検出するセンサーです。このセンサーは、ユーザーがキーボードの特定のキー(例えば、矢印キーでアイテム選択、スペースバーでドラッグ開始など)を使用してアイテムを操作することを可能にします。

⑤ collisionDetection(衝突検出機能)

DndContext コンポーネントのプロパティの一つで、ユーザーがアイテムをドロップしようとした場所が正しいかどうか、そしてどのドロップゾーンがそのアイテムを受け入れるべきかを判断するための仕組みです。

page.tsx
import {
  DndContext,
  PointerSensor,
  useSensor,
  useSensors,
} from '@dnd-kit/core';import { SortComponent } from '../sort-component';

function App() {
const sensors = useSensors(
      useSensor(PointerSensor),
    );

  return (
    <DndContext
 sensors={sensors}
        collisionDetection={closestCenter}
>
      <SortUnit key={id} id={id}>
                </SortUnit>
    </DndContext>
  );
}

closestCenter

ドラッグしているアイテムと対象のアイテムの中央がドラッグされている要素の中心点と、ドロップされる予定の要素の中心点との間の距離を計算し、交差すると、順番を入れ替えたという判定になります。

closestCorners

ドラッグされた要素の角と、ドロップされる予定の要素の角との間の最も近い距離を計算し、交差すると、順番を入れ替えたという判定になります。

rectIntersection

この戦略では、ドラッグされた要素と他の要素の矩形が交差するかどうかを基に衝突を検出します。交差する面積が最大の要素が選択されます。要素が重なるようなレイアウトで使用することが多いと思います。

カスタム機能

作成したカスタム衝突検出関数を記述することができます。

⑥ イベントハンドラの設定

DndContextonDragEndプロパティを使用して、ドラッグ&ドロップの終了時に実行される関数を設定します。この関数内で、アイテムの移動などの処理を行います。

page.tsx
'use client';

import { SortComponent } from '@/components/sort/sort-component';
import {
  DndContext,
  DragEndEvent,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  SortableContext,
  arrayMove,
} from '@dnd-kit/sortable';
import { useEffect, useState } from 'react';
import styles from './page.module.css';

interface Item {
  id: number;
  content: string;
}

export default function App() {
  const [items, setItems] = useState<Item[]>([]);

  useEffect(() => {
    setItems([
      { id: 1, content: 'Item1' },
      { id: 2, content: 'Item2' },
      { id: 3, content: 'Item3' },
    ]);
  }, []);

  const sensors = useSensors(useSensor(PointerSensor));

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;

    if (over && 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);
      });
    }
  };

  return (
    <main className={styles.main}>
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragEnd={handleDragEnd}
      >
          {items.map((item) => (
            <SortComponent key={item.id} id={item.id}>
              <div key={item.id}> {item.content}</div>
            </SortComponent>
          ))}
      </DndContext>
    </main>
  );
}

onDragEndハンドラー

DndContextコンポーネントに対して設定される、ドラッグ&ドロップ操作を管理するためのコンテキストを提供するコンポーネントです。handleDragEndは、このプロパティに渡される関数の名前であり、開発者が定義します。

handleDragEnd関数

activeoverの情報を使用してどの要素がどの位置に移動したかを判断することで、ユーザーが要素をドラッグ&ドロップで並び替えたときにその並び替えがアプリケーションのUIに反映されています。

handleDragEnd関数は、ドラッグ&ドロップ操作の終了時に自動的に呼び出される関数で、以下の情報を含むイベントオブジェクトを引数として受け取ります。

active

ドラッグされていた要素の識別子や情報のことです。

over

ドロップされた要素の直下にある要素の識別子や情報、ドラッグされた要素がドロップされたターゲットのことです。

その他のハンドラー

onDragStart

ドラッグ操作が開始されたときに呼び出されます。この時点で、ユーザーがドラッグを開始した要素の情報を取得できます。

onDragMove

ドラッグしている要素が移動するたびに呼び出されます。このイベントを通じて、ドラッグ操作中の要素の位置や動きに関するロジックを実装できます。

onDragOver

ドラッグされている要素がドロップ可能な領域(他の要素の上など)に入ったときに呼び出されます。これは、特定のドロップターゲット上での特別なフィードバックを提供するのに便利です。

onDragEnd

既に説明した通り、ドラッグ&ドロップ操作が終了したとき(要素がドロップされたとき)に呼び出されます。最終的なドロップ位置に基づいてアクションを実行できます。

onDragCancel

ドラッグ操作がキャンセルされたとき(例えば、ユーザーがエスケープキーを押した場合など)に呼び出されます。このイベントを使って、ドラッグ操作のキャンセルに伴うクリーンアップや状態のリセットを行うことができます。

SortableContext

並び替え操作の範囲とルールを設定し、要素間でのドラッグ&ドロップによる並び替えを可能にしています。

page.tsx
'use client';

import { SortComponent } from '@/components/sort/sort-component';
import {
  DndContext,
  DragEndEvent,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  SortableContext,
  arrayMove,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useEffect, useState } from 'react';
import styles from './page.module.css';

interface Item {
  id: number;
  content: string;
}

export default function Sort() {
  const [items, setItems] = useState<Item[]>([]);

  useEffect(() => {
    setItems([
      { id: 1, content: 'Item1' },
      { id: 2, content: 'Item2' },
      { id: 3, content: 'Item3' },
    ]);
  }, []);

 return (
    <main className={styles.main}>
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragEnd={handleDragEnd}
      >
        <SortableContext
          items={items.map((item) => item.id)}
          strategy={verticalListSortingStrategy}
        >
          {items.map((item) => (
            <SortComponent key={item.id} id={item.id}>
              <div key={item.id}> {item.content}</div>
            </SortComponent>
          ))}
        </SortableContext>
      </DndContext>
    </main>
  );
}

itemsプロパティ

itemsは、SortableContext内で並び替え可能な要素のリストを表します。このリストは、並び替えを行う各要素の一意の識別子(通常は文字列または数字)の配列です。これらの識別子は、ドラッグ&ドロップ操作中にどの要素が移動されているかを追跡するために使用されます。
並び替え操作が行われると、items配列の順序が変更され、それに応じてUIが更新されることで、ユーザーが要素の新しい順序を視覚的に確認できるようになります。

例えば、itemsが['Item1', 'Item2', 'Item3']の配列だった場合、これらの文字列はそれぞれ並び替えられるリスト内の要素を表します。ユーザーがItem1Item2を入れ替えると、items配列の順序もそれに応じて更新されます。

strategyプロパティ

並び替えられる要素のレイアウトや並び替え操作の振る舞いを定義する関数です。

verticalListSortingStrategy

要素が垂直に並んでいる場合に使用します。

horizontalListSortingStrategy

要素が水平に並んでいる場合に使用します。

rectSortingStrategy

要素がグリッドのように配置されている場合に使用します。

枠をはみ出した場合:ドラッグ操作をキャンセルする、移動範囲を制限する

dnd-kitにドラッグ操作自体を直接キャンセルする、移動範囲を制限する機能がないので実装は難しそうでした。

枠をはみ出した場合:警告を表示する

下記のように警告を表示します。

page.tsx
'use client';
import { SortComponent } from '@/components/sort/sort-component';
import {
  DndContext,
  DragEndEvent,
  DragStartEvent,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  SortableContext,
  arrayMove,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useEffect, useRef, useState } from 'react';
import styles from './page.module.css';

interface Item {
  id: number;
  content: string;
}

export default function Sort() {
  const [items, setItems] = useState<Item[]>([]);
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  const [isDragging, setIsDragging] = useState(false);
  const [warning, setWarning] = useState('');
  // 警告メッセージ表示のためのタイマーIDを保持するステートを追加
  const [warningTimeoutId, setWarningTimeoutId] =
    useState<NodeJS.Timeout | null>(null);

  useEffect(() => {
    setItems([
      { id: 1, content: 'Item1' },
      { id: 2, content: 'Item2' },
      { id: 3, content: 'Item3' },
    ]);
  }, []);

  const sensors = useSensors(useSensor(PointerSensor));

  const handleDragStart = (event: DragStartEvent) => {
    setIsDragging(true);
  };

  const handleDragEnd = (event: DragEndEvent) => {
    setIsDragging(false);
    setWarning('');
    // ドラッグ終了時に既存のタイマーをクリア
    if (warningTimeoutId) {
      clearTimeout(warningTimeoutId);
      setWarningTimeoutId(null);
    }

    const { active, over } = event;

    if (over && 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);
      });
    }
  };

  const handleDragOver = (event: MouseEvent) => {
    if (!isDragging || !wrapperRef.current) return;

    const { clientX, clientY } = event;
    const { left, right, top, bottom } =
      wrapperRef.current.getBoundingClientRect();

    if (
      clientX < left ||
      clientX > right ||
      clientY < top ||
      clientY > bottom
    ) {
      // 警告メッセージ表示前に既存のタイマーをクリア
      if (warningTimeoutId) {
        clearTimeout(warningTimeoutId);
      }
      // ドラッグが範囲外にある場合、少し遅延してから警告メッセージを設定
      const newTimeoutId = setTimeout(() => {
        setWarning('範囲外にドラッグしています!');
      }, 300);
      setWarningTimeoutId(newTimeoutId);
    } else {
      // 範囲内に戻った場合は警告メッセージを即座にクリア
      setWarning('');
      if (warningTimeoutId) {
        clearTimeout(warningTimeoutId);
        setWarningTimeoutId(null);
      }
    }
  };

  useEffect(() => {
    if (isDragging) {
      document.addEventListener('mousemove', handleDragOver);
    } else {
      document.removeEventListener('mousemove', handleDragOver);
    }

    return () => {
      document.removeEventListener('mousemove', handleDragOver);
      // コンポーネントアンマウント時にタイマーをクリア
      if (warningTimeoutId) {
        clearTimeout(warningTimeoutId);
      }
    };
  }, [isDragging, warningTimeoutId]);

  return (
    <main className={styles.main}>
      {warning && <div className={styles.warning}>{warning}</div>}
      <div ref={wrapperRef} className={styles.wrapper}>
        <DndContext
          sensors={sensors}
          onDragStart={handleDragStart}
          onDragEnd={handleDragEnd}
          collisionDetection={closestCenter}
        >
          <SortableContext
            items={items.map((item) => item.id)}
            strategy={verticalListSortingStrategy}
          >
            {items.map((item) => (
              <SortComponent key={item.id} id={item.id}>
                {item.content}
              </SortComponent>
            ))}
          </SortableContext>
        </DndContext>
      </div>
    </main>
  );
}
page.module.css
.main {
  background: #fff;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 6rem;
  min-height: 100vh;
}

.warning {
  color: red;
}

.wrapper {
  width: 250px;
  padding: 16px;
  margin-bottom: 8px;
  border-radius: 4px;
  border: solid 1px black;
}

その他 使用しそうな機能

useDndMonitor

プロバイダーでラップされたコンポーネント内で使用して、DndContextそのコンポーネントに対して発生するさまざまなドラッグ&ドロップイベントを監視できるようです。
https://docs.dndkit.com/api-documentation/context-provider/use-dnd-monitor

終わりに

何かありましたらお気軽にコメント等いただけると助かります。
ここまでお読みいただきありがとうございます🎉

Discussion