💨

dnd-kitを使ってリストの並び替えを実装する

2023/07/30に公開

始めに

Reactで並び替えの実装をする際に、いくつかのライブラリを調べてみましたが、dnd-kitのsortableを使うのがよさそうだったので、これを使って並び替えを実装した内容を備忘録としてまとめました。

https://docs.dndkit.com/presets/sortable

サンプルコード

今回検証で書いたコードは以下のCodeSandboxになります。動作や詳細のコードを見たい方はこちらをご参照ください。

シンプルなSortable

まずは一番シンプルな実装をしたいと思います。先にdnd-kitで使うパッケージをインストールしておきます。

  • @dnd-kit/core
  • @dnd-kit/modifiers
  • @dnd-kit/sortable
  • @dnd-kit/utilities

dnd-kitにあるuseSortableで並び替え可能なコンポーネントにすることができるので、まずはコンポーネントの方を実装します。
useSortableで渡すべき引数はidだけで、並び替え対象のユニークなIDを渡します。返り値はたくさんありますが、大きく2つに分かれます。

  • 並び替えを始めるつまみ部分に設定するプロパティ
    • setActivatorNodeRef(つまみ部分のref)
    • attributes(つまみ部分に設定する属性)
    • listeners(つまみ部分に設定するリスナー)
  • DOM全体に対して設定するプロパティ
    • setNodeRef(移動対象となるDOMを指すref)
    • transform(移動情報)
      • このままだとstyleに代入できないため、CSS.Transform.toStringを通す必要がある
    • transition(アニメーションのイージング設定)

これらの情報を使うと以下のように実装できます。追加でちょっとだけ工夫しているところとして、z-indexを指定していないと並び替え中にドラッグしているものが下にくる場合があるのでisDraggingフラグを見てz-indexが設定されるようにしています。

SimpleSortableItem.tsx
import { FC } from "react";
import { clsx } from "clsx";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

// サンプルでは type Item = { id: number; text: string; }
import { Item } from "../../types";
import styles from "./SimpleSortableItem.module.scss";

export type SimpleSortableItemProps = {
  // idを含む型ならなんでも良い
  item: Item;
};

export const SimpleSortableItem: FC<SimpleSortableItemProps> = ({ item }) => {
  const {
    isDragging,
    // 並び替えのつまみ部分に設定するプロパティ
    setActivatorNodeRef,
    attributes,
    listeners,
    // DOM全体に対して設定するプロパティ
    setNodeRef,
    transform,
    transition
  } = useSortable({ id: item.id });

  return (
    // DOM全体
    <div
      ref={setNodeRef}
      className={clsx(styles.ItemWrapper, {
        // ドラッグ中の項目は一番上に表示して欲しいのでz-indexを設定するCSSを当てる
        [styles._active]: isDragging
      })}
      style={{
        transform: CSS.Transform.toString(transform),
        transition
      }}
    >
      <div className={styles.Item}>
        {/* つまみ部分 */}
        <i
          ref={setActivatorNodeRef}
          className="mdi mdi-drag"
          style={{
            cursor: isDragging ? "grabbing" : "grab"
          }}
          {...attributes}
          {...listeners}
        />
        <div className={styles.Item__content}>{JSON.stringify(item)}</div>
      </div>
    </div>
  );
};

これをDndContextSortableContextの中で呼び出すことで実装完了です。一点だけ注意としてcollisionDetectionclosestCenterを入れます。これがないと一番上に項目を移動した際に、リストよりも外に出ると並び変わらず違和感が出てしまいます。

import { FC, useState } from "react";
import { DndContext, closestCenter } from "@dnd-kit/core";
import { arrayMove, SortableContext } from "@dnd-kit/sortable";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";

import { ITEMS } from "../constants";
import { SimpleSortableItem } from "../components/SimpleSortable";

export const SimpleSortablePage: FC = () => {
  const [items, setItems] = useState(ITEMS);

  return (
    <div>
      <DndContext
        collisionDetection={closestCenter}
        onDragEnd={(event) => {
          const { active, over } = event;
          if (over == null || active.id === over.id) {
            return;
          }
          const oldIndex = items.findIndex((item) => item.id === active.id);
          const newIndex = items.findIndex((item) => item.id === over.id);
          const newItems = arrayMove(items, oldIndex, newIndex);
          setItems(newItems);
        }}
      >
        <SortableContext items={items}>
          {items.map((item) => (
            <SimpleSortableItem key={item.id} item={item} />
          ))}
        </SortableContext>
      </DndContext>
    </div>
  );
};

移動方向の制約

オプションとして、DndContextのmodifiersrestrictToVerticalAxisを設定するとドラッグ操作中に縦方向だけの移動に制限をかけることができます。

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

 export const SimpleSortablePage: FC = () => {
   return (
       <DndContext
         collisionDetection={closestCenter}
+        modifiers={restrictToVerticalAxis}
         onDragEnd={(event) => {
           // 実装の中身は省略
         }}
       >
       </DndContext>
   )
 }

制約がない場合

制約がある場合

DragOverlayを使った実装

