🚅

pointermoveで真面目にドラッグ

に公開

ここが真面目

タッチデバイス用に touch-action: none を付与しているので、コードを使われる際は CodePen でCSSもご確認ください。

コード
const target = document.querySelector('.draggable') as HTMLElement
const startScroll = { x: 0, y: 0 }
const currentScroll = { x: 0, y: 0 }
const originPosition = { x: 0, y: 0 }
const startPosition = { x: 0, y: 0 }
const currentPosition = { x: 0, y: 0 }
let isDragging = false
let rafId: null | number = null

const calculatePosition = () => {
  return {
    x: originPosition.x + currentPosition.x - startPosition.x + currentScroll.x - startScroll.x,
    y: originPosition.y + currentPosition.y - startPosition.y + currentScroll.y - startScroll.y,
  }
}

const updatePosition = () => {
  const { x, y } = calculatePosition()

  target.style.transform = `translate3d(${x}px, ${y}px, 0)`
  rafId = null
}

const requestUpdate = () => {
  if (!rafId) {
    rafId = requestAnimationFrame(updatePosition)
  }
}

const handleScroll = () => {
  currentScroll.x = window.scrollX
  currentScroll.y = window.scrollY

  requestUpdate()
}

const handlePointerDown = (e: PointerEvent) => {
  if (!e.isPrimary || isDragging) {
    return
  }

  isDragging = true
  startPosition.x = currentPosition.x = e.clientX
  startPosition.y = currentPosition.y = e.clientY
  startScroll.x = currentScroll.x = window.scrollX
  startScroll.y = currentScroll.y = window.scrollY

  target.setPointerCapture(e.pointerId)
  target.classList.add('is-dragging')

  window.addEventListener('scroll', handleScroll, { passive: true })
  target.addEventListener('pointermove', handlePointerMove, { passive: true })
}

const handlePointerMove = (e: PointerEvent) => {
  if (!e.isPrimary || !isDragging) {
    return
  }

  currentPosition.x = e.clientX
  currentPosition.y = e.clientY

  requestUpdate()
}

const cleanup = (e: PointerEvent) => {
  if (!e.isPrimary || !isDragging) {
    return
  }

  isDragging = false

  if (rafId) {
    cancelAnimationFrame(rafId)
    rafId = null
  }

  const { x, y } = calculatePosition()

  originPosition.x = x
  originPosition.y = y

  target.classList.remove('is-dragging')
  window.removeEventListener('scroll', handleScroll)
  target.removeEventListener('pointermove', handlePointerMove)
}

const release = (e: PointerEvent) => {
  target.releasePointerCapture(e.pointerId)
}

target.addEventListener('pointerdown', handlePointerDown, { passive: true })
target.addEventListener('pointerup', release)
target.addEventListener('pointercancel', release)
target.addEventListener('lostpointercapture', cleanup)

React

コード
index.tsx
import { useDrag } from './useDrag'

const DragExample = () => {
  const { ref, isDragging, transform } = useDrag<HTMLDivElement>()

  return (
    <div className="stage">
      <div
        ref={ref}
        className={`draggable${isDragging ? ' is-dragging' : ''}`}
        style={{ transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }}
      />
    </div>
  )
}
useDrag.ts
import { useEffect, useRef, useState } from 'react'

type Position = {
  x: number
  y: number
}

type ScrollState = {
  start: Position
  current: Position
}

type PointerPositionState = {
  origin: Position
  start: Position
  current: Position
}

