2022.09.13[Tue]

Reactでマルチカラム対応なドラッグアンドドロップUIを作る。dnd-kitの使い方

  • React
  • TypeScript

目次

こんにちは、エンジニアチームのさらだです。

「ドラッグ&ドロップでリストの順番を入れ替える」、直感的で使いやすいUXですね。そのため、CMSなどを開発している時には要望が多くなるべく使い回しが出来るように作っておきたいかと思います。

Reactでドラッグ&ドロップのライブラリといえば、atlassianのreact-beautiful-dndなどが出てくると思います。私もreact-beatiful-dndを採用しようかと思っていたのですが、マルチカラムに対応していないという問題がありました。

そこで今回はマルチカラムに対応したライブラリdnd-kitを使って、ドラッグ&ドロップでソート可能なComponentを作っていこうと思います。

基本的な使い方

Version

今回は以下のVersionを使用しております。

  • @dnd-kit/core v6.0.3
  • @dnd-kit/sortable v7.0.0

インストール

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

ドラッグ&ドロップのメインとなる@dnd-kit/coreと、リソースの順番を管理しソート可能にしてくれる@dnd-kit/sortableをインストールします。typescriptに対応しているので別途型定義ファイルをインストールする必要はありません。

コード

import { FC } from 'react';
import {
  DndContext,
 UniqueIdentifier,
  useSensor,
  useSensors,
  PointerSensor,
  KeyboardSensor,
  DragStartEvent,
  DragEndEvent,
  closestCenter
} from '@dnd-kit/core';
import {
  SortableContext,
  sortableKeyboardCoordinates,
  rectSortingStrategy,
  useSortable
} from '@dnd-kit/sortable';

// リソースの型
type Item = {
  id: UniqueIdentifier,
  ...
};

// ドラッグ&ドロップでソート可能なリスト
export const MyDraggableList: FC<{
  items: Item[];
}> = ({
  items
}) => {
  // ドラッグ&ドロップする時に許可する入力
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  // ドラッグ開始時に発火する関数
  const onDragStart = useCallback((e: DragStartEvent) => {
    const { active } = event;
    // active.id: ドラッグしたリソースのid
  }, []);

  // ドラッグ終了時に発火する関数
  const onDragEnd = useCallback((e: DragEndEvent) => {
    const { active, over } = event;
    // active.id: ドラッグしたリソースのid
    // over.id: ドロップした場所にあったリソースのid
  }, []);

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={onDragStart}
      onDragStart={onDragEnd}
    >
      <SortableContext
        items={items}
        strategy={rectSortingStrategy}
      >
        <ul>
          {items.map((item) => (
            <MyDraggableItem item={item} />
          )}
        </ul>
      </SortableContext>
    </DndContext>
  );
}

// ドラッグ&ドロップ可能なリストアイテム
export const MyDraggableItem: FC<{
  item: Item;
}> = ({
  item
}) => {
  const { setNodeRef, attributes, listeners } = useSortable({ id: item.id });
  
  return (
    <li ref={setNodeRef} {...attributes} {...listeners}>
      {id}
    </li>
  )
}

DndContext内がドラッグ&ドロップの管理対象、SortableContext内がソートの管理対象です。useSortableで用意した値を埋め込んだDOMが実際にドラッグ&ドロップするアイテムになります。ソート後の配列はonDragEndのDragEndEventから取得できます。

基本的に移動中のアイテムの管理や、順序の管理などはid: UniqueIdentifierが基準になります。UniqueIdentifierはstringかnumberを許容する@dnd-kit/core提供の型です。

UniqueIdentifierではnumberを許容していますが、DragStartEvent、DragEndEventに含まれるidはstring固定です。比較を行う際などは取り扱いに注意してください。

DndContext

<DndContext
  sensors={sensors}
  collisionDetection={closestCenter}
  onDragEnd={onDragStart}
  onDragStart={onDragEnd}
>
...
</DndContext>

ドラッグ&ドロップを行うためのContextです。SorableContextや各種操作対象はこのDndContextの配下にある必要があります。

sensorsはドラッグの開始、移動、終了などにどのような入力を許可するかを決めるpropsです。useSensors()を使って複数のsensorを登録していきます。各sensorはuseSensor()を使って生成します。カスタムsensorも利用できますが、今回はbuit-inのsensorを使います。PointerSensorでポインター操作を、KeyboardSensorでキーボード操作を許可します。KeyboardSensorでcoordinateGetterをsortableKeyboardCoordinatesに設定していますが、これは矢印キーを押した際の動きを設定することが出来ます。sortableKeyboardCoordinatesを設定することで矢印キー押下時に隣のアイテムと入れ替えしてくれるようになります。

collisionDetectionはアイテム同士の衝突検知の位置を決定します。built-inのclosestCenterはアイテムDOMの中央を示します。つまるところ、ドラッグしているアイテムと対象のアイテムの中央が交差すると、順番を入れ替えたという判定になります。

