(小ネタ) Web のドラッグを良い感じにする

PC の Web ブラウザ用に HTML と JavaScript でインタラクティブな UI を作る時に、ちょっとした工夫が使い勝手の印象を大きく変えます。今回はスライダーの UI を題材として、ドラッグ時の動きが良い感じになる工夫について説明します。

結論

対象の要素の mousemove ではなく、documentmousemove を使うと良い感じになるよ

ドラッグ対象の要素のイベントを使う場合

mousedown でドラッグを開始し、mousemove イベントが発火する度に offsetX や offsetY を取得してスライダーに反映します。 マウスポインターがスライダーの領域を外れるとドラッグが継続できず、ぎこちない感じになりますね。また、スライダーの領域外でドラッグを終了すると、mouseup が呼ばれず、次にスライダーにポインターを当てるとクリックしていない状態でもドラッグ状態になります。

document のイベントを使う場合

mousedown でドラッグを開始するのは同じですが、mousemovemouseup は document のイベントを使います。 こうすることで、スライダーの領域外だけでなく、ブラウザーのウィンドウ外でもドラッグが効くことにご注目ください。

document のイベントを使うため、offsetX がドラッグ対象の要素のローカル座標になっていません。そのためドラッグ開始時の clientX と、ドラッグ中の clientX の差分をとって結果を計算します。

便利関数と React

このパターンは良く使うので、以下のような関数を作っておくと便利です。

export interface DragHandler {
  onMouseMove?: (e: MouseEvent) => void
  onMouseUp?: (e: MouseEvent) => void
  onClick?: (e: MouseEvent) => void
}

export const observeDrag = ({
  onMouseMove,
  onMouseUp,
  onClick,
}: DragHandler) => {
  let isMoved = false

  const onGlobalMouseMove = (e: MouseEvent) => {
    isMoved = true
    onMouseMove?.(e)
  }

  const onGlobalMouseUp = (e: MouseEvent) => {
    onMouseUp?.(e)

    if (!isMoved) {
      onClick?.(e)
    }

    document.removeEventListener("mousemove", onGlobalMouseMove)
    document.removeEventListener("mouseup", onGlobalMouseUp)
  }

  document.addEventListener("mousemove", onGlobalMouseMove)
  document.addEventListener("mouseup", onGlobalMouseUp)
}

また、下記のように React と組み合わせて使うのもなかなかに良いです。

const onMouseDown = useCallback((e) => {
  ...
  observeDrag({
    onMouseMove: (e) => {
      // なにかする
    },
    onMouseUp: (e) => {
    }
  })
}, [])

<div onMouseDown={onMouseDown}>
  hello
</div>

mousedown 時のイベントを observeDrag の引数に取って、clientX, clientY の差分を取る処理を実装するのもいいかもしれません。

export const observeDrag = ({
  mouseDownEvent,
  onMouseMove,
  onMouseUp,
  onClick,
}) => {
  let isMoved = false
  const startClientX = mouseDownEvent.clientX
  const startClientY = mouseDownEvent.clientY

  const onGlobalMouseMove = (e: MouseEvent) => {
    isMoved = true

    const deltaX = e.clientX - startClientX
    const deltaY = e.clientY - startClientY

    onMouseMove?.(e, deltaX, deltaY)
  }

  const onGlobalMouseUp = (e: MouseEvent) => {
    onMouseUp?.(e)

    if (!isMoved) {
      onClick?.(e)
    }

    document.removeEventListener("mousemove", onGlobalMouseMove)
    document.removeEventListener("mouseup", onGlobalMouseUp)
  }

  document.addEventListener("mousemove", onGlobalMouseMove)
  document.addEventListener("mouseup", onGlobalMouseUp)
}

おわりに

以上、UX を改善する小ネタでした。 大した内容ではありませんが、こういうちょっとした使い勝手の良さを積み重ねることって大事ですよね。仕事だとこういった工夫は細かすぎて仕様として載らなかったり、なかなかやる余裕が無いことも多いですが、できればやっていきたいものです。 自作のドロップダウンメニューをエスケープキーで閉じられるようにするとか、そういう感じのことをぜひやっていきましょう。