React-DnDのベストプラクティス
結論
- | 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をなぜ強調したかは後に話します。
アプリケーションで使用するケース
- 一覧から対象にむけてアイテムをdragする
- アイテムの入れ替え
タッチデバイスではロングタップでDnDイベントが発生する
PCで動作確認した時はすんなり動くが、iPadなどのタッチデバイスではdndイベントが発火する前耐えられないくらいのラグがあります。ロングタップしないと、dndイベントが発火してくれません。
それによって以下の問題が発生しました。
- ロングタップしないとイベントが発生しないので、実際に動くかどうか分からない。
- 一覧から対象に向けてdragするときに、タップからコンポーネントをドラッグする時に実はdndイベントまだ発生していなくて画面スクロールしちゃう
- 画像要素を長押しした時に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の感度が良すぎて一覧をスクロール出来ない
TouchBackend
とuseDragLayer
で解決と思いきや、次にスクロール出来なくなる問題が発生しました。
先ほど、一覧から左に向かってdragすると言いましたが、ここが罠でした。
私が遭遇した例だと、商品をdragするときに一覧から引っ張るような形になるので、一覧をスクロールしようとしたときにdnd発火までの遅延が0msのためスクロールされずdndイベントが発火してしまいます。
良い意味でも悪い意味でもtouchbackend
は感度が良すぎます。
そこで、カード全体をdrag可能として扱っているので、商品画像のみをdrag可能にしたらよいのでは?となりました。
しかし、UI的にはカードなのでカード全体にイベントが走るべきなのでこの案はなしになりました。
行き詰まりました😓
scrollAngleRangesでドラッグイベントを無視する
DndProvider
にoptions
を設定することが可能です。
実際にドキュメントをみると
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