先ほどまでが最小構成で作った並び替えの実装でした。これでも十分だと思いますが、細かいところでドラッグを外した際にアニメーションされない問題があります。

これを解消するにはドラッグ中の項目を別途描画できるDragOverlayというものを使います。これを使うと他にもリアルタイムで並び替えは行わずに並び替え先だけ表示させるということもできますので、その辺を紹介したいと思います。

DragOverlayに書き換える

全体のイメージがわかるようにまずは呼び出し側を実装します。onDragStart時にactiveIdを取得し、activeIdがあるときはDragOverlayコンポーネントの中でドラッグ中のコンポーネントをrenderします。

DragOverlayを呼び出す
import {
  DndContext,
  DragOverlay
} from "@dnd-kit/core";

export const OverlaySortablePage: FC = () => {
  const [activeId, setActiveId] = useState<number | null>(null);
  const [items, setItems] = useState(ITEMS);

  const activeItem = items.find((item) => item.id === activeId);

  return (
    // 重要ではないコードは一部省略
      <DndContext
        onDragStart={(event) => {
	  // ドラッグ中のIDを保存する
          setActiveId(event.active.id as number);
        }}
	onDragEnd={(event) => {
	  // ドラッグが終わったのでnullにする
	  setActiveId(null)
	  // 並び替え更新処理は省略
	}}
      >
        <SortableContext items={items}>
          {items.map((item) => (
            <OverlaySortableItem
              key={item.id}
              item={item}
            />
          ))}
        </SortableContext>
        <DragOverlay>
	  {/* ドラッグ中のものがある場合に対象のものをDragOverlay内でrenderする */}
          {activeItem && <OverlaySortableSource item={activeItem} />}
        </DragOverlay>
      </DndContext>
  )
}

ここで注意しなければいけないことは、DragOverlayの中ではuseSortablehooksを使ったコンポーネントを含めてはいけないことです。 上のコードの例もSortableContext内とDragOverlay内で呼んでいるコンポーネントが異なっています。しかし基本的にはオーバーレイするものと一覧で描画されるコンテンツは同じであるため、上手く使い回されるように設計する必要があります。

まずは一番元となるオーバーレイでも描画されるコンポーネントを実装します。オーバーレイの方にも並び替えするためのつまみ部分が描画されているため、useSortableにあるプロパティを渡す時と渡さない時の両方に対応できるようにします。

OverlaySortableSource.tsx
import { FC } from "react";
import {
  DraggableAttributes,
  DraggableSyntheticListeners
} from "@dnd-kit/core";

import { Item } from "../../types";
import styles from "./OverlaySortableSource.module.scss";

export type OverlaySortableSourceProps = {
  item: Item;
  // useSortableで受け取るプロパティをつまみ部分に渡すprops
  // undefinedのときはオーバーレイで描画している
  handlerProps?: {
    ref: (element: HTMLElement | null) => void;
    attributes: DraggableAttributes;
    listeners: DraggableSyntheticListeners;
  };
};

export const OverlaySortableSource: FC<OverlaySortableSourceProps> = ({
  item,
  handlerProps
}) => {
  return (
    <div className={styles.SourceWrapper}>
      <div className={styles.Source}>
        <i
          ref={handlerProps?.ref}
          className="mdi mdi-drag"
          style={{
            cursor: handlerProps ? "grab" : "grabbing"
          }}
          {...handlerProps?.attributes}
          {...handlerProps?.listeners}
        />
        <div className={styles.Source__content}>{JSON.stringify(item)}</div>
      </div>
    </div>
  );
};

次にuseSortableを使ったコンポーネントを先ほどのOverlaySortableSourceを使って実装します。

OverlaySortableItem.tsx
import { FC } from "react";
import { clsx } from "clsx";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

import { Item } from "../../types";
import { OverlaySortableSource } from "./OverlaySortableSource";

import styles from "./OverlaySortableItem.module.scss";

export type OverlaySortableItemProps = {
  item: Item;
};

export const OverlaySortableItem: FC<OverlaySortableItemProps> = ({
  item
}) => {
  const {
    isDragging,
    // DOM全体に設定するプロパティ
    setNodeRef,
    transform,
    transition,
    // つまみ部分に設定するプロパティ
    setActivatorNodeRef,
    attributes,
    listeners
  } = useSortable({
    id: item.id
  });

  return (
    <div
      ref={setNodeRef}
      className={styles.Item}
      style={{
        opacity: isDragging ? 0 : undefined,
        transform: CSS.Transform.toString(transform),
        transition
      }}
    >
      <OverlaySortableSource
        item={item}
        handlerProps={{
          ref: setActivatorNodeRef,
          attributes,
          listeners
        }}
      />
    </div>
  );
};

これで以下のような感じでドラッグを離した瞬間に所定の位置にアニメーションで戻っていくようになります。

ドラッグ中は移動先だけ示すように実装

