React DnDをタッチデバイス対応にしてみる

最近、業務でReactDnDを利用したドラッグ&ドロップを実装したので、その学びを踏まえて公式ドキュメントの[Sortable Simple](https://react-dnd.github.io/react-dnd/examples/sortable/simple)をタッチデバイス対応にしてみました。
作成したコードは[こちら](https://github.com/souenxx/react-dnd-sample)になります。

本記事の流れ
- ReactDnDとは
- バックエンドモジュール
- ドラッグ&ドロップ実装
- DragLayerによるプレビュー実装

#React DnDとは
Reactでドラッグ&ドロップが実現できるライブラリです。登場は2014年ですが、2022年1月現在も継続的にメンテナンスされており、hooksも提供されています。

![html5backend.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2518928/583fcd0c-230b-7c78-764b-ee478f64ff81.gif)

#バックエンドモジュール
React DnDはドラッグ&ドロップの処理を、バックエンドモジュールを指定したプロバイダー内部に書く必要があります。公式では[HTML5Backend](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API)を指定することが推奨されていますが、こちらのモジュールはタブレットなどのタッチデバイスには対応していません。

```react
function App() {
  return (
    <div className="App">
      <DndProvider backend={HTML5Backend} >
        {/* プロバイダーの中にドラッグ&ドロップの処理を配置する */}
      </DndProvider>
    </div>
  );
}
```


そこでタッチイベントに対応したモジュールである[TouchBackend](https://react-dnd.github.io/react-dnd/docs/backends/touch)を代わりに用います。こちらを利用することで、タッチデバイスでもドラッグ&ドロップが可能になります。
![iphone-nodraglayer.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2518928/9b467b11-b815-27e4-31e6-813675dc6bb2.gif)

しかし、TouchBackendだと今度はドラッグ時のプレビューが表示されません。

HTML5Backendの時はReactDnD側ではなく、HTML5Backend側のAPIを利用することでドラッグ中のプレビュー表示を実現していました。TouchBackendだとそれがないため、自前で実装する必要があります。

#ドラッグ&ドロップ実装
ドラッグ&ドロップ自体の実装は公式のコードを引っ張ってきています。
以下は今回のドラッグ対象かつドロップ対象であるCardコンポーネントです。

```react:Card.tsx
export const Card: FC<CardProps> = ({ id, text, index, moveCard }) => {
  const ref = useRef<HTMLDivElement>(null);

  // collectでmonitor経由で取得した値を戻り値として外に渡せる
  const [{ handlerId }, drop] = useDrop({
    accept: ItemTypes.CARD,
    collect(monitor) {
      return {
        handlerId: monitor.getHandlerId(),
      };
    },
    // ドラッグ中にドロップ対象にhoverしている時のcallback関数を定義
    hover(item: DragItem, monitor: DropTargetMonitor) {
      if (!ref.current) {
        return;
      }
      // ドラッグ中のアイテムのindexを取得
      const dragIndex = item.index;
      // ドラッグ中にhoverしているドロップ対象のアイテムのindexを取得
      const hoverIndex = index;

      if (dragIndex === hoverIndex) {
        return;
      }

      // refからドラッグ中のアイテムの初期位置を取得
      const hoverBoundingRect = ref.current?.getBoundingClientRect();

      const hoverMiddleY =
        (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;

      // monitorからカーソルの位置を取得
      const clientOffset = monitor.getClientOffset();

      const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;

      // カーソルがドロップ対象のアイテムの高さの半分を超えるまでは、並び替えを実行しない

      if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
        return;
      }

      if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
        return;
      }

      // 高さの半分を超えた時点で並び替えを実行
      moveCard(dragIndex, hoverIndex);

      item.index = hoverIndex;
    },
  });

  // collectでmonitor経由で取得した値を戻り値として外に渡せる
  const [{ isDragging }, drag] = useDrag({
    type: ItemTypes.CARD,
    item: () => {
      return { id, index };
    },
    collect: (monitor: any) => ({
      isDragging: monitor.isDragging(),
    }),
  });

  // ドラッグ中はドラッグされているアイテムを非表示にしておく
  const opacity = isDragging ? 0 : 1;


  // refとuseDrag、useDropを紐づける
  drag(drop(ref));

  return (
    <div ref={ref} style={{ ...style, opacity }} data-handler-id={handlerId}>
      {text}
    </div>
  );
};
```

ドラッグ&ドロップの処理はuseDrop、useDragといったhooksを利用することで、それぞれドロップ時、ドラッグ時の挙動を設定できます。
今回はドラッグ中のカーソルがドロップ対象にhoverした時点で並び替えするように実装されているため、useDrop内ではhoverしている時のcallback関数を定義しています。
各hooksではcollectという関数を介すことで、内部の状態を返り値として外で受け取ることができます。ここでは現在ドラッグしているかどうかの状態（isDragging）をuseDragから返り値として受け取り、ドラッグ中は元々そのアイテムがあった箇所を非表示にするopacityを適用しています。

また、ドラッグ時のプレビューの実装をする際に、div内に表示するテキストが必要になるため、itemの返り値にtextを追加しておきます。


```react

  const [{ isDragging }, drag] = useDrag({
    type: ItemTypes.CARD,
    item: () => {
      // itemの返り値にtextを追加
      return { id, index, text };
    },
    collect: (monitor: any) => ({
      isDragging: monitor.isDragging(),
    }),
  });
```

#DragLayerによるプレビュー実装

ここからドラッグ時のプレビューを実装します。ReactDnDにはDragLayerというプレビュー表示を作成できるAPIがあるので、このhooksを用いたコンポーネントを作成します。
DragLayerのコンポーネントはドラッグ&ドロップ実装と同様プロバイダー配下に配置する必要があります。

```react
function App() {
  return (
    <div className="App">
　　　　　　　　　　　　{/* タッチでもマウスでもドラッグできるようにオプションを指定 */}
      <DndProvider backend={TouchBackend} options={{ enableMouseEvents: true }}>
        {/* ドラッグ&ドロップができるコンポーネントの描写を行う */}
        <Example />
        {/* プレビューの描写を行う */}
        <SampleDragLayer />
      </DndProvider>
    </div>
  );
}
```

***

次にプレビューのコンポーネントであるSampleDragLayerの実装です。
今回作成したSampleDragLayerのフォルダ構成とコードは以下になります。

- SampleDragLayer
 - hooks.ts（ロジックをまとめたカスタムフック）
 - index.tsx（カスタムフックとDOM構造を呼び出す）
 - presenter.tsx（DOM構造を管理）


```react:hooks.ts
export const useSampleDragLayer = () => {
  const { item, isDragging, initialOffset, differenceOffset } = useDragLayer(
    (monitor) => ({
      // ドラッグしているアイテムの初期位置を取得
      initialOffset: monitor.getInitialSourceClientOffset(),
      // ドラッグ開始位置から現在のカーソル位置までの差分を取得
      differenceOffset: monitor.getDifferenceFromInitialOffset(),
      // useDragのItemに渡していた要素をここから取得
      item: monitor.getItem(),
      isDragging: monitor.isDragging(),
    })
  );

  if (!isDragging || !differenceOffset || !initialOffset) {
    return { text: "", isDragging: isDragging, x: 0, y: 0 };
  }

  return {
    text: item.text,
    isDragging: isDragging,
    // 以下でプレビューを表示したい座標を計算
    // スクロールで表示の初期位置がずれてしまうのでwindow.scrollX、window.scrollYで補正
    x: differenceOffset.x + initialOffset.x + window.scrollX,
    y: differenceOffset.y + initialOffset.y + window.scrollY,
  };
};
```

useDragLayer内のmonitorは先程定義したuseDragのmonitorと紐づいているため、プレビューの描写に必要なアイテムの情報とドラッグ状態、カーソルの位置情報などを返り値から受け取ります。
スクロールで表示位置でずれる部分はwindow.scrollX、window.scrollYで補正しました。

***

```react:index.tsx
export const SampleDragLayer: FC = () => {
  const { isDragging, ...props } = useSampleDragLayer();
  // ドラッグ中じゃない時はプレビューのコンポーネントを返さない
  if (!isDragging) {
    return null;
  }

  return <SampleDragLayerPresenter {...props} />;
};
```

カスタムフックからpresenterに渡すpropsを受け取ります。
ドラッグ中以外はプレビューを表示したくないので、isDraggingがtrueの時はnullを返します。

***

```react:presenter.tsx
const style = {
  border: "1px dashed gray",
  padding: "0.5rem 1rem",
  marginBottom: ".5rem",
  backgroundColor: "white",
  cursor: "move",
};

type Props = {
  text: string;
  x: number;
  y: number;
};

export const SampleDragLayerPresenter: FC<Props> = ({ text, x, y }) => {
  return (
    <div
      style={{
        width: 400,
        zIndex: 10,
        position: "absolute",
        top: 0,
        left: 0,
        transform: `translate(${x}px, ${y}px)`,

        // こいつが無いとドロップ処理が上手くいかない
        pointerEvents: "none",
      }}
    >
      <div style={{ zIndex: 10, ...style }}>{text}</div>
    </div>
  );
};
```
プレビューを表示すべき座標のpropsを受け取り、スタイルに適用します。

***

![iphone-drag.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2518928/f1a20592-1f2c-310c-6358-e19e735985af.gif)

プレビューが表示された！

#終わりに
今回はReactDnD公式サンプルのコードをタッチデバイス対応にしてみました。
ドラッグ&ドロップのライブラリとしては[react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd)も有名なので、今後そちらも触って機能面や使いやすさを比較してみたいです。
ここまで読んでいただきありがとうございましたmm

#参考サイト

https://react-dnd.github.io/react-dnd/

https://zenn.dev/mtblue81/articles/98ae1a40266317

https://qiita.com/hiyamamoto/items/d83cf4ccfcfa886a0ba8