🥣

【React-DnD】クロスプラットフォームで使おうとしたらハマった🦭🦭

2023/07/13に公開

結論

- PC とか タッチデバイス
backend HTMLBackend TouchBackend
プレビュー画像 デフォルトで表示される useDragLayer でのカスタムが必要
遅延 不要 50ms(プロジェクトによる)
画像ロングタップ - Webkitcallout で非表示に
scrollAngleRanges - 場合によっては必要

React-DnDとは?

React 用のドラッグアンドドロップ(以下 DnD)ライブラリです。このライブラリには UI コンポーネントはなく、MUI などで作成したコンポーネントをラップすることで DnD が可能になります。

DnD の基本的な使い方や、フックは調べたらいくらでも出てくるので今回はさらっと紹介します。

DndProvider

  • DnD を使いたい範囲を DndProvider をラップします。これによってそのページで DnD が可能になります。
  • DndProvider の Props にbackendを渡してあげる必要があります
  • DnDProvider の中に DnDProvider を設定するといったネストは出来ません
import { HTML5Backend } from "react-dnd-html5-backend";
import { DndProvider } from "react-dnd";

export const YourApp = () => {
  return <DndProvider backend={HTML5Backend}>/* Your Drag-and-Drop Application */</DndProvider>;
};

また、React-DnD を使うにあたって大事になる 3 つのフックがライブラリで用意されています。

useDrag

  • 要素を drag したい時に使います。
  • ドラッグ対象のコンポーネントにこの hook の返り値(以下のコードのdragのとこ)を渡してあげると drag 可能と認識してくれます。

こんな感じのコード

const [collected, drag] = useDrag(() => ({ type, item: { id } }));

useDrop

  • 要素を drop したい時に使います。
  • ドロップ対象のコンポーネントにこの hook の返り値(以下のコードのdropのとこ)を渡してあげると drog 可能と認識してくれます。
const [collected, drop] = useDrop(() => ({ accept }));

useDragLayer

  • ドラッグしているかどうかやドラッグ中のコンポーネントの座標などを取得できます。
  • ドラッグ中のプレビュー画像をカスタマイズしたい時とかに使います。
  • イメージとしては、useDrag と useDrop の間に使う感じです。
  • HTML5Backendかつ PC ブラウザ(タッチデバイス以外)の場合、いい感じにコンポーネントをそのままプレビュー表示してくれるのでカスタマイズする必要がない場合は不要です
  • HTML5Backendをなぜ強調したかは後に話します。

アプリケーションで使用するケース

  1. 一覧から対象にむけてアイテムを drag する
  2. アイテムの入れ替え

タッチデバイスではロングタップで DnD イベントが発生する

PC で動作確認した時はすんなり動くが、iPad などのタッチデバイスでは dnd イベントが発火する前耐えられないくらいのラグがあります。ロングタップしないと、dnd イベントが発火してくれません。
それによって以下の問題が発生しました。

  1. ロングタップしないとイベントが発生しないので、実際に動くかどうか分からない。
  2. 一覧から対象に向けて drag するときに、タップからコンポーネントをドラッグする時に実は dnd イベントまだ発生していなくて画面スクロールしちゃう
  3. 画像要素を長押しした時に safari 標準のポップアップみたいなのが出てくる

よって、UX は最悪です

3 は、style に以下を追加することですぐ対応できました。
-webkit-touch-callout

style={{
    // eslint-disable-next-line @typescript-eslint/naming-convention -- 長押し時に出てくるデフォルトメニューを非表示にする
    WebkitTouchCallout: 'none',
}}

DndProvider に渡す backend が違った

DndProvider の紹介の際に、backend にHTML5Backendを渡すと言いましたが、これが間違いでした。

<DndProvider backend={HTML5Backend}> ← これ /* Your Drag-and-Drop Application */</DndProvider>

ドキュメントを見たところ以下が書いてありました。(Backends)

Unfortunately, the HTML5 drag and drop API also has some downsides. It does not work on touch screens, and it provides less customization opportunities on IE than in other browsers.

This is why the HTML5 drag and drop support is implemented in a pluggable way in React DnD. You don't have to use it. You can write a different implementation, based on touch events, mouse events, or something else entirely. Such pluggable implementations are called the backends in React DnD.

The library currently ships with the HTML backend, which should be sufficient for most web applications. There is also a Touch backend that can be used for mobile web applications.

つまり、モバイル用には HTMLBackend ではなくて、TouchBackendを使えと書いてありました。

これをもとに作成した DndProvider が以下になります。
タッチデバイスかどうかの条件分岐にreact-device-detectを使用しています。

import { isMobile } from "react-device-detect";
import { HTML5Backend } from "react-dnd-html5-backend";
import { TouchBackend } from "react-dnd-touch-backend";

<DndProvider backend={isMobile ? TouchBackend : HTML5Backend}>
  /* Your Drag-and-Drop Application */
</DndProvider>;

これでタッチデバイスでも正常に動くことができます。
しかし、また次の問題が発生します。

Touchbackend ではデフォルトでプレビュー画像を表示してくれない

タッチデバイスでは、プレビュー画像を表示してくれません。
これはtouchbackendの通常の挙動で、プレビューを表示させるにはuseDragLayerを使用する必要がありました。

