💧

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