export const useDrag = <T extends HTMLElement = HTMLElement>() => {
  const targetRef = useRef<T | null>(null)
  const scrollRef = useRef<ScrollState>({
    start: { x: 0, y: 0 },
    current: { x: 0, y: 0 }
  })
  const positionRef = useRef<PointerPositionState>({
    origin: { x: 0, y: 0 },
    start: { x: 0, y: 0 },
    current: { x: 0, y: 0 }
  })
  const rafIdRef = useRef<number | null>(null)
  const isDraggingRef = useRef(false)

  const [transform, setTransform] = useState<Position>({ x: 0, y: 0 })
  const [isDragging, setIsDragging] = useState(false)

  useEffect(() => {
    const node = targetRef.current

    if (!node) {
      return
    }

    const calculatePosition = (): Position => {
      const scroll = scrollRef.current
      const pointer = positionRef.current

      return {
        x: pointer.origin.x + pointer.current.x - pointer.start.x + scroll.current.x - scroll.start.x,
        y: pointer.origin.y + pointer.current.y - pointer.start.y + scroll.current.y - scroll.start.y
      }
    }

    const updatePosition = () => {
      rafIdRef.current = null
      setTransform(calculatePosition())
    }

    const requestUpdate = () => {
      if (!rafIdRef.current) {
        rafIdRef.current = window.requestAnimationFrame(updatePosition)
      }
    }

    const handleScroll = () => {
      const scroll = scrollRef.current

      scroll.current.x = window.scrollX
      scroll.current.y = window.scrollY

      requestUpdate()
    }

    const handlePointerDown = (event: PointerEvent) => {
      if (!event.isPrimary || isDraggingRef.current) {
        return
      }

      isDraggingRef.current = true
      setIsDragging(true)

      const pointer = positionRef.current
      const scroll = scrollRef.current

      pointer.start.x = pointer.current.x = event.clientX
      pointer.start.y = pointer.current.y = event.clientY
      scroll.start.x = scroll.current.x = window.scrollX
      scroll.start.y = scroll.current.y = window.scrollY

      node.setPointerCapture(event.pointerId)
      window.addEventListener('scroll', handleScroll, { passive: true })
      node.addEventListener('pointermove', handlePointerMove, { passive: true })
    }

    const handlePointerMove = (event: PointerEvent) => {
      if (!event.isPrimary || !isDraggingRef.current) {
        return
      }

      const pointer = positionRef.current

      pointer.current.x = event.clientX
      pointer.current.y = event.clientY

      requestUpdate()
    }

    const cleanup = (event: PointerEvent) => {
      if (!event.isPrimary || !isDraggingRef.current) {
        return
      }

      isDraggingRef.current = false
      setIsDragging(false)

      if (rafIdRef.current !== null) {
        cancelAnimationFrame(rafIdRef.current)
        rafIdRef.current = null
      }

      const nextPosition = calculatePosition()
      const pointer = positionRef.current

      pointer.origin.x = nextPosition.x
      pointer.origin.y = nextPosition.y

      setTransform(nextPosition)

      window.removeEventListener('scroll', handleScroll)
      node.removeEventListener('pointermove', handlePointerMove)
    }

    const releasePointer = (event: PointerEvent) => {
      if (!event.isPrimary) {
        return
      }

      node.releasePointerCapture(event.pointerId)
    }

    node.addEventListener('pointerdown', handlePointerDown, { passive: true })
    node.addEventListener('pointerup', releasePointer)
    node.addEventListener('pointercancel', releasePointer)
    node.addEventListener('lostpointercapture', cleanup)

    return () => {
      node.removeEventListener('pointerdown', handlePointerDown)
      node.removeEventListener('pointerup', releasePointer)
      node.removeEventListener('pointercancel', releasePointer)
      node.removeEventListener('lostpointercapture', cleanup)
      window.removeEventListener('scroll', handleScroll)
      node.removeEventListener('pointermove', handlePointerMove)

      if (rafIdRef.current !== null) {
        cancelAnimationFrame(rafIdRef.current)
        rafIdRef.current = null
      }

      isDraggingRef.current = false
    }
  }, [])

  return {
    ref: targetRef,
    isDragging,
    transform
  }
}

普段Reactを触ってないのでまともに書けているかわかりません。

Pointer Events

異なる入力デバイス(マウス・ペン・タッチ等)を統一して扱うためのイベント。

PointerEvent interface

interface PointerEvent : MouseEvent {
  constructor(DOMString type, optional PointerEventInit eventInitDict = {});
  readonly  attribute long      pointerId;
  readonly  attribute double    width;
  readonly  attribute double    height;
  readonly  attribute float     pressure;
  readonly  attribute float     tangentialPressure;
  readonly  attribute long      tiltX;
  readonly  attribute long      tiltY;
  readonly  attribute long      twist;
  readonly  attribute double    altitudeAngle;
  readonly  attribute double    azimuthAngle;
  readonly  attribute DOMString pointerType;
  readonly  attribute boolean   isPrimary;
  [SecureContext] sequence<PointerEvent> getCoalescedEvents();
  sequence<PointerEvent> getPredictedEvents();
}

仕様はこんな感じなので、MouseEvent が現代に向けて強化されたイメージ。主なポインターであることを示す isPrimary や、後述するPointer Captureの pointerId は使う機会がありそう。

主なイベント

