🎄

@dnd-kitでSortableTreeコンポーネントを実装する!!

2023/09/15に公開

実装する!と言いつつ公式のサンプルがとてもわかりやすかったので、写経しつつ一部は自分の都合のいい実装にしました。

何故やりたかったか

プロダクト内でソート可能なツリー構造の一覧を実装していました。現状react-sortable-treeというライブラリを使用しているのですが、React17以上に対応していないために置き換える必要が出てきました。forkしてReact17↑でも動くように内部で利用しているライブラリを変更したパッケージもあったのですが、そちらも残念ながらこれ以上のメンテナンスは確約しませんと作者から言われていました。

そのため色々と検討した結果、sortable treeのライブラリに置き換えるのではなく、@dnd-kitというDnDライブラリを使って独自で実装することにしました。

@dnd-kitとは

https://dndkit.com/

@dnd-kitは、ドラッグ&ドロップのインタラクションを簡単に構築するためのモダンなライブラリです。Reactベースであり、ドラッグ&ドロップ機能を簡単に組み込むために必要なツールとコンポーネントを提供します。リストの並べ替えやアイテムのドラッグなど、ユーザーインタラクションを高度にカスタマイズしたい場合に非常に便利です。

詳細な使い方や設定については、公式ドキュメントを参照してください。とてもわかりやすくて英語苦手な僕も読みやすかったです。

何故@dnd-kit?

比較対象としてreact-dndが有名かと思います。こちらは元々react-sortable-treeに使われていたライブラリで、コミュニティも成熟していて、柔軟性が高く複雑なカスタマイズをできます。しかし、今回そこまで複雑な用途は必要ではなく、公式でsortableをサポートしていた@dnd-kitの方が早く簡単に使いこなせると思い選択しませんでした。さらに以下の2つの点が決め手になりました。

公式のExampleにSortable Treeがあった

言わずもがなこれが一番の理由です。

https://master--5fc05e08a4a65d0021ae0bf2.chromatic.com/?path=/story/examples-tree-sortable--all-features

@dnd-kitが公式に提供しているライブラリの中に@dnd-kit/sortableがあり、ツリー構造ではないD&Dが可能でソートされたリストを簡単に構築できるライブラリがあります。sortable treeは内部でツリー構造のデータをフラット化してレンダリングしているため、@dnd-kit/sortableの機能を使うとほとんどの部分を実装できました。

アクセシビリティに強い

@dnd-kitがすごいのはアクセシビリティをかなり意識していて、サンプルの時点でツリー構造の移動操作をキーボードだけで簡単に行えるようになっていました。今回実装する画面ではアクセシビリティを考慮する必要はなかったのですが、将来的に必要になった場合にサンプルがあるのとないのでは実装負荷がかなり変わると思います。

実装を見てみる

Sortable Treeのコンポーネント自体は公式から提供されていないので、プロジェクトで利用するために独自に実装する必要がありました。しかしほとんどの機能はExamplesのもので補えたので、ほぼほぼ公式のコードを見ながら同じように自分の言葉で実装することにしました。
できたものはこちらです。(まだできるかどうか、調査の段階だったので実際に使う際にはもう少し調整が必要です。)

Completed Sortable Tree

公式のExampleのSortable Tree
独自に実装したSortable Tree

ここでは重要な部分を抜粋して説明します。

@dnd-kit/sortableの利用

@dnd-kitはSortableなリストを表示するためのライブラリ@dnd-kit/sortableを提供しています。これを利用することで、リストの並べ替えやドラッグ&ドロップを簡単に実装できます。

<DndContext>
  <SortableContext items={items}>
    {items.map((id) => (
      <SortableItem key={id} id={id} />
    ))}
  </SortableContext>
</DndContext>

DndContextはドラッグ&ドロップのコンテキストを提供するコンポーネントです。このコンポーネント内で他のコンポーネントやhooksを利用することで、ドラッグ&ドロップの機能を実装できます。

SortableContextは、Sortableなリストを表示するためのコンテキストを提供するコンポーネントです。コンポーネントのitemsのpropsとしてidの配列を渡して、内部でuseSortable hooksに同じidを渡すことで、そのコンポーネントがドラッグ&ドロップの対象になり、Sortableという名の通り、ドラッグ&ドロップで並べ替えができるようになります。

const {
  isDragging,
  setDroppableNodeRef,
  setDraggableNodeRef,
  transform,
  transition,
  attributes,
  listeners,
} = useSortable({
  id: item.id,
  animateLayoutChanges,
});

ツリー構造の実現

@dnd-kit/sortableは1次元のリストにしか対応していません。ツリー構造を表現するために、まずツリー構造のデータをフラットな配列に変換して渡す必要があります。

