LoginSignup
8
1

More than 1 year has passed since last update.

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

Posted at

最近、業務でReactDnDを利用したドラッグ&ドロップを実装したので、その学びを踏まえて公式ドキュメントのSortable Simpleをタッチデバイス対応にしてみました。
作成したコードはこちらになります。

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

React DnDとは

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

html5backend.gif

バックエンドモジュール

React DnDはドラッグ&ドロップの処理を、バックエンドモジュールを指定したプロバイダー内部に書く必要があります。公式ではHTML5Backendを指定することが推奨されていますが、こちらのモジュールはタブレットなどのタッチデバイスには対応していません。

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

そこでタッチイベントに対応したモジュールであるTouchBackendを代わりに用います。こちらを利用することで、タッチデバイスでもドラッグ&ドロップが可能になります。
iphone-nodraglayer.gif

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

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

ドラッグ&ドロップ実装

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

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を追加しておきます。


  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のコンポーネントはドラッグ&ドロップ実装と同様プロバイダー配下に配置する必要があります。

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構造を管理)
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で補正しました。


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

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

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


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

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

終わりに

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

参考サイト

8
1
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
8
1