プレビュー画像の設定は以下のように行いました。この設定は、プロジェクト毎に異なると思うので参考程度に作成したプレビューコンポーネントを貼っておきます。


type Props = {
  scale: number;
};

export const PreviewDragLayer: FC<Props> = ({ scale }) => {
  const { itemType, isDragging, item, clientOffset } = useDragLayer(
    (monitor) => ({
      item: monitor.getItem(),
      itemType: monitor.getItemType(),
      initialOffset: monitor.getInitialSourceClientOffset(),
      clientOffset: monitor.getClientOffset(),
      isDragging: monitor.isDragging(),
    })
  );

  const offsets = useMemo(() => {
    const zeroOffset = { x: 0, y: 0 };
    if (!item?.data) return zeroOffset;
    const { individual, group, } = item.data;
    switch (itemType) {
      case ItemTypes.INDIVIDUAL:
      case ItemTypes.GROUP: {
        return { x: width / 2, y: height / 2 };
      default:
        return zeroOffset;
    }
  }, [item?.data, itemType]);

  const transform = useMemo(() => {
    if (!clientOffset) return;
    let { x, y } = clientOffset;
    x -= offsets.x;
    y -= offsets.y;
    return `translate(${x}px, ${y}px) scale(${scale})`;
  }, [clientOffset, offsets, scale]);

  if (!isDragging || !clientOffset) {
    return <></>;
  }
  return (
    <Box
      component="div"
      sx={{
        position: 'fixed',
        pointerEvents: 'none',
        transform,
        WebkitTransform: transform,
      }}
    >
      {item?.data && (
        <Preview itemType={itemType} dndData={item.data} />
      )}
    </Box>
  );
};

const Preview: FC<{ dndData: DndData }> = ({
  dndData: { item, itemGroup },
}) => (
        <Group
          isDragging
	  // your props
        />
      );

これで、タッチデバイスでも正常に Dnd を動かすことが出来るはずでした。
また次の問題が発生します。

TouchBackend の感度が良すぎて一覧をスクロール出来ない

TouchBackenduseDragLayerで解決と思いきや、次にスクロール出来なくなる問題が発生しました。
先ほど、一覧から左に向かって drag すると言いましたが、ここが罠でした。
私が遭遇した例だと、商品を drag するときに一覧から引っ張るような形になるので、一覧をスクロールしようとしたときに dnd 発火までの遅延が0msのためスクロールされず dnd イベントが発火してしまいます。

良い意味でも悪い意味でもtouchbackendは感度が良すぎます。
そこで、カード全体を drag 可能として扱っているので、商品画像のみを drag 可能にしたらよいのでは?となりました。
しかし、UI 的にはカードなのでカード全体にイベントが走るべきなのでこの案はなしになりました。

行き詰まりました 😓

scrollAngleRanges でドラッグイベントを無視する

DndProvideroptionsを設定することが可能です。
実際にドキュメントをみると

Specifies ranges of angles in degrees that drag events should be ignored. This is useful when you want to allow the user to scroll in a particular direction instead of dragging. Degrees move clockwise, 0/360 pointing to the left.

ドラッグイベントを無視する角度の範囲を指定出来ます。
なので、商品をスクロールする角度に対して dnd イベントを無効化してしまえば解決します。

イメージ

また、設定したコードは以下になります

<DndProvider
  backend={isMobile ? TouchBackend : HTML5Backend}
  options={{
    scrollAngleRanges: [
      { start: 60, end: 120 },
      { start: 240, end: 300 },
    ],
  }}
>
  /* Your Drag-and-Drop Application */
</DndProvider>

しかし、またここで問題が発生します。
最初に dnd を 2 つのケースで使うと話しましたが、全ての箇所において縦ドラッグが無効化されてしまいました。
これでは、縦方向に入れ替えを行いたいときに dnd が無視されてしまいます。
単純に option を条件分岐させることで解決出来ると思ってましたが、drag 要素を drag しようとした時にそれぞれのタイプ(一覧かドラッグ先のコンポーネントか)が割り振られるので、タイプが分かった時には drag イベントが発火しているので出来ません。

delayTouchStart で dnd イベント発火に遅延を入れる

ドキュメントをもう一度見てみると、delayTouchStart という options がありました。

The amount in ms to delay processing of touch events

タッチイベントの処理を遅延させることが出来ます。こんな感じです。

  • 0 ~ {delay} ms の場合 → ドラッグイベントを無視 → スクロール可能
  • dealy ~ の場合 → ドラッグイベントを発火

ここの delay の秒数が肝になってきます。遅すぎると、最初のロングタップと同じような操作感となってしまう振り出しに戻るので細かく調整することが必要です。

私が試した感じでは以下の秒数で試したところ、50msが一番良いという結論になりました。
試した秒数(ms)は以下になります。

  • 50
  • 100
  • 150
  • 200 --- これ以上はロングタップの時と同じ操作感
  • 250
  • 300
  • 500
  • 750
  • 1000

余談
タブレットの場合 Safari の Pull-to-refresh が邪魔で、下方向にドラッグしようとするとスクロールされてしまうことがあります。
iOS16 の overscroll-behaviorで制御できるらしい

Discussion