変換のための型のデータと関数を用意します。

src/components/SortableTree/types.tsから一部抜粋
// ツリー構造の型
export type TreeItem = {
  id: UniqueIdentifier;
  name?: string;
  children: TreeItem[];
};

// フラットな配列に変換した後の型
export type FlattenedItem = TreeItem & {
  parentId: UniqueIdentifier | null; // UniqueIdentifierは@dnd-kit/coreの型
  depth: number;
};
src/components/SortableTree/utilities.tsから一部抜粋
const flatten = (
  items: TreeItem[],
  parentId: UniqueIdentifier | null = null
  depth = 0
) => {
  return items.reduce((acc, item): FlattenedItem[] => {
    return [
      ...acc,
      { ...item, parentId, depth },
      ...flatten(item.children, item.id, depth + 1),
    ];
  }, [] as FlattenedItem[]);
};

再帰関数を使って、ツリー構造のデータをフラットな配列に変換しています。親のIDを持たせることで、後でツリー構造に戻すことも可能にしています。

独自の実装では、デフォルトの状態が全て閉じている状態にしたかったので、expandedIdsというstateを用意して、開いているノードのIDを保持するようにしています。フラットな配列に変換した後に、親のIDがnullのものと、expandedIdsに親のIDが含まれている物のみを表示するようにしています。

src/components/SortableTree/hooks/useSortableTree.tsから一部抜粋
const [expandedIds, setExpandedIds] = useState<UniqueIdentifier[]>([]);

// ツリーをフラット化する。
const flattenedItems = useMemo(() => {
  const flattenedTree = flatten(items);

  // 1階層目のアイテムと親アイテムがexpandedIdsに含まれるアイテムのみを表示する。
  return flattenedTree.filter(
    (item) => item.parentId === null || expandedIds.includes(item.parentId)
  );
}, [expandedIds, items]);

const sortedIds = useMemo(
  () => flattenedItems.map((item) => item.id),
  [flattenedItems]
);

// expandedにない場合は追加、含まれている場合は小アイテムのidも削除する。
const handleToggleExpand = useCallback(
  (id: UniqueIdentifier) => {
    setExpandedIds((expandedIds) => {
      if (expandedIds.includes(id)) {
        const childrenIds = getChildrenIds(items, id);
        return expandedIds.filter(
          (expandedId) => expandedId !== id && !childrenIds.includes(expandedId)
        );
      } else {
        return [...new Set([...expandedIds, id])];
      }
    });
  },
  [items]
);

const handleDragStart = useCallback(
  ({ active: { id: activeId } }: DragStartEvent) => {
    setActiveId(activeId)
    setOverId(activeId)

    const childrenIds = getChildrenIds(flattenedItems, activeId)
    // ドラッグ中のアイテムとその子アイテムを閉じる
    setExpandedIds((expandedIds) =>
      expandedIds.filter(
        (expandedId) =>
          expandedId !== activeId && !childrenIds.includes(expandedId),
      ),
    )
  },
  [flattenedItems],
)
const handleDragMove = useCallback(({ delta }: DragMoveEvent) => {
  setOffsetLeft(delta.x)
}, [])

const handleDragOver = useCallback(({ over }: DragOverEvent) => {
  setOverId(over?.id ?? null)
}, [])

画面上で階層構造を表現するのは簡単で、フラット化した各要素のdepthを見て、padding-leftでずらしています。

DragOverlayの利用

ドラッグしている間はどこに落ちるかがわかりやすいように半透明な状態で表示してあげます。(公式のサンプルでは、落ちる場所に線が表示されるようになっていますが、どちらもスタイルの当て方の問題で実装自体はほぼ同じでした。)

src/components/SortableTree/index.tsxから一部抜粋
<DndContext
  onDragStart={handleDragStart}
  onDragMove={handleDragMove}
  onDragOver={handleDragOver}
  onDragEnd={handleDragEnd}
  onDragCancel={handleDragCancel}
>

DndContextには各イベント発生時のコールバック関数を渡すことができます。
移動先のアイテムを取得するために、ドラッグしているアイテムのIDと、マウスオーバーしているアイテムのIDを取得します。そして、掴んだ時点からx軸にいくらマウスを移動させたかも一緒に取得します。

この時特に何もしないと↓の動画のように、アイテムそのものをドラッグします。

Sortable Tree with no overlay

この状態だと、ドラッグ元やドロップ先がわかりにくいと思います。さらにツリー全体の高さが変わってしまうため、スクロールの挙動がおかしくなったり、仮想リストを使用している場合はコンポーネントがアンマウントされてしまいます。
そこでDragOverlayを使います。

