😸

モダンなドラッグ&ドロップライブラリのdnd-kitを試してみた

2024/11/10に公開

dnd-kit とは?

https://dndkit.com/

Reactのための、軽量、高性能、アクセス可能、拡張可能なドラッグ&ドロップツールキットです

  • 機能豊富:衝突検出、ドラッグハンドル、自動スクロールなどカスタマイズ可能。
  • React対応useDraggableuseDroppable フックが提供され、再構築不要。
  • 多様な用途に対応:リストやグリッド、複数コンテナ、2Dゲームなどに対応。
  • 依存なし&モジュール式:10KB未満で、外部依存がない軽量設計。
  • 多入力対応:ポインタ、マウス、タッチ、キーボード入力に対応。
  • カスタマイズ性:アニメーションやスタイル、衝突検出アルゴリズムを自由に設定可能。
  • アクセシビリティ:キーボード操作、スクリーンリーダー対応。
  • パフォーマンス重視:スムーズなアニメーションを実現。
  • プリセットありdnd-kit/sortable など、簡単に並べ替えを実装可能。

検証用の環境構築

https://github.com/Slowhand0309/nodejs-devcontainer-boilerplate

👆今回もこちらをベースに環境構築していきます。

$ yarn create vite . --template react-ts
Current directory is not empty. Please choose how to proceed:
    Remove existing files and continue
    Cancel operation
❯   Ignore files and continue

.devcontainer/postAttach.sh に以下を追加します。

yarn install
yarn dev

.devcontainer/docker-compose.yml に以下を追加します。

services:
  app:
    build: .
    ...
    working_dir: /usr/src
    ports: # ports 追加
      - "5173:5173"

package.jsonscripts > dev を以下に変更します。

  "scripts": {
    "dev": "vite --host=0.0.0.0",
    // ...
  },

ここまでして、再度VSCodeでdevcontainerを使ってコンテナを起動し直します。

ブラウザで http://localhost:5173/ にアクセスしVite + React が表示されていればOKです。

必要パッケージインストール

yarn add @dnd-kit/core

シンプルな実装

src/App.tsx を以下に修正します。

import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";

function DraggableItem() {
  const { attributes, listeners, setNodeRef, transform } = useDraggable({
    id: "draggable-item",
  });

  return (
    <button
      ref={setNodeRef}
      style={{
        transform: `translate3d(${transform?.x}px, ${transform?.y}px, 0)`,
      }}
      {...listeners}
      {...attributes}
    >
      Drag me!
    </button>
  );
}

function DroppableArea() {
  const { isOver, setNodeRef } = useDroppable({
    id: "droppable-area",
  });

  return (
    <div
      ref={setNodeRef}
      style={{
        backgroundColor: isOver ? "lightgreen" : "lightgray",
        width: 200,
        height: 200,
      }}
    >
      Drop here
    </div>
  );
}

function App() {
  return (
    <DndContext>
      <DroppableArea />
      <DraggableItem />
    </DndContext>
  );
}

export default App;

動作させてみると以下の様になります。

image1.gif

onDragStart / onDragEnd / onDragMove / onDragCancel / onDragOver

DndContext には上記の onDragXXX イベントが定義できるようになっています。

それぞれ発火するタイミングとしては以下になっています。

  • onDragStart
    • Drag操作開始時
  • onDragEnd
    • Drag操作終了時
  • onDragMove
    • Drag中
  • onDragCancel
    • esc等でDragキャンセルした時
  • onDragOver
    • drop対象エリアの上のHoverした時

image2.gif

Modifiers

Modifiersを使用すると、Sensorによって検出された移動座標を動的に変更することができるらしい。以下の様な例が挙げられる。

  • 動きを1軸に制限する
  • ドラッグ可能なノードコンテナの外接矩形に動きを制限する
  • ドラッグ可能なノードのスクロールコンテナ境界矩形に動きを制限する
  • 抵抗を加えるか、動きをクランプする

別途 @dnd-kit/modifiers のインストールが必要になります。

yarn add @dnd-kit/modifiers

Modifiersには公式で用意しているものもあれば、カスタムする事もできます。

以下は公式で用意されているModifiersになります。

restrictToVerticalAxis

縦方向にしかDrag対象を移動できなくなる

import { restrictToVerticalAxis } from '@dnd-kit/modifiers';

function App() {
  return (
    <DndContext modifiers={[restrictToVerticalAxis]}>
      // ...
    </DndContext>
  );
}

image3.gif

restrictToHorizontalAxis

横方向にしかDrag対象を移動できなくなる

import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';

function App() {
  return (
    <DndContext modifiers={[restrictToHorizontalAxis]}>
      // ...
    </DndContext>
  );
}

image4.gif

Sortable

名前の通り、D&Dで並び替えを行う様な場面で使います。簡単なサンプルを作成してみます。

こちらも別途 @dnd-kit/sortable をインストールする必要があります。

yarn add @dnd-kit/sortable

新規に src/SortableItem.tsx を以下内容で作成します。

import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

export const SortableItem = (props: { id: number }) => {
  const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({ id: props.id });

  const style = {
    width: '200px',
    height: '50px',
    color: 'black',
    backgroundColor: 'white',
    border: '1px solid black',
    transform: CSS.Transform.toString(transform),
    transition,
  };

  return (
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      {props.id}
    </div>
  );
};

次に src/App.tsx を以下に修正します。

import {
  DndContext,
  type DragEndEvent,
  KeyboardSensor,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  SortableContext,
  arrayMove,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useState } from 'react';

import { SortableItem } from './SortableItem';

function App() {
  const [items, setItems] = useState([1, 2, 3]);
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );

  return (
    <div style={{ width: '500px', height: '100vh' }}>
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragEnd={handleDragEnd}
      >
        <SortableContext items={items} strategy={verticalListSortingStrategy}>
          {items.map((id) => (
            <SortableItem key={id} id={id} />
          ))}
        </SortableContext>
      </DndContext>
    </div>
  );

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

    if (active.id !== over?.id) {
      setItems((items) => {
        const oldIndex = items.indexOf(active.id as number);
        const newIndex = items.indexOf(over?.id as number);

        return arrayMove(items, oldIndex, newIndex);
      });
    }
  }
}

export default App;

この状態で実行すると、以下の様にD&Dで並び替えができる様なUIが実装できます

image5.gif

DragOverlay

ドラッグ時に別のコンポーネントをOverlay表示してくれます。先ほどのSortableのサンプルをDrag時にOverlay表示する様にしてみます。src/App.tsx を修正します。

// 追加
const OverlayItem = () => {
  return <div style={{ width: '200px', height: '50px', background: 'red' }} />;
};

function App() {
  // 追加
  const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);

  return (
    <div style={{ width: '500px', height: '100vh' }}>
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragStart={handleDragStart} // 追加
        onDragEnd={handleDragEnd}
      >
        ...
        {/* 追加 */}
        <DragOverlay>
          {activeId ? <OverlayItem key={activeId} /> : null}
        </DragOverlay>
      </DndContext>
    </div>
  );
  // 追加
  function handleDragStart(event: DragEndEvent) {
    setActiveId(event.active.id);
  }
}

export default App;

実行してみると以下の様にDrag時に別のコンポーネントがOverlay表示しているのが分かります。

image6.gif

ただ👆の例だとWindowの外にOverloayがはみ出しています。これをはみ出さないようにするModifierがあります。

restrictToWindowEdges

先ほどの DragOverlayrestrictToWindowEdges を設定します。

<DragOverlay modifiers={[restrictToWindowEdges]}>

image7.gif

👆はみ出せない様になっているのが分かるかと思います。

Discussion