🌲

dnd-kitで並び替え可能なツリー構造のアイテムを実装する

2024/12/09に公開

この記事は、ラクスパートナーズ AdventCalendar 2024の9日目の記事です!
https://qiita.com/advent-calendar/2024/rakus-partners

はじめに

プロダクト内で木構造のリストの並び替えを実装する機会があり、めちゃめちゃ勉強になったので、どんな感じで実装を進めたのかを共有しようと思います。

今回ドラッグ&ドロップを実現するライブラリとしてhello-pangea/dndclauderic/dnd-kitを検討しました。最終的にdnd-kitを採用した理由としては、公式ドキュメントが充実していることに加え、木構造リストのサンプルが提供されていたためです。そのため、今回の実装ではdnd-kitを選択しました。

dnd-kitとは?

https://dndkit.com/

dnd-kitは、React向けの軽量でモジュール化されたドラッグ&ドロップツールキットです。
主な特徴は以下です。

  1. 多機能で便利
  • ドラッグ位置の自動調整や衝突検出をサポート
  • ドラッグハンドル、オーバーレイ、スクロール制御など、多くの機能を搭載
  1. Reactに最適化
  • useDraggableuseDroppableなどのフックを活用
  • アプリの構造を大きく変更せずに導入可能
  1. 幅広い用途に対応
  • リストやグリッドの並び替え、複数コンテナ、入れ子構造、仮想リストなど、多彩なユースケースに利用可能
  1. 軽量&依存なし
  • 外部依存ライブラリがなく、約10kbと非常に軽量
  • Reactの標準的なステート管理とコンテキストを活用
  1. 多様な操作方法をサポート
  • マウス、タッチ、キーボード、ポインターで操作可能
  1. 自由自在なカスタマイズ
  • アニメーション、動作、スタイル、キー操作など、すべてカスタマイズ可能
  • 独自のセンサーや衝突検出ロジックも作成可能
  1. アクセシビリティ対応
  • キーボード操作やスクリーンリーダー対応を標準装備
  • aria属性やライブリージョンで視覚以外の操作にも配慮
  1. スムーズな動作
  • パフォーマンスを重視した設計で、滑らかなアニメーションを実現
  1. 便利なプリセット
  • 並び替えUIを簡単に作れる@dnd-kit/sortableなどのツールも利用可能。

実装していく

今回実装した内容のソースコードは、こちらのGitHubリポジトリで公開しています。

dnd-kitの導入

まずはdnd-kitで今回必要なライブラリをインストールしていきます。

pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

コンポーネントの実装

すでに公式で提供されているサンプルが非常に充実しており、かなり参考にさせていただきました。
このサンプルを基に、自分のプロジェクトに合わせてカスタマイズした内容を以下で解説します。
また、こちらの記事にも多くのヒントをいただきました。大変感謝しています!🙇‍♂️

SortableTreeコンポーネントを実装

SortableTreeコンポーネントは、ツリー構造のドラッグ&ドロップ機能を提供するコンポーネントです。ロジック部分はカスタムフックuseSortableTreeに切り出しており、画面描画に専念した設計となっています。
dnd-kitDndContextSortableContextを利用して、ドラッグ操作と並び替え機能を簡潔に実現しています。

  • DndContext
    ドラッグアンドドロップをするためのコンテキストです。
    SortableContextDragOverlayなどdnd-kitを使った操作を行うためには、DndContextプロバイダ内にネストされる必要があります。

  • SortableContext
    ソートを行うためのコンテキストです。
    ソートをする対象はこのSortableContextにネストされる必要があります。
    今回は並び替えを実施したいので、dnd-kit/sortableからimportして使用します。

/components/sortableTree/index.tsx
type SortableTreeProps = {
  defaultItems: TreeItem[]
}

const SortableTree = ({ defaultItems }: SortableTreeProps) => {
  const {
    flattenedItems,
    sortedIds,
    getDndContextProps,
    getSortableTreeItemProps
  } = useSortableTree({ defaultItems })

  const sensors = useSensors(useSensor(PointerSensor))

  return (
    <DndContext {...getDndContextProps(sensors, measuring)}>
      <SortableContext items={sortedIds}>
        {flattenedItems.map((item) => (
          <SortableTreeItem key={item.id} {...getSortableTreeItemProps(item, INDENTION_WIDTH)} />
        ))}
      </SortableContext>
    </DndContext>
  )
}

export default SortableTree

カスタムフックでロジックを整理

/components/sortableTree/useSortableTree.tsから大事な部分だけ抜粋して解説します。

1. ツリー構造のフラット化と再構築
ツリー構造のままでは並び替えの実装ができなかったので、flattenTreeを用いてフラットなリストに変換しています。

// ※ flatten と flattenTree は utils/index.ts に記載しています。
const flatten = (
  items: TreeItems,
  parentId: UniqueIdentifier | null = null,
  depth = 0
): FlattenedItems => {
  const result: FlattenedItems = []

  items.forEach((item, index) => {
    const currentItem: FlattenedItem = {
      ...item,
      depth,
      parentId,
      index
    }
    result.push(currentItem)

    const children = flatten(item.children, item.id, depth + 1)
    result.push(...children)
  })

  return result
}

export const flattenTree = (items: TreeItems): FlattenedItems => {
  return flatten(items)
}


  const flattenedItems = useMemo(() => {
    const flattenedTree = flattenTree(items)
    const expandedItems = flattenedTree.filter(
      ({ parentId }) => parentId === null || expandedIds.includes(parentId)
    )

    return expandedItems
  }, [items, expandedIds])

2. ドラッグ操作の開始と終了
ドラッグ開始時には、ドラッグ中のアイテムを記録し、カーソルを変更します。また、子アイテムの展開状態をリセットすることで、ドラッグ中のアイテムをわかりやすくしています。

  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)
        )
      )

      document.body.style.setProperty('cursor', 'grabbing')
    },
    [flattenedItems]
  )

ドラッグ終了時には、アイテムの深さや親子関係を再計算し、ツリー構造を更新します。

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

    if (projected && over) {
      const { depth, parentId } = projected

      const clonedItems: FlattenedItems = JSON.parse(JSON.stringify(flattenTree(items)))
      const overIndex = clonedItems.findIndex(({ id }) => id === over.id)
      const activeIndex = clonedItems.findIndex(({ id }) => id === active.id)

      if (activeIndex === -1 || overIndex === -1) return

      const activeItem = clonedItems[activeIndex]
      const childrenIds = getChildrenIds(clonedItems, activeItem.id)

      clonedItems[activeIndex] = { ...activeItem, depth, parentId }

      clonedItems.forEach((item) => {
        if (childrenIds.includes(item.id)) {
          item.depth = depth + 1
        }
      })

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

      setExpandedIds((expandedIds) => {
        const newExpandedIds = expandedIds.filter((id) => !childrenIds.includes(id))
        if (parentId) {
          newExpandedIds.push(parentId)
        }

        return Array.from(new Set(newExpandedIds))
      })

      setItems(newItems)
    }
  }

3. ツリーの展開と折りたたみ
特定のアイテムの展開状態を変更します。

  const handleToggleExpand = useCallback(
    (id: UniqueIdentifier) => {
      setExpandedIds((currentExpandedIds) => {
        const isExpanded = currentExpandedIds.includes(id)

        if (isExpanded) {
          const childrenIds = getChildrenIds(items, id)
          return currentExpandedIds.filter(
            (expandedId) => expandedId !== id && !childrenIds.includes(expandedId)
          )
        } else {
          return [...new Set([...currentExpandedIds, id])]
        }
      })
    },
    [items]
  )

4. ドラッグ&ドロップ用のプロパティ生成
ドラッグ&ドロップ用に必要な情報をDndContextやツリーアイテムに渡します。

  const getDndContextProps = (
    sensors: SensorDescriptor<SensorOptions>[],
    measuring: {
      droppable: {
        strategy: MeasuringStrategy
      }
    }
  ) => {
    return {
      sensors,
      measuring,
      collisionDetection: closestCenter,
      onDragStart: handleDragStart,
      onDragMove: handleDragMove,
      onDragOver: handleDragOver,
      onDragEnd: handleDragEnd,
      onDragCancel: handleDragCancel
    }
  }

  const getSortableTreeItemProps = (
    item: FlattenedItem,
    indentationWidth = DEFAULT_INDENTATION_WIDTH
  ) => {
    return {
      item,
      depth: item.id === activeId && projected ? projected.depth : item.depth,
      onExpand: item.children.length > 0 ? () => handleToggleExpand(item.id) : undefined,
      expanded: item.children.length > 0 && expandedIds.includes(item.id),
      indentationWidth
    }
  }

完成

ツリー構造を維持したまま並び替えができるようになりました!

DragOverlayで並び替えの視覚的な操作性を向上

さっきの状態だと、移動先がどこなのかわかりにくかったので、SortableTreeコンポーネントにDragOverlayを設定します。
これにより、親子関係の並び替えが視覚的にわかりやすくなりました。

const SortableTree = ({ defaultItems }: SortableTreeProps) => {
  const {
    flattenedItems,
    activeId,
    activeItem,
    sortedIds,
    getDndContextProps,
    getSortableTreeItemProps
  } = useSortableTree({ defaultItems })

  const sensors = useSensors(useSensor(PointerSensor))

  return (
    <DndContext {...getDndContextProps(sensors, measuring)}>
      <SortableContext items={sortedIds}>
        {flattenedItems.map((item) => (
          <SortableTreeItem key={item.id} {...getSortableTreeItemProps(item, INDENTION_WIDTH)} />
        ))}

        {typeof document !== 'undefined' &&
          createPortal(
            <DragOverlay dropAnimation={dropAnimationConfig}>
              {activeId && activeItem && (
                <SortableTreeItem
                  item={activeItem}
                  depth={activeItem.depth}
                  indentationWidth={INDENTION_WIDTH}
                  clone
                  childrenCount={getChildrenIds(flattenedItems, activeId).length}
                />
              )}
            </DragOverlay>,
            document.body
          )}
      </SortableContext>
    </DndContext>
  )
}

まとめ

今回はdnd-kitを使って並べ替え可能なツリー構造のリストを作成しました。公式のサンプルがあったことが何よりもありがたく、一部を自分用にカスタマイズしたりカスタムフックに切り分けるだけでほとんど実装できました。

今回紹介した内容をベースに、リストやグリッドの並び替えはもちろん、もっと複雑な多階層構造のUIにもぜひ応用してみてください。さらに、@dnd-kit/sortableみたいなプリセットを使うと、別のユースケースにもサクッと対応できるのでおすすめです!

今回サンプルで実装したリポジトリはこちらです。
https://github.com/uraaaa24/dnd-kit-sortable-tree

Discussion