ドラッグで要素を移動させる - React
導入
ドラッグで要素を移動させるような実装について、方法と注意点等を書きます。
デモをCodesandboxに置いています。
注意が必要な部分について先に箇条書きしておきます。
setPointCapture
/releasePointCapture
を使わないとスムーズな挙動にならない- Reactの
SynteticEvent
はイベントをプールするため、コールバックにプロパティを渡してはいけない onPointerMove
が高頻度に実行されるので、race conditionに注意する- スマホ・タブレットの場合
onMouseUp
等ではなくonPointerUp
等を使用する- スクロールのジェスチャーにイベントを取られないよう、
touch-action: none
を使用する
実装
カスタムHookで実装しており、メインの実装はuseMoveOnDrag.ts
です。
基本
ドラッグに対する挙動を実装したいので、 onPointerDown
, onPointerMove
, onPointerUp
をうまく定義することを考えます。
const startDrag = (event) => { ... }; const dragging = (event) => { ... }; const endDrag = (event) => { ... }; ... return { onPointerDown: startDrag, onPointerMove: dragging, onPointerUp: endDrag, };
ちなみに、PCでの動作のみを想定する場合は onMouseDown
などでも構いません。
onMouseDown
等はマウスによる入力を前提としたもので、 onPointerDown
等はペンやタッチを考慮したものです。
参考: Pointer events - Web API | MDN
一方、スマホ等での使用を想定する場合はスクロール等のジェスチャーにイベントを取られないよう、稼働範囲の要素のスタイルにtouch-action: none
を設定します。
状態や副作用の設計
移動を実現するコールバックの実装方法はいろいろ考えられると思いますが、非同期に onPointerMove
が高頻度で実行されるので、race conditionに注意が必要です。
今回の実装では、ドラッグ開始時の要素の位置とカーソルの位置を覚えておき、 onPointerMove
が発生するたびにカーソルが初期位置から移動した距離を計算して要素を初期位置から移動させています。
現在の位置 argPosition
, 新しい位置に移動する関数 onMove
を引数として受け取ることにし、onPointerMove
で onMove
を呼ぶことでドラッグ中の位置に移動します。
interface Position { x: number; y: number; } interface DragState { originalPosition: Position; startCursor: Position; } const useMoveOnDrag = (argPosition, onMove) => { const [state, setState] = useState<DragState | null>(null); const startDrag = (event: React.PointerEvent) => { setState({ originalPosition: { x: argPosition.x, y: argPosition.y }, startCursor: { x: event.pageX, y: event.pageY } }); }; const dragging = (event: React.PointerEvent<T>) => { if (state === null) return; onMove({ x: state.originalPosition.x + event.pageX - state.startCursor.x, y: state.originalPosition.y + event.pageY - state.startCursor.y }); }; const endDrag = (event: React.PointerEvent<T>) => { setState(null); if (state === null) return; onMove({ x: state.originalPosition.x + event.pageX - state.startCursor.x, y: state.originalPosition.y + event.pageY - state.startCursor.y }); }; ... }
前回からの移動分を足し続けるような実装の仕方もあるかと思いますが、その場合は高頻度に変化する状態によるrace conditionを考慮する必要があります。State Hookで実装する場合は、 setState
のコールバック形式( setState((prevState) => newState)
)での実装を検討するなど、工夫が必要です。
また、別の実装をする場合はReactでイベントハンドラに渡されるイベントはSynthetic Eventであることに注意が必要です。
イベントハンドラのスコープを抜けるとイベントは null
で初期化されてしまうため、ハンドラ内でコールバックを宣言する際はコールバック内でイベントを参照すると null
になってしまいます。
イベントハンドラ内でプロパティを別の変数として格納することで回避します。
今回の実装の場合はコールバックの宣言はしていないので対策はしていません。
Pointer Captureをする
上記のような実装のままでは、カーソルの移動に追いつかずカーソルが要素の外に出てしまったりした際にイベントハンドラが実行されずぎこちない動きになってしまいます。
onPointerDown
から onPointerUp
までの間、カーソルの移動のイベントのターゲットを移動させたい要素に設定するよう Pointer capture
を使用します。
const startDrag = (event: React.PointerEvent) => { event.currentTarget.setPointerCapture(event.pointerId); ... }; ... const endDrag = (event: React.PointerEvent<T>) => { event.currentTarget.releasePointerCapture(event.pointerId); ... };
setPointerCapture
をしない場合の挙動は↓で確認できます。
「Move me」を素早くドラッグしてカーソルに追いつかない場合や、「Slide me」をドラッグしている時に上下にカーソルがはみ出た時の挙動がおかしいことがわかると思います。
主な実装の説明は以上です。 このHookを使用する際は、コンポーネントの位置をStateとして持ち、現在位置と位置を設定するコールバックをHookの引数に渡します。(例: Code Sandbox)
冒頭で掲載したデモの実装では、記事で触れた内容に加えて useCallback
によるメモ化をしたり、型をしっかり定義したりしています。
まとめ
onPointerDown
、onPointerMove
、onPointerUp
のハンドラを書くことで実現した- 実装の際には、Pointer Captureやrace conditionなどに注意する