pointermoveで真面目にドラッグ
ここが真面目
- 軽快な動作:requestAnimationFrame で描画
- 高速移動で切れない:setPointerCapture
- スクロールしても切れない:scroll でも描画
- シンプルな後処理:lostpointercapture で releasePointerCapture を拾う設計
- なんか100行近くなってしまった
タッチデバイス用に 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
コード
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>
)
}
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.
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 ピクセルであったりします。
これは未検証ですが、MDNにある警告も載せておきます。
長くなりましたが、細部にこだわるなら movementX/Y を使わないほうが良いかな、ぐらいのフワッとした結論。
Discussion