ドラッグで要素を移動させる - 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 を引数として受け取ることにし、onPointerMoveonMove を呼ぶことでドラッグ中の位置に移動します。

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 をしない場合の挙動は↓で確認できます。

CodeSandbox

「Move me」を素早くドラッグしてカーソルに追いつかない場合や、「Slide me」をドラッグしている時に上下にカーソルがはみ出た時の挙動がおかしいことがわかると思います。

主な実装の説明は以上です。 このHookを使用する際は、コンポーネントの位置をStateとして持ち、現在位置と位置を設定するコールバックをHookの引数に渡します。(例: Code Sandbox

冒頭で掲載したデモの実装では、記事で触れた内容に加えて useCallback によるメモ化をしたり、型をしっかり定義したりしています。

まとめ

  • onPointerDownonPointerMoveonPointerUpのハンドラを書くことで実現した
  • 実装の際には、Pointer Captureやrace conditionなどに注意する

参考