先ほどの実装ではドラッグ中の項目はリストの方ではopacity: 0にして敢えて非表示にしていましたが、これを表示させておくことでリアルタイムで並び替えをしない実装にすることもできます。次はドラッグ中は並び替えをせずに移動先だけを示すような実装を紹介したいと思います。
基本的には先ほど実装したコンポーネントに微調整を加えるだけなので、isPreviewModeというフラグを追加して移動先だけ示すパターンを実装したいと思います。

OverlaySortableSourceの方は変更する必要がないので、まずはOverlaySortableItemの方から以下のような変更を加えます。なお、CSSの実装はcodesandboxの方をご参照ください。

  • 挿入先に横棒を表示させたいと思っているため、差し込み対象の項目の上か下か分かるようactiveIndexとoverIndexから算出してフラグとして渡す
  • ドラッグ中は元の項目は完全に消さずに半透明にする
  • transformによる移動がデフォルトだとドラッグ中なのでその時にtransformが設定されないようにする
  • ドラッグ終了時にアニメーションが発火したいのでanimateLayoutChangesの条件を変える
isPreviewModeを加えたOverlaySortableItem.tsx
 import { FC } from "react";
 import { clsx } from "clsx";
-import { useSortable } from "@dnd-kit/sortable";
+import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
 import { CSS } from "@dnd-kit/utilities";

 import { Item } from "../../types";
 import { OverlaySortableSource } from "./OverlaySortableSource";

 import styles from "./OverlaySortableItem.module.scss";

 export type OverlaySortableItemProps = {
+  isPreviewMode: boolean;
   item: Item;
 };

 export const OverlaySortableItem: FC<OverlaySortableItemProps> = ({
+  isPreviewMode,
   item
 }) => {
   const {
+    isOver,
+    activeIndex,
+    overIndex,
+    isSorting,
     isDragging,
     // DOM全体に設定するプロパティ
     setNodeRef,
     transform,
     transition,
     // つまみ部分に設定するプロパティ
     setActivatorNodeRef,
     attributes,
     listeners
   } = useSortable({
     id: item.id,
+    animateLayoutChanges: isPreviewMode
+      ? ({ isSorting }) => !isSorting
+      : defaultAnimateLayoutChanges
   });

+  const canTransform = !isPreviewMode || !isSorting;

+  /** どっち向きに並び変わるか */
+  const sortDirection =
+    activeIndex > overIndex
+      ? "before"
+      : activeIndex < overIndex
+      ? "after"
+      : null;

+  /** 挿入先を表示するか */
+  const isShowIndicator = isPreviewMode && isOver && sortDirection != null;

+  const opacity = isPreviewMode ? 0.5 : 0;

   return (
     <div
       ref={setNodeRef}
-      className={styles.Item}
+      className={clsx(styles.Item, {
+        [styles._active]: isShowIndicator,
+        [styles._before]: sortDirection === "before",
+        [styles._after]: sortDirection === "after"
+      })}
       style={{
-        opacity: isDragging ? 0 : undefined,
-        transform: CSS.Transform.toString(transform),
+        opacity: isDragging ? opacity : undefined,
+        transform: canTransform ? CSS.Transform.toString(transform) : undefined,
         transition
       }}
     >
       <OverlaySortableSource
         item={item}
         handlerProps={{
           ref: setActivatorNodeRef,
           attributes,
           listeners
         }}
       />
     </div>
   );
 };

ページコンポーネントの方も微調整します。と言ってもDragOverlayのdropAnimation部分を少し変えるだけです。オーバーレイを使っている場合は元となった項目が何故か消えてしまうので、その辺のstyleを打ち消すようにしています。(プレビューモードではないときは最初からopacity: 0で消していたので問題になりませんでした)

isPreviewModeを加えたOverlaySortablePage.tsx
 import {
   DndContext,
   DragOverlay,
   closestCenter,
+  defaultDropAnimationSideEffects
 } from "@dnd-kit/core";

 // 省略

 export const OverlaySortablePage: FC = () => {
   const [isPreviewMode, setIsPreviewMode] = useState(false);

   const [activeId, setActiveId] = useState<number | null>(null);
   const [items, setItems] = useState(ITEMS);

   const activeItem = items.find((item) => item.id === activeId);

   return (
     <div>
       {/* 省略 */}
       <DndContext
         // 省略
       >
         <SortableContext items={items}>
           {items.map((item) => (
             <OverlaySortableItem
               key={item.id}
+              isPreviewMode={isPreviewMode}
               item={item}
             />
           ))}
         </SortableContext>
         <DragOverlay
+          dropAnimation={
+            isPreviewMode
+              ? {
+                  sideEffects: defaultDropAnimationSideEffects({
+                    styles: {}
+                  })
+                }
+              : undefined
+          }
         >
           {activeItem && <OverlaySortableSource item={activeItem} />}
         </DragOverlay>
       </DndContext>
     </div>
   );
 };

これで以下のような動きになりました。

終わりに

以上がdnd-kitを使ったリストの並び替えを実装する方法でした。DragOverlayを使うと割と実装に手間がかかってしまいますが、なくてもそこまで操作感に問題なさそうだったのでシンプルに作る場合は割と気軽に実装できそうだなと思いました。Reactで並び替えの実装をする場合の参考になれたら幸いです。

Discussion