onDragStartとonDragEndはそれぞれドラッグの開始と終了時に発火する関数です。それぞれDragStartEventとDragEndEventという引数を受け取ります。このevent内にはドラッグ中のリソースやドロップした場所にあったリソースなどが含まれています。

公式ドキュメント: DndContext

SortableContext

<SortableContext
  items={items}
  strategy={rectSortingStrategy}
>
...
</SortableContext>

ソートを行うためのContextです。ソートをする対象はこのSortableContextの配下にある必要があります。

itemsはソート対象のリソースの配列です。リソースはobjectで表現し、string型でユニークなidをkeyとして持っている必要があります。DragEndEventに含まれるソート済の配列はこのitemsを基に作られます。

strategyはどのようなリストをソート対象にするかを示します。今回はマルチカルムなリストを構成するので、rectSortingStrategyを使用します。横一列、縦一列など決まった用途であればhorizontalListSortingStrategyやverticalListSortingStrategyを使用してください。

公式ドキュメント: SortableContext

useSortable

const { setNodeRef, attributes, listeners } = useSortable({ id: item.id });

ソートを行うためのHookです。ドラッグ&ドロップをしたいDOMに必要な値を用意します。引数でどのアイテムをドラッグしているかの判定するために、idを指定する必要があります。

ドラッグで移動させたいDOMにsetNodeRefをrefとして設定します。

attributesには、roleやaria-roledescriptionなどのwebアクセシビリティが含まれています。これは、ドラッグで移動させたいDOMではなく、ドラッグで掴みたいDOM(以下、ハンドル)に設定します。

listenersもドラッグで掴みたいDOMに設定します。このlistersが設定されているとドラッグすることが出来るようになります。これはsetNodeRefとは別のDOMを設定することが出来るので、ドラッグで掴めるのは子孫DOMで、移動するのはそれを含む親DOMにすることが出来ます。

公式ドキュメント: useSortable

再利用性を高くする

方針

今のままでは、再利用しにくいのでパラメータをブラックボックスにしつつ、どんなリソース、DOMでも利用できるように調整していきます。

どこまで使いやすくするかなどは、程度、手法いろいろあると思いますがこの記事では下記の要件を満たそうと思います。

  • 移動対象のDOMとソート対象の配列に汎用性を持たせる
  • 移動対象のDOMとハンドルを別途設定できるようにする
  • マルチカラム、シングルカラムどちらでも対応できるようにする

移動対象のDOMとソート対象の配列に汎用性を持たせる

まずは、どのようなDOMでも扱えるようにchildrenを使って、MyDraggableList、MyDraggableItemから見た目の責務を減らしていきます。

// ドラッグ&ドロップでソート可能なリスト
export const MyDraggableList: FC<{
  items: Item[];
  children: ReactNode;
}> = ({
  items,
  children
}) => {
  // ドラッグ&ドロップする時に許可する入力
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  // ドラッグ開始時に発火する関数
  const onDragStart = useCallback((e: DragStartEvent) => {
  }, []);

  // ドラッグ終了時に発火する関数
  const onDragEnd = useCallback((e: DragEndEvent) => {
  }, []);

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={onDragStart}
      onDragStart={onDragEnd}
    >
      <SortableContext
        items={items}
        strategy={rectSortingStrategy}
      >
        <ul>
          {children}
        </ul>
      </SortableContext>
    </DndContext>
  );
}

// ドラッグ&ドロップ可能なリストアイテム
export const MyDraggableItem: FC<{
  item: Item;
  children: ReactNode;
}> = ({
  item,
  children
}) => {
  const { setNodeRef, attributes, listeners } = useSortable({ id: item.id });
  
  return (
    <li ref={setNodeRef} {...attributes} {...listeners}>
      {children}
    </li>
  )
}

次に、どのようなリソースでもソート対象にできるように、ジェネリクスを使って型の汎用性を上げます。この時、dnd-kitはstring型のidを必要とするので、interfaceでidを担保してあげます。

// idを含むインターフェイス
export interface HasId {
  id: UniqueIdentifier;
};

// ドラッグ&ドロップでソート可能なリスト
export const MyDraggableList: FC = <T extends HasId,>({
  items,
  children
}: {
  items: T[];
  children: ReactNode;
}) => {
  // ドラッグ&ドロップする時に許可する入力
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );
  // ドラッグ開始時に発火する関数
  const onDragStart = useCallback((e: DragStartEvent) => {
  }, []);

  // ドラッグ終了時に発火する関数
  const onDragEnd = useCallback((e: DragEndEvent) => {
  }, []);

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={onDragStart}
      onDragStart={onDragEnd}
    >
      <SortableContext
        items={items}
        strategy={rectSortingStrategy}
      >
        <ul>
          {children}
        </ul>
      </SortableContext>
    </DndContext>
  );
}

// ドラッグ&ドロップ可能なリストアイテム
export const MyDraggableItem: FC<{
  id: HasId['id'];
  children: ReactNode;
}> = ({
  id,
  children
}) => {
  const { setNodeRef, attributes, listeners } = useSortable({ id });
  
  return (
    <li ref={setNodeRef} {...attributes} {...listeners}>
      {children}
    </li>
  )
}

ついでにonDragStart、onDragEndもpropsで指定できるようにします。

// idを含むインターフェイス
export interface HasId {
  id: UniqueIdentifier;
};

// ドラッグ&ドロップ可能なリストアイテム
export const MyDraggableList: FC = <T extends HasId,>({
  items,
  onDragStart,
  onDragEnd,
  children
}: {
  items: T[];
  onDragStart: ComponentProps<typeof DndContext>['onDragStart'];
  onDragEnd: ComponentProps<typeof DndContext>['onDragEnd'];
  children: ReactNode;
}) => {
  // ドラッグ&ドロップする時に許可する入力
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={onDragStart}
      onDragStart={onDragEnd}
    >
      <SortableContext
        items={items}
        strategy={rectSortingStrategy}
      >
        <ul>
          {children}
        </ul>
      </SortableContext>
    </DndContext>
  );
}

移動対象のDOMとハンドルを別途設定できるようにする

先述の通り、setNodeRefとlistenersを別のDOMに設定すれば別に出来ます。attributesはlistenersと同じDOMに設定します。

// ドラッグ&ドロップ可能なリストアイテム
export const MyDraggableItem: FC<{
  id: HasId['id'];
  children: ReactNode;
}> = ({
  id,
  children
}) => {
  const { setNodeRef } = useSortable({ id });
  
  return (
    <li ref={setNodeRef}>
      {children}
    </li>
  )
}

// MyDraggableItemをドラッグ&ドロップするためのハンドル
export const MyDraggableHandle: FC<{
  id: HasId['id'];
  children: ReactNode;
}> = ({
  id,
  children
}) => {
  const { attributes, listeners } = useSortable({ id });
  
  return (
    <div {...attributes} {...listeners}>
      {children}
    </div>
  )
}

マルチカラム、シングルカラムどちらでも対応できるようにする

strategyを外部から指定できるようにします。縦横一列の場合でもrectSortingStrategyを指定しておけば動きますが、想定しない方向での想定しない動きを抑制するために適切なstrategyを指定します。

// ドラッグ&ドロップ可能なリストアイテム
export const MyDraggableList: FC = <T extends HasId,>({
  items,
  onDragStart,
  onDragEnd,
  layout: 'horizontal' | 'vertical' | 'grid';
  children
}: {
  items: T[];
  onDragStart: ComponentProps<typeof DndContext>['onDragStart'];
  onDragEnd: ComponentProps<typeof DndContext>['onDragEnd'];
  children: ReactNode;
}) => {
  // ドラッグ&ドロップする時に許可する入力
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  // リストの種類
  const strategy = useMemo(() => {
    switch (layout) {
      case 'horizontal':
        return horizontalListSortingStrategy;
      case 'vertical':
        return verticalListSortingStrategy;
      case 'grid':
      default:
        return rectSortingStrategy;
    }
  }, [layout]);

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={onDragStart}
      onDragStart={onDragEnd}
    >
      <SortableContext
        items={items}
        strategy={strategy}
      >
        <ul>
          {children}
        </ul>
      </SortableContext>
    </DndContext>
  );
}

使用例

上記で作ったものを、「ECサイトの管理画面で商品を並び替える」ような場面に適用してみます。

import { MyDraggableList, MuDraggableItem, MyDraggableHandle } from './MyDraggableList';

// リソース:商品の型
type Product = {
  id: string;
  name: string;
  price: number;
};

// ドラッグ&ドロップ可能な商品リスト
export const ProductList: FC => {
  // 一覧表示する商品群
  const [products, setProducts] = useState<Product[]>([]);

  const onDragStart = useCallback((e: DragStartEvent) => {
    ...
  }, []);

  const onDragEnd = useCallback((e: DragEndEvent) => {
    ...
  }, [])
  
  return (
     <MyDraggableList<Product> items={products} onDragStart={onDragStart} onDragEnd={onDragEnd}>
       {products.map((product) => (
         <ProductItem product={product} />
       )}
     </MyDraggableList>
  );
}

// 商品を表示するリストアイテム
const ProductItem: FC<{
  product: Product
}> = ({
  product
}) => (
  <MyDraggableItem id={product.id}>
    <p>{product.name}</p>
    <p>¥ {product.price}</p>
    <MyDraggableHandle id={product.id}>
      <svg ..../>
    </MyDraggableHandle>
  </MyDraggableItem>
);

まとめ

Reactのドラッグ&ドロップ ライブラリdnd-kitの使い方について解説しました。マルチカラムレイアウトでドラッグ&ドロップしたい場合はdnd-kitが選定の候補に上がってくるのではないでしょうか。

他にも色々な機能があるので、公式サイトもご確認ください。

Share

TypeScript チートシートShopifyストアに独自サイトマップを追加する