src/components/SortableTree/index.tsxから一部抜粋
{/* ドラッグ中に要素がどこに落ちるかを表示するため */}
{createPortal(
  <DragOverlay dropAnimation={dropAnimationConfig}>
    {activeId && activeItem && (
      <SortableTreeItem
        item={activeItem}
        depth={activeItem.depth}
        indentionWidth={indentionWidth}
        clone
        childrenCount={getChildrenIds(items, activeId).length}
      />
    )}
  </DragOverlay>,
  document.body,
)}

DragOverlayを使うと、アイテムそのものをドラッグするのではなく、アイテムのコピーをドラッグすることができます。これによりドラッグ中のアイテムの位置は元にツリー上に表示されるため、上記の問題が解決できます。通常DragOverlayは記述された場所にレンダリングされますが、createPortalを使うことでbodyの直下にレンダリングすることができます。公式でも多くのサンプルがcreatePortalを使っているので、これは公式の推奨されている方法だと思います。

ドロップ先の計算

ドラッグ中のアイテムをどこに落とすかを計算します。単純な1次元のリストであれば@dnd-kit/srotableの機能で十分ですが、ツリー構造なので別の階層に移動できる必要があります。そのためドラッグ開始時点から左右にどれくらい移動したかを取得し、その移動量を元に移動先の階層を計算します。

src/components/SortableTree/utilities.ts
// 移動先の親アイテムの情報を取得する
export const getProjection = (
  items: FlattenedItem[],
  activeId: UniqueIdentifier,
  overId: UniqueIdentifier,
  dragOffset: number,
  indentationWidth: number,
) => {
  const overItemIndex = items.findIndex(({ id }) => id === overId)
  const activeItemIndex = items.findIndex(({ id }) => id === activeId)
  const activeItem = items[activeItemIndex]

  // 「ドラッグ中のアイテム」を「マウスオーバーしているアイテム」の位置に移動する
  const newItems = arrayMove(items, activeItemIndex, overItemIndex)
  const previousItem = newItems[overItemIndex - 1]
  const nextItem = newItems[overItemIndex + 1]

  const dragDepth = Math.round(dragOffset / indentationWidth)
  const projectedDepth = activeItem.depth + dragDepth

  const depth = getDepth(projectedDepth, previousItem, nextItem)

  const parentId = getParentId(depth, overItemIndex, previousItem, newItems)
  return { depth, parentId }
}

ドラッグ中のアイテムのdepthと、マウスオーバーしているアイテムのdepthを比較して、マウスオーバーしているアイテムのdepthよりも1つ上の階層に移動するか、下の階層に移動するかを判断します。その後、移動先の階層のdepthと、移動先の階層の前後のアイテムの情報を元に、移動先の親アイテムのIDを計算します。

最後にドロップ時の処理としてonDragEndでドラッグ元のアイテムを削除し、ドラッグ先のアイテムを追加します。

src/components/SortableTree/hooks/useSortableTree.tsから一部抜粋
const handleDragEnd = useCallback(
  ({ active, over }: DragEndEvent) => {
    resetState()

    if (projected && over) {
      const { parentId, depth } = projected
      const clonedItems: FlattenedItem[] = flatten(items)

      const overIndex = clonedItems.findIndex((item) => item.id === over.id)
      const activeIndex = clonedItems.findIndex(
        (item) => item.id === active.id,
      )
      const activeTreeItem = clonedItems[activeIndex]
      clonedItems[activeIndex] = {
        ...activeTreeItem,
        parentId: parentId,
        depth: depth,
      }

      const sortedItems = arrayMove(clonedItems, activeIndex, overIndex)
      const newItems = buildTree(sortedItems)

      setItems(newItems)
      // expandedIdsに親アイテムが含まれていない場合は追加する
      if (parentId) {
        setExpandedIds((expandedIds) =>
          expandedIds.includes(parentId)
            ? expandedIds
            : [...expandedIds, parentId],
        )
      }
    }
  },
  [items, projected, resetState],
)

サンプルと変えた部分としては、ドラッグ先の親アイテムが閉じている場合は親アイテムを開くようにしました。この辺りは好みで変えるといいと思います。
これでツリー構造の並べ替えができるようになりました。

まとめ

@dnd-kitを使うことで、ソート可能なツリー構造の実装が簡単にできました。公式のサンプルがとてもわかりやすかったので、写経しつつ一部は自分の都合のいい実装にしました。今回は第一歩としてほとんど写経しかしていませんが、実際に利用する際はツリーのアイテムはPropsとして渡せるようにしたり、スタイルをカスタムできるようにしたいと思っています。

GitHubで編集を提案

Discussion