2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

dnd-kit × flex-wrapで衝突検出が壊れる問題と、ポインタベースのカスタム collision detection で解決した話

2
Posted at

はじめに

dnd-kit は React の DnD ライブラリとしてはかなり使いやすい部類だと思う。公式ドキュメントのサンプル通りにリストやグリッドの並べ替えを作る分には、ほぼハマらずに実装できる。

ただ、それは要素のサイズが均一な場合の話。

今回やりたかったのは、幅がバラバラのカードを flex-wrap で並べて、それをドラッグで並べ替えるというもの。中身の数によってカードの幅が変わるので、均一グリッドには収まらない。

┌──── カードA (400px) ────┐ ┌── カードB (250px) ──┐ ┌── カードC (300px) ──┐
│  [item] [item] [item]   │ │  [item] [item]      │ │  [item] [item]      │
└─────────────────────────┘ └─────────────────────┘ └─────────────────────┘
┌──────── カードD (550px) ────────┐ ┌── カードE (200px) ──┐
│  [item] [item] [item] [item]   │ │  [item]             │
└────────────────────────────────┘ └─────────────────────┘

表示するだけなら display: flex; flex-wrap: wrap で終わり。でもここに dnd-kit を入れてドラッグで並べ替えようとした瞬間、3つの問題が同時に襲ってきた。組み込みの closestCenterrectIntersection だとドロップ判定がズレる、プレビューアニメーションが明後日の方向に飛ぶ、行に入りきらないカードの扱いが考慮されない。

結論から言うと、collision detection と SortingStrategy を両方フルカスタムする必要があった。公式ドキュメントにはこの手のレイアウトの例がほぼなく、CollisionDetection の型定義と GitHub Issues を読みながら手探りで進めることになった。同じ構成でハマっている人向けに、やったことをまとめておく。

何がダメだったか

まず rectIntersectionclosestCenter もグリッド前提で作られている。幅バラバラの flex-wrap だと、カード間の隙間にドロップしても何にもヒットしない。ネストした droppable がある場合は矩形がモロ被りで、意図した方に落ちてくれない。

次に SortingStrategy。プレビューアニメーション(他のアイテムがずれる動き)が全然違う位置に飛ぶ。「ここに入るんだな」と思ってドロップしたら別の場所に入っていて、もうわけがわからない。

そして地味に一番やっかいだったのが行の容量の話。幅400pxのカードを、すでに800px埋まっている行(コンテナ幅1000px)に落とそうとしても、collision detection は折り返しなんて知らない。CSS上は次の行に押し出されるのに、判定上はその行に入ったことになっている。

解決策 — ポインタ座標ベースの行計算

全体構成

collisionDetection をカスタムして、ポインタ座標から「どの行の、どのカードの前/後に挿入するか」を計算する。

const customCollision = useCallback<CollisionDetection>(
  (args) => {
    const pointer = args.pointerCoordinates
    if (!pointer) return closestCenter(args)
    // ポインタ座標ベースの判定に入る(後述)
  },
  [],
)

Step 1: カードを行にグループ化

droppable containers の矩形をY座標でグルーピングする。

const ROW_Y_TOLERANCE = 20

const groupIntoRows = (items) => {
  const sorted = [...items].sort(
    (a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left,
  )

  return sorted.reduce((rows, item) => {
    const currentRow = rows[rows.length - 1]
    const entry = {
      id: item.id,
      left: item.rect.left,
      right: item.rect.left + item.rect.width,
      width: item.rect.width,
    }

    if (!currentRow || item.rect.top > currentRow.bottom + ROW_Y_TOLERANCE) {
      // 新しい行
      rows.push({
        items: [entry],
        top: item.rect.top,
        bottom: item.rect.top + item.rect.height,
        usedWidth: item.rect.width,
      })
    } else {
      // 同じ行に追加
      currentRow.items.push(entry)
      currentRow.bottom = Math.max(currentRow.bottom, item.rect.top + item.rect.height)
      currentRow.usedWidth += item.rect.width
    }
    return rows
  }, [])
}

ROW_Y_TOLERANCE = 20 がミソ。flex-wrap でカードの高さが違うと、同じ行でも top が数ピクセルずれる。これがないと同じ行のカードが別行に分類されて、特定のデータでだけ壊れるバグを踏んだ。

Step 2: 行のフィット判定 + X座標で挿入位置決定

const computeDropTarget = (rows, pointer, draggedWidth, containerWidth, gap) => {
  // ポインタのY座標 → 対象行
  const rowIndex = rows.findIndex(
    (row) => pointer.y >= row.top - ROW_Y_TOLERANCE
          && pointer.y <= row.bottom + ROW_Y_TOLERANCE,
  )
  const row = rows[rowIndex] ?? rows[rows.length - 1]

  // ドラッグ中のカードがこの行に入りきるか?
  const fits = row.usedWidth + draggedWidth + row.items.length * gap <= containerWidth

  if (!fits) {
    // 入りきらない → 次の行の先頭をターゲットにする
    const nextRow = rows[rowIndex + 1]
    if (nextRow) return { targetId: nextRow.items[0].id, side: 'before' }
    // 次の行がなければ末尾(CSSが勝手に改行する)
    const last = row.items[row.items.length - 1]
    return { targetId: last.id, side: 'after' }
  }

  // 入りきる → X座標でbefore/afterを決定
  return findInsertionInRow(row, pointer.x)
}

const findInsertionInRow = (row, pointerX) => {
  const first = row.items[0]
  if (pointerX < first.left + first.width / 2) {
    return { targetId: first.id, side: 'before' }
  }

  const last = row.items[row.items.length - 1]
  if (pointerX >= last.left + last.width / 2) {
    return { targetId: last.id, side: 'after' }
  }

  // カード間の中間点で判定
  for (const [i, item] of row.items.entries()) {
    if (i >= row.items.length - 1) continue
    const next = row.items[i + 1]
    const boundary = (item.right + next.left) / 2
    if (pointerX < boundary) {
      return { targetId: item.id, side: 'after' }
    }
  }

  return { targetId: last.id, side: 'after' }
}

各カードの中心と、カード間の中間点でbefore/afterを判定する。

collision detection から hook への橋渡し(ここで1日溶かした)

collisionDetection は DndContext が内部で呼ぶ関数で、setState はできない(無限ループする)。でも side: 'before' | 'after' の情報を onDragEnd で使いたい。collision detection の返り値は「どの droppable にヒットしたか」だけで、付加情報を載せる口がない。

結局 ref を使った片方向通信にした。

const dropTargetRef = useRef(null)

// collision detection 内で書き込む
const customCollision = useCallback((args) => {
  const target = computeDropTarget(rows, pointer, ...)
  dropTargetRef.current = target // ← ref 経由で共有

  // dnd-kit には「どのcontainerにヒットしたか」だけ返す
  const container = args.droppableContainers.find((c) => c.id === target.targetId)
  return container ? [{ id: container.id, data: { droppableContainer: container } }] : []
}, [])

// onDragEnd で ref を読む
const handleDragEnd = useCallback(() => {
  const target = dropTargetRef.current // ← side の情報が取れる
  if (!target) return
  // target.side を使って並べ替え処理
}, [])

ref を経由するのは、collision detection がマウス移動のたびに呼ばれるので state 更新だと間に合わないから。最初 state で直接やろうとしてインジケータが1フレーム遅れてチラつく現象に悩まされた。

flex-wrap 対応の SortingStrategy

プレビューアニメーションも自前で計算する必要がある。移動後の並び順で左上から詰め直して、コンテナ幅を超えたら改行する。

const createFlexWrapStrategy = (containerWidth, gap) => {
  return ({ activeIndex, index, rects, overIndex }) => {
    if (activeIndex === overIndex || rects.length === 0) return null

    const originLeft = Math.min(...rects.map((r) => r.left))
    const originTop = Math.min(...rects.map((r) => r.top))
    const currentRelLeft = rects[index].left - originLeft
    const currentRelTop = rects[index].top - originTop

    const newOrder = arrayMove(
      Array.from({ length: rects.length }, (_, i) => i),
      activeIndex, overIndex,
    )

    // 新しい順序で仮想レイアウト
    const layout = newOrder.reduce((acc, origIdx) => {
      const w = rects[origIdx].width
      const h = rects[origIdx].height

      // コンテナ幅を超えたら折り返し
      const nextX = acc.x > 0 && acc.x + w > containerWidth ? 0 : acc.x
      const nextY = nextX === 0 && acc.x > 0 ? acc.y + acc.rowH + gap : acc.y
      const rowH = nextX === 0 && acc.x > 0 ? h : Math.max(acc.rowH, h)

      return {
        x: nextX + w + gap,
        y: nextY,
        rowH,
        tX: origIdx === index ? nextX : acc.tX,
        tY: origIdx === index ? nextY : acc.tY,
      }
    }, { x: 0, y: 0, rowH: 0, tX: currentRelLeft, tY: currentRelTop })

    return {
      x: layout.tX - currentRelLeft,
      y: layout.tY - currentRelTop,
      scaleX: 1,
      scaleY: 1,
    }
  }
}

これで CSS flex-wrap と同じ折り返し挙動のプレビューが出る。

所感

正直、ここまでやるなら dnd-kit じゃなくて素の Pointer Events で全部書いた方が早かったのでは、と途中で何度か思った。ただ、キーボード操作やアクセシビリティ周り、DragOverlay のアニメーションなんかは dnd-kit に乗っかった恩恵がちゃんとあるので、結果的にはカスタム collision detection を書く判断で正解だったと思う。

同じような構成で DnD を実装しようとしている人へのアドバイスとしては、closestCenterrectIntersection の組み合わせで済むかをまず検証して、ダメそうなら早めにポインタベースに切り替えること。中途半端にデフォルトに乗っかってエッジケースをパッチし続けると、余計に時間を食う。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?