MouseEvent でおなじみのイベントはそのまま使えるので、Pointer ならではのイベントをまとめています。最後に発火される lostpointercapture が素敵だね。down > move > up > lost が正規ルート。

イベント名 概要
pointercancel ポインターイベントがキャンセルされたとき
gotpointercapture ポインターをキャプチャしたとき
lostpointercapture ポインターが解放されたとき

Pointer Capture

これが Pointer ならではの便利な仕様。要素に対して setPointerCapture() することで、要素とポインターを紐づけられる。マウスイベントでは高速でドラッグすると切れてしまう問題があり、より大きい要素にmousemoveを設定する必要がありましたが、それが不要になります。

releasePointerCapture() で明示的に解放!

Pointer Captureの暗黙的な解放

Immediately after firing the pointerup or pointercancel events, the user agent MUST clear the pending pointer capture target override for the pointerId of the pointerup or pointercancel event that was just dispatched, and then run process pending pointer capture steps to fire lostpointercapture if necessary.

9.5 Implicit release of pointer capture

pointerup または pointercancel イベントを発火した直後、必要に応じて lostpointercapture を発火させるための処理を実行しなければならない。と、仕様で定義されてます。

つまり、releacePointerCapture() を実行しなくても、暗黙的にポインターが解放されて lostpointercapture が発火する仕様になっているMaybe。理屈ではわかっていても、明示的に解放したくなるのが人間の性。

const cleanup = (e: PointerEvent) => {}

const release = (e: PointerEvent) => {
  target.releasePointerCapture(e.pointerId)
}

target.addEventListener('pointerup', release)
target.addEventListener('pointercancel', release)
target.addEventListener('lostpointercapture', cleanup)

pointerdown に対する pointerup がないと気持ちが悪い、というお気持ち実装です。

movementX/Y がわからん

PointerEvent の本題からは逸れますが、仕様の把握に時間がかかった movement について。結論から言うと、いまいち信用できずに使いませんでしたが、そこに至る過程が以下になります。

最初は、直前の座標との差分を取れる movementX/Y が便利そうだと思って使うつもりでした。ただ、この値は整数で返ってくるようで、サブピクセルを丸めて加算し続けたらズレるときがあるのでは?という疑念が生じ、昔ながらの起点との差分を取る実装にしています。

// console.log
PointerEvent:clientX 340.7578125
MouseEvent:clientX 340
MouseEvent:movementX -3

Chromiumの実装を見ると MouseEvent の clientX/Y は整数に切り捨てられていて(型はdouble)、movementX/Y は整数として扱われてるっぽい?(C++がまったくなので空気を感じ取っています)

▼ third_party/blink/renderer/core/events/mouse_event.h

virtual double clientX() const { return std::floor(client_x_); }
virtual double clientY() const { return std::floor(client_y_); }

int movementX() const { return movement_delta_.x(); }
int movementY() const { return movement_delta_.y(); }

一方の PointerEvent は、Click系は MouseEvent と同様に切り捨てられた値、それ以外は浮動小数点数のまま返ってきそう。

▼ third_party/blink/renderer/core/events/pointer_event.h

double clientX() const override {
  if (ShouldHaveIntegerCoordinates())
    return MouseEvent::clientX();
  return client_x_;
}

▼ third_party/blink/renderer/core/events/pointer_event.cc

bool PointerEvent::ShouldHaveIntegerCoordinates() const {
  if (type() == event_type_names::kClick ||
      type() == event_type_names::kContextmenu ||
      type() == event_type_names::kAuxclick) {
    return true;
  }
  return false;
}

実際に確認したらそんな挙動でした。

pointermove: 396.28515625
click: 396
イベント
MouseEvent 整数
PointerEvent 浮動小数点数

ただ、整数を加算する際に誤差があっても、大量に呼ばれれば上下の誤差がある程度は相殺されるため、そこまで気にしなくてもいいかも。試しに両方の実装で比べても、1px程度しか差はありませんでした。

client: { x: 248.48828125, y: 323.94140625 }
movement: { x: 248, y: 325 }

警告: ブラウザーは movementX と screenX に 仕様で定義されているものとは異なる単位を使用します。ブラウザーとオペレーティングシステムによって、 movementX の単位は物理ピクセルであったり、論理ピクセルであったり、 CSS ピクセルであったりします。

movementX - MDN

これは未検証ですが、MDNにある警告も載せておきます。

長くなりましたが、細部にこだわるなら movementX/Y を使わないほうが良いかな、ぐらいのフワッとした結論。

Discussion