Reactでドラッグ&ドロップ dnd-kitのstrategy関数をカスタマイズした話

### はじめに
Reactでドラッグ&ドロップ処理を実装するにあたり、自分の実装したい内容に合って綺麗に移動してくれるライブラリが無かったので、react-dnd-kitの移動量計算関数を自作カスタマイズしてみました。

### 実装したかったUI
![beautiful-model.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620936/0b6fcfd5-b2ac-172b-5654-041e8d671b81.gif)

要件のポイントは以下のとおり
- 長方形型（右端から下の行の左端に折り返し）でドラッグ&ドロップ入替えができる。
- ノード（ボタン）の幅は可変。
- ノード（ボタン）の高さは固定で良い。
- 幅が大きく違うノード同士のドラッグ&ドロップ入替え時にもUIが崩れない。

結果としては、react-dnd-kitの入替えStrategyを自作関数で折り返し部分の判定や移動量を調整することでできました。
react-beautiful-dndやreact-dnd-kitを試しましたがデフォルトで用意されているProps設定だけではどうやらできないようで、他ライブラリ含めたexamplesも確認しましたが上記要件と合っている例はありませんでした。ノードの幅が固定であれば例もたくさん有って実装も簡単だったのですが、可変幅というのがネックで少し苦労しましたので、苦労の記録を残していきます。

### react-beautiful-dndを試す

まずは、reactのdnd系ライブラリで一番githubスター数の多いreact-beautiful-dndを試しました。とりあえず公式ドキュメントに沿って実装してみると…

![vertical.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620936/c920cdcb-7320-a663-d748-830628dcd5be.gif)

縦方向の入替えにしか対応していない！？

さらにドキュメントを追っていくとDroppableコンポーネントのPropsの1つであるdirectionを"horizontal"にして、内部のドロップエリア全体を囲っているdiv要素のstyleにdisplay:"flex"を足すだけでできるとのこと。実装してみると…

![beautiful-horizontal.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620936/14af2cd9-bce2-af7c-b832-b45ec8e88431.gif)

ノードが改行してくれません。cssスタイルで、container内でflex化しているので改行配置されるはずなのに。色々試しましたが、react-beautiful-dndのhorizontalモードは1行にしか対応していないようでした。
ここで策としては、右端まで達するとDroppableコンポーネントごと1行ずつ増やしていくということも可能かもしれませんでしたが中々しんどいなぁと思い別ライブラリを試すことにしました。

### react-dnd-kitを試す
次にreact-dnd-kitを試してみました。こちらは事前にexamplesで長方形型に折り返していくドラッグ&ドロップが実装されているのを見ていたので、自分の要件に合っている可能性大と期待して使ってみました。
実際に使ってみて「いいんじゃないか？」と思っていたのですが、色んなケースを試していると問題点を発見。以下のようにボタンの大きさを違うノード同士の入替え時、スケールが自動調整されて変になってしまいました。

![no-scale.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620936/7cf58710-f019-734c-b110-f86d91903d05.gif)


スケール自動調整については、SortableItemコンポーネントのcssスタイルのtransformを生成する関数に少し修正を加えれば対応はできました。それで実装できたのが以下。かなり要件に近くはなっているのですが、入れ替えられたボタン同士の間隔が近くなったり遠くなったりと、UIが崩れてしまっています。

![yes-scale.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620936/855ec82d-c4fe-d791-8e6e-6e43387a37e8.gif)


ここで、[公式ドキュメント](https://docs.dndkit.com/)を読みながら、react-dnd-kitの入替え時のノードの移動量がSortableContextコンポーネントのPropsの1つであるstrategyであることを理解しました。
strategyは公式が用意したものが垂直モード、水平モードなど4種類用意されていて、基本的には自作関数ではなく公式が用意したものを使うことが推奨されています。しかしその4種類とも自分が実装したいものと完全に合致してはいなかったので、致し方なくそのstrategy関数を自分で書き換えてみることにしました。

### react-dnd-kitのstrategy関数を自作

少し長くなりますが、以下が自作関数になります。

```typescript
export const customStrategy = (_ref: any) => {
  var _rects$activeIndex;
  const buttonHeight = 50;

  let {
    rects,
    activeNodeRect: fallbackActiveRect,
    activeIndex,
    overIndex,
    index,
  } = _ref;
  const activeNodeRect =
    (_rects$activeIndex = rects[activeIndex]) != null
      ? _rects$activeIndex
      : fallbackActiveRect;

  if (!activeNodeRect) {
    return null;
  }

  const itemGap = getItemGap(rects, index, activeIndex);

  if (index === activeIndex) {
    const newIndexRect = rects[overIndex];

    if (!newIndexRect) {
      return null;
    }

    return {
      x:
        activeIndex < overIndex
          ? newIndexRect.left +
            newIndexRect.width -
            (activeNodeRect.left + activeNodeRect.width)
          : newIndexRect.left - activeNodeRect.left,
      y: 0,
      ...defaultScale,
    };
  }
  // インデックスが大きい側（右、下）方向にドラッグしたとき
  if (index > activeIndex && index <= overIndex) {
    // デフォルトのX移動量(ドラッグしているノードと同じ行内のノードのX移動量)
    let newX = -activeNodeRect.width - itemGap;

    // ドラッグ中ノードの1行以上↓に位置する左端ノード以外のX移動量
    if (
      rects[index].bottom - rects[activeIndex].bottom > buttonHeight &&
      rects[index].bottom == rects[index - 1].bottom
    ) {
      let gap = 0;
      // 移動対象のノードの行の一番左端のノードを検出
      while (true) {
        if (
          rects[index - gap].bottom - rects[index - gap - 1].bottom >
          buttonHeight
        ) {
          newX = -1 * rects[index - gap].width;
          break;
        }
        gap++;
      }
    }

    // ドラッグ中ノードの2行以上↓に位置する左端ノード専用のX移動量
    if (
      rects[index].bottom - rects[activeIndex].bottom > buttonHeight * 2 &&
      rects[index].bottom - rects[index - 1].bottom > buttonHeight
    ) {
      let gap = 1;
      // 移動対象のノードの行の一番左端のノードを検出
      while (true) {
        if (
          rects[index - gap].bottom - rects[index - gap - 1].bottom >
          buttonHeight
        ) {
          newX = -1 * rects[index - gap].width + rects[index - 1].right - 25;
          break;
        }
        gap++;
      }
    }

    // ドラッグ中ノードと同じ行にいるノード専用のY移動量 (デフォルト)
    let newY = 0;

    // 一番左端のノードの場合(移動対象ノードの前のノードが1行上にある場合)
    if (rects[index].bottom - rects[index - 1].bottom > buttonHeight) {
      newY = -60;
    }

    return {
      x: newX,
      y: newY,
      ...defaultScale,
    };
  }

  // インデックスが小さい側（左、上）方向にドラッグしたとき
  if (index < activeIndex && index >= overIndex) {
    // デフォルトのX移動量(ドラッグしているノードと同じ行内のノードのX移動量)
    let newX = activeNodeRect.width + itemGap;

    // ドラッグ中ノードの1行以上↑に位置する右端ノード以外のX移動量
    if (
      rects[activeIndex].bottom - rects[index].bottom > 30 &&
      rects[index].bottom == rects[index + 1].bottom
    ) {
      let gap = 0;
      // 移動対象のノードの行の一番右端のノードを検出
      while (true) {
        if (rects[index + gap + 1].bottom - rects[index + gap].bottom > 30) {
          newX = rects[index + gap].width;
          break;
        }
        gap++;
      }
    }

    // ドラッグ中ノードの2行以上↑に位置する右端ノード専用のX移動量
    if (
      rects[activeIndex].bottom - rects[index].bottom > buttonHeight * 2 &&
      rects[index + 1].bottom - rects[index].bottom > buttonHeight
    ) {
      let gap = 1;
      // 移動対象のノードの行の一番右端のノードを検出
      while (true) {
        if (
          rects[index + gap + 1].bottom - rects[index + gap].bottom >
          buttonHeight
        ) {
          newX = rects[index + gap].width - rects[index].right + 25;
          break;
        }
        gap++;
      }
    }

    let newY = 0;

    // 一番右端のノードの場合(移動対象ノードの次のノードが1行下にある場合)
    if (rects[index + 1].bottom - rects[index].bottom > buttonHeight) {
      newY = 60;
    }
    return {
      x: newX,
      y: newY,
      ...defaultScale,
    };
  }

  return {
    x: 0,
    y: 0,
    ...defaultScale,
  };
};

// 同じ行同士、ドラッグ中ノードと移動対象ノード間距離を取得する関数
function getItemGap(rects: ClientRect[], index: number, activeIndex: number) {
  const currentRect = rects[index];
  const previousRect = rects[index - 1];
  const nextRect = rects[index + 1];

  if (!currentRect || (!previousRect && !nextRect)) {
    return 0;
  }

  if (activeIndex < index) {
    return previousRect
      ? currentRect.left - (previousRect.left + previousRect.width)
      : nextRect.left - (currentRect.left + currentRect.width);
  }

  return nextRect
    ? nextRect.left - (currentRect.left + currentRect.width)
    : currentRect.left - (previousRect.left + previousRect.width);
}

// スケール(ボタンの大きさ)拡大縮小はしない (1固定)
const defaultScale = {
  scaleX: 1,
  scaleY: 1,
};
```

customStrategyをSortableContextコンポーネントのPropsであるstrategyに入れることで上手く動きました。
基本的には公式が用意しているhorizontalListSortingStrategyを元にして、折り返し時のX移動量、Y移動量を算出する処理を新たに付け加えました。

Y軸移動量は比較的簡単で、移動対象ノードが右端 or 左端かどうかを判定し、右端ノードがさらに右側に動くときはY軸移動量(=ボタンの固定高さ)を正に、左端ノードがさらに左に動くときはY軸移動量(=ボタンの固定高さ)を負に与えます。

問題はX軸移動量でした。言葉での説明が難しいのですが、右端 or 左端の場合でY軸が移動した場合、移動後の行の逆の端のX座標と、もう片端のノード幅（ボタン幅）に応じた移動量に依存する必要があります。以下の例で言うと、ノード1を2行下に移動させたときのノード4のX移動量を計算するためには、ノード2の幅とノード3の元X座標が必要になります。そのため、それら必要なノードを取得するためのwhile文が入っています。
![kit-x-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620936/d566e9f8-828a-405f-cd22-a1d620ebc382.gif)


また、ドラッグ中ノードの1行下の左端 or 1行上の右端の場合のX移動量は、ドラッグ中のノードがある行のもう片端のノード幅ではなくドラッグ中のノードの幅に依存しますので、その場合の条件分岐も加えています。以下の例で言うと、ノード1を1行下に移動させたときのノード3のX移動量を計算するためには、ノード2の元X座標とノード1の幅が必要になります。こちらも、それら必要なノードを取得するためのwhile文が入っています。
![kit-x-2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620936/d2084181-b129-ad64-d971-6aba133fdf2e.gif)


### まとめ
react-dnd-kitのstrategyでドラッグ時の各ノード移動量をカスタマイズしてみて、以下のようにボタン幅が大きく違うノード同士の入替えや、折り返し部分の移動も綺麗に実装できました！

![ok.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620936/0e8878c7-0947-9d5d-35b2-f717426d5550.gif)