ドラッグ可能なオブジェクトを実装する
(redblobgames.com)- Red Blob Gamesのインタラクティブなダイアグラムでは、読者がオブジェクトを直接動かすと画面が即座に反応する必要があるため、入力処理を Pointer Events 中心に整理し、マウスとタッチの両方を扱う
- 以前の
mousedown/mouseup/mousemove方式では、ポインターがオブジェクトの外に出たり、外でボタンを離したりすると ドラッグ状態が壊れることがあり、グローバルハンドラーのような補完が必要だった - タッチ入力はイベント伝達方式がマウスと異なり、1本指ドラッグがページスクロールまで引き起こすため、
touchstartでpreventDefault()を呼び出して衝突を減らす - 最終的なレシピは
pointerdown、pointermove、pointerup、pointercancelとsetPointerCapture()を組み合わせ、左ボタンと開始オフセットを確認してオブジェクトが突然飛ばないようにする - テキスト・画像を含むオブジェクト、マルチタッチ、ネストしたドラッグ、
<canvas>、hover と drag の組み合わせ、lostpointercaptureのような プラットフォームごとの例外 は、実際の UI では別途処理が必要になる
ドラッグ入力ハンドラーの目標
- 多くのインタラクティブなページには、読者が直接動かす ドラッグ可能なオブジェクト があり、ダイアグラムはオブジェクト位置の変化に反応する必要がある
- 目標は、マウス入力とタッチ入力の両方で動作する入力イベントハンドラーを作ること
- 基本の流れは、
pointerdownでドラッグを開始し、pointermoveで位置を更新し、pointerupまたはキャンセルイベントでドラッグを終了する構成である - 実際の実装では、マウスボタン、複数指タッチ、イベント伝達先、右クリックのコンテキストメニューのため、単純なモデルより複雑になる
- 基本レシピは次の要素を組み合わせる
pointerdownでevent.button !== 0なら return して 主ボタン のみ処理- イベント座標をダイアグラム座標に変換
- 開始時にオブジェクト位置とポインター位置の差である
dx、dyを保存 el.setPointerCapture(event.pointerId)でポインターキャプチャを設定pointermoveで保存したオフセットを加えてstate.posを更新pointerup、pointercancelでstate.dragging = nulltouchstartでpreventDefault()を呼び出す
- このレシピは入力イベントハンドラーであり、実際の出力処理は別の状態ハンドラーに接続する必要がある
- 完全なコードは examples page にあり、オブジェクト移動ではなく数値の左右ドラッグ、キャンバスペインティング、制限付きドラッグの例も含まれる
- テスト範囲は Gecko/Firefox、Blink/Chrome、WebKit/Safari の Mac、Windows、Linux、Android、iPhone、iPad である
- hover 可能な stylus、ハイブリッドなタッチ+マウス機器、音声入力はテストしていない
- この方式は、要素を別の要素の上に置く HTML Drag and Drop API ではなく、ダイアグラムではマウス・タッチイベントを直接読み取る必要がある
マウスイベントだけを使う場合の限界
- 初期実装は
mousedown、mouseup、mousemoveをドラッグ要素に直接付ける方式だった - 見た目には動作するが、2つの問題が発生する
- ポインターを素早く動かすと、もはや円の上にいないためイベントを受け取れない
- 円の外でボタンを離すと、
dragging状態に閉じ込められることがある
- 改善策は、
mousedownは円で受け、ドラッグ中のmousemoveとmouseupはdocumentに一時的に付ける方式である mouseupでdocumentに付けたmousemove、mouseupハンドラーを削除すると、マウスドラッグはより安定して動作する- この方式はマウスだけを処理し、タッチイベント は扱わない
タッチイベントの違い
- タッチ入力では
mousedown、mouseup、mousemoveの代わりにtouchstart、touchend、touchmoveを使う - タッチイベントは
touchstartの後、自動的に キャプチャ され、すべてのtouchmoveが元の要素に配信される - この特性のため、マウスイベントのように
documentにグローバルイベントハンドラーを一時的に付ける必要がない - ブラウザーがタッチシーケンスをキャンセルする必要があるときは
touchcancelを送る - タッチドラッグ実装では、
touchstartでpreventDefault()を呼び出し、touchmoveでevent.changedTouches[0]の座標を使って位置を更新する - この方式はタッチだけを処理し、マウスイベント は処理しない
Pointer Eventsでマウスとタッチを統合する
- マウスとタッチの両方を処理するには多くのイベントハンドラーが必要で、2021年以前はそのような方式を使っていた
- 2011〜2014年には d3 プロジェクトで d3-drag を使い、d3 を使わないプロジェクトでは別個のマウス+タッチコードを書いていた
- Pointer Events は、マウスとタッチの処理を統合して単純化する
- MS IE は 2012年にサポートを追加
- Chrome は 2017年、Firefox は 2018年、Safari は 2020年にサポートを追加
- ブラウザーの動作は時間とともに変化し、2017年の Chrome では一部イベントがデフォルトで passive mode に変わり、オブジェクトをドラッグしようとするとページがスクロールする問題が生じた
- Safari と Firefox も 2018年に同じ変更を適用した
- pointer capture を使うと、マウスイベントで必要だった
documentグローバルハンドラーの追加・削除なしに、より単純なロジックで処理できる
Pointer Captureでポインター追跡を維持する
- Pointer capture は、ポインターが円、ダイアグラム、ブラウザーウィンドウの外に出ても追跡を継続できるようにする
- マウスイベントでは同じ効果のために
documentにイベントハンドラーを付ける必要があったが、Pointer Events ではsetPointerCapture(event.pointerId)のほうが単純である - ポインターキャプチャがないと、素早くドラッグしたりダイアグラムの外に出て戻ったりしたときに、ドラッグが止まることがある
- ダイアグラムの外でボタンを離した場合や、Alt+Tab で別ウィンドウに移動した場合も、キャプチャの有無によってドラッグ終了処理が変わる
- タッチデバイスではポインターキャプチャがデフォルト動作だが、マウスでは明示的に設定する必要がある
タッチスクロールと preventDefault()
- タッチデバイスでの1本指ドラッグは、デフォルトではページスクロールを引き起こす
- 同じ1本指ドラッグで円も動くと、スクロールとオブジェクトドラッグ が同時に発生する
- 最も単純な解決策はダイアグラムに CSS
touch-action: noneを適用することだが、この場合はダイアグラムのどこでもスクロールできなくなる - 望ましい動作は、円をドラッグしているときだけスクロールを防ぎ、ダイアグラム自体をドラッグしているときはスクロールを許可すること
- 比較結果は次のとおり
- ダイアグラム全体に
touch-action: none: 円ドラッグとダイアグラムドラッグの両方でスクロール停止 - デフォルト値: どちらもスクロールを防げない
- 円だけに
touch-action: none: 期待どおりには動作しない touchstartでpreventDefault(): 円ドラッグ中のスクロールだけを防ぎ、ダイアグラムのスクロールは許可
- ダイアグラム全体に
preventDefault()はpointerstartではなくtouchstartに付ける- この方式はほとんどの状況で動作するが、desktop Firefox の “mobile emulation mode” では動作せず、Android の mobile Firefox では動作した
ドラッグ開始オフセットの保存
- オブジェクトの端をつかんでドラッグするときに、オブジェクトの中心がポインタ位置へ瞬間移動すると操作感が悪くなる
- 解決策は、ドラッグ開始時にオブジェクト中心とポインタ位置の相対位置を保存すること
- その後の移動では、現在のポインタ座標に保存した
dx、dyを足してオブジェクト位置を計算する - この方式では、
state.draggingを単なるtrue/falseではなく、{dx, dy}のような相対位置情報に変える - マウスでは端をつかんだときのジャンプ差がよく見えるが、タッチでは指で隠れるため目立ちにくいことがある
- 関連する細かなディテールは little details のページにもまとまっている
コンテキストメニューとマウスボタン
- コンテキストメニューの有効化方法はプラットフォームごとに異なる
- Windows: 右クリック down+up、Shift+F10
- Linux: 右ボタン down、Shift+F10
- Mac: 右ボタン down、Ctrl+左クリック
- iOS: テキストでのみ long press
- Android: どこでも long press
- 問題は、
pointerdownは見えてもpointerupが常に見えるとは限らないこと - 実際にはボタンが離されているのに、コード上ではまだ押された状態だと判断してしまうことがある
- 対応策は、右ボタンを無視して左マウスボタンでのみ
dragging状態を設定すること - Pointer Events 仕様の
button属性ではbutton = 0が primary button を意味し、これを使って中ボタン・右ボタンを除外する - Mac の Chrome と Safari では Ctrl+クリックが左ボタンの
pointerdownのように入ってくることがあるため、event.ctrlKeyも確認する必要がある - 選択肢は次のとおり
contextmenuでpreventDefault()を使ってメニューを防げるが、デフォルトの中クリックやドラッグ動作と衝突する可能性がある- 左ボタンのみでドラッグすれば、右クリックまわりの差異の大半を避けられる
- Mac の Ctrl+クリックまで避けるなら、
ctrlKeyも除外する必要がある
- ボタン変化が
pointerdownやpointerupとして伝わらなくても、pointermoveのボタン状態から一部を確認できる
テキストや画像を含むオブジェクト
- ドラッグ可能な要素の中にテキストや画像がある場合は追加処理が必要になる
- 円をドラッグしている間に内部テキストが選択されることがある
- 解決策は CSS の
user-select: noneを適用すること- 常に適用すると内部テキストは常時選択できなくなる
- ドラッグ中だけ適用すれば、通常時はテキスト選択を可能にできる
- 2023年初頭時点では Safari は unprefixed の
user-selectをまだサポートしておらず、webkitUserSelectもあわせて設定する必要がある - Windows、Linux、Mac はテキストや画像のアプリケーション間 drag and drop をサポートしており、これがオブジェクトのドラッグと衝突する
dragstartでpreventDefault()を呼び出すと、選択されたテキストや画像のドラッグよりオブジェクトドラッグを優先できる- ブラウザや OS によって動作は少しずつ異なる
複数ポインタと同時ドラッグ
- 複数の指や複数のマウスで複数オブジェクトを同時にドラッグできるかはエッジケースである
- タッチでは、提示されたコードはすでに複数オブジェクトの同時ドラッグに対応している
- 2本の指で同じオブジェクトをドラッグする場合は別途処理が必要になる
- 解決策は、
pointerdownでevent.pointerIdをstate.draggingまたは状態に保存し、pointermoveで同じpointerIdでなければ無視すること - この実装例は本文には完全には反映されておらず、canvas dragging test で確認できる
- マウスについては、Pointer Events 仕様では一般的な OS とユーザーエージェントは通常、複数マウス入力という概念を持たないと述べている
- トラックパッドと外付けマウスが同時にあっても通常は1つのマウスポインタとして扱われるため、別々のマウスで別々のオブジェクトをドラッグする方法はないと考えられる
複数のマウスボタンを同時に押した場合
- 複数のマウスボタンを同時に押すと、Pointer Events と Mouse Events では動作が異なる
- Mouse Events は各ボタン押下ごとに
mousedownを、各ボタン解放ごとにmouseupを送る - Pointer Events 仕様によれば、最初に押されたボタンで
pointerdownが発生し、最後に解放されたボタンでpointerupが発生する - このため、
pointerdownとpointerupが異なるボタン状態で発生することがある - たとえば左ボタンを押し、次に右ボタンを押し、その後左ボタンを離すと、オブジェクトがドラッグ状態のまま残ることがある
- 解決策は、
pointermoveでevent.buttons & 1により左ボタン状態を確認し、もう押されていなければend(event)を呼び出すこと - ポインタキャプチャは、すべてのボタンを離すまで継続する可能性があり、明示的にキャプチャを解除しない限り続くことがある
- この実装は、この問題や複数のエッジケースをすべて処理しているわけではない
ネストしたドラッグ要素
- ドラッグ可能な要素の中に別のドラッグ可能な要素があると、内側と外側の要素の両方がドラッグを処理してしまう
- 解決策は、内側のドラッグ要素で
event.stopPropagation()を呼び出し、イベントが外側のドラッグ要素へ伝播しないようにすること pointerdownとpointermoveの両方で伝播を止める方法が使われる- デモは本文にはないが、Vue playground example で、赤いドラッグ要素が黄色いドラッグ要素の子になっている例を確認できる
Canvasでのドラッグ
- 通常はSVGを使うが、
<canvas>ではドラッグ可能な要素自体にイベントハンドラーやマウスポインターの形を設定できない - 2D CanvasやWebGLでは、イベントハンドラーを
<canvas>に付け、座標がドラッグ可能なオブジェクト上にあるかを自分で判定する必要がある - 処理方法は次のとおり
pointerdown,touchstart,dragstart: ドラッグ可能なオブジェクト上でなければすぐにreturnするpointermove: ドラッグ可能なオブジェクト上かどうかに応じてカーソルを設定する
- デモはexamples pageにある
マウスHoverとタッチDragの組み合わせ
- ある種のダイアグラムは、マウスではボタンを押さないhoverで反応し、タッチデバイスではhoverがないためdragで反応する必要がある
- 基本のレシピでは、マウスとタッチの両方でdragを想定している
- マウスhoverとタッチdragを併用するには、
pointermoveでif (!state.dragging)という条件を削除する - Responsive Design pageでは、マウスで行の上にhoverするとレイアウトが変わり、タッチではdragでレイアウトが変わる
- Hexagons Guideでも、多くのダイアグラムがマウスhoverとタッチdragを併用している
- タッチで項目AからBへドラッグするとき、キャプチャが維持されるとmoveイベントがAに送られるため、Bをハイライトするにはキャプチャの解除が必要になることがある
Toggle Paintとドラッグ状態の拡張
- Rounded Cell Painterでは、
pointerdownが初期のペイント色をキャプチャする - その後、
pointerupまでのすべてのpointermoveは同じペイント色を使う - そのため、
state.draggingには初期のx,yだけでなく、初期のペイント色も含める必要がある - デモはexamples pageにある
lostpointercaptureのエッジケース
lostpointercaptureは、ポインターキャプチャを失ったことを検知するために使える- どのような状況で発生し、何をすべきかは、まだ完全には整理されていない
- コメントで取り上げられていたシナリオは次のとおり
- 左ボタンで円のドラッグを開始
- 左ボタンを押したままドラッグ領域の外へ移動
- 右ボタンを押したあと、左ボタンを離す
- ドラッグ領域の外のどこかをクリック
- standardでは、ポインターキャプチャが解除されたあと、ユーザーエージェントは
lostpointercaptureイベントを必ず発生させなければならないとしている - 実際のブラウザーの挙動はかなり異なり、
lostpointercaptureが発生するシステムもあれば、別の場所をクリックするまで発生しないシステムもある
1件のコメント
Hacker News の意見
ドラッグを扱った記事ですが、Amit が挙げている落とし穴はほぼ全部経験していて、同じ解決策にたどり着きました。素晴らしい記事です。
ユーザーの立場では、ドラッグによる並べ替えはとても自然に見えますが、サイズがばらばらの項目、項目間のプレースホルダー生成、エッジ検出、ボックスフィット・アルゴリズムの沼まで踏み込むと、自前で実装するのはかなり難しい部類に入ります。
マウスが動くたびに、それらの位置のうち現在のドラッグオブジェクトの位置に最も近いものを選び、選ばれたレイアウトをレンダリングすればよいのです。複雑なレイアウトやリフローがあっても、感触よく動作します。
最終的には、カードを持ち上げるとそのカードがボードから削除されたかのようにレイアウトを計算するようにしました。そうすると簡単になります。マウスを乗せた位置にあったものを押しのけるだけでよいからです。
それでも例外処理はものすごく多く、特にまだデータモデルに「コミット」されていない新しいカードを編集し始めるときは厄介でした。既存のカードを押しのけてゴーストカードの場所を作れるオプションも追加する必要がありました。
手作業で処理しなければならない例外が多いと認めることが助けになります。もっと一般的で均質な解決策があるはずだ、という姿勢で臨むと設計で堂々巡りになります。いつかさまざまな例外ケースをブログ記事にまとめるべきかもしれません。
さらに良いレイアウト方式も見つけました。不要な計算を減らし、レイアウトエンジンをより柔軟でユーザーフレンドリーにしてくれます。データモデルの表現をどう選ぶかが、解決策に非常に大きな違いを生むのは驚きです。
[1] https://www.ussherpress.com/cardbuddy/
記事ではキャンセル処理は扱っていないようです。たとえば Windows では、Escape を押すとドラッグがキャンセルされる慣例があります。
場合によっては、定義された領域の外でマウスを離したらドラッグをキャンセルしたいこともあります。キャンセルはユーザーに、より素早い取り消し、あるいは「やっぱりドラッグするつもりじゃなかった」といった逃げ道を与えます。
そのため、別の取り消し機能がなくても、ドラッグ開始時点の元の状態を保存しておき、キャンセルされたら復元する必要があります。
領域外ドラッグキャンセルの場合には、キャンセル解除もあります。ポインタが領域外にあるときは元の状態に戻ったかのように視覚的に見せ、ポインタが再び領域内に入るとドラッグを再開する方式です。実際のキャンセルはマウスを離したときに起こりますが、ドラッグ中にもキャンセルされることを事前に視覚的に知らせるわけです。
Microsoft がいまだにこれを良いUI設計だと信じているのが信じられません。ユーザーを完全に混乱させ、何が起きているのか分からず左マウスボタンを離すのを怖がったり、ヨーヨーのように繰り返したりする状況も見られます。
キャンセルはしばしば見過ごされる大きな問題で、単純ではないという点には同意します。一般ユーザーは ESC が役に立つことすら知りません。
Ardour [0] の GUI でさまざまなオブジェクトをドラッグする作業を20年やってきて学んだ追加の細部があります。すべてのボタン押下・解放イベントを、動きのないドラッグイベントとして処理すべきです。
つまり、押下は常にドラッグを開始し、解放で終了する。そして解放を処理するコードが、動きなしという条件を特別扱いする、という方式です。
[0] https://ardour.org/
通常の
mousedownとmouseupのハンドラを置き、クリックなのかドラッグ開始なのか、あるいは別の動作なのかを判断するために、約150msの設定可能なタイムアウトを使います。mouseupが発生するか、マウスが画面外に出ていて進行中のドラッグがあればstopdragします。私にとってドラッグは、いくつもの特殊ケースの一つにすぎません。はるか昔、ミネアポリスに NSF が支援する geometry center がありました。計算群論の学会が開かれ、周辺分野からマスコット的な人たちが何人か招かれました。
私は代数幾何学システムを書いていたおかげで招待され、-20°F の寒さの中、非常に早く起きてその日に使う Silicon Graphics ワークステーションを確保し、群の生成元と関係をよりよく理解するためのゲームをコーディングしました。
そのゲームにはドラッグが入っていました。その結果、午前2時まで続いた会話が楽しいものになりました。私の考えでは、ドラッグが現実世界の物理法則に従う必要はなく、ドラッグは非常に強い LSD のタブのような感覚であるべきでした。皆がこれに夢中になり、それぞれアイデアを出しました。
この記事は、Web ブラウザで発生するさまざまな例外を脇に置けば、比較的基本的なドラッグを実装している。ガイドラインにスナップする、境界や他のオブジェクトとの衝突を防ぐ、ドラッグ操作に反応してサイズや位置が変わるアニメーション付きのドロップ対象など、制約のあるドラッグの資料があるのか気になる。
以前、円形の時計を長さの異なる扇形のピースに分けて、集中/休憩の区間を定義するカスタム Pomodoro タイマー UI を作りたいと思っていた。その扇形ピースのドラッグによる並べ替えの実装は、あまり進まなかった。
制約システムをイベント処理システムから切り離したかった。jquery-ui のようなライブラリは両者を結び付けてしまうため、イベント処理システムが可能なすべての制約を知っている必要がある。jquery-ui は境界ボックス、軸、正方形グリッド、DOM 要素へのスナップをサポートしている。だが、六角形グリッド、対数スケールのグリッド、円形境界にスナップしたい場合はサポートされていない。
コード内に
state.pos = …が見えるが、そこが状態を設定している場所だ。制約ではposを setter の背後に隠す。そうすれば、ドラッグイベント処理コードがどの制約が必要かを知らなくても、setter が制約を適用できる。ページを更新して制約の例を見せるべきだと思う。コードレシピでこの側面に触れるのを完全に忘れていた。
古い制約の例は https://www.redblobgames.com/articles/curved-paths/making-of... にあり、衝突回避の例は https://redblobgames.github.io/circular-obstacle-pathfinding... にある。ただし、ドラッグによる並べ替えやアニメーション付きドロップ対象はまだ試していない。
このサイトには私も感謝している。Uber で地理分析用の六角形システムを設計したとき、https://www.redblobgames.com/grids/hexagons/ を大いに参考にした。
きちんとしたインタラクションを扱うときに出てくる落とし穴や注意点をよく整理した記事だ。
より「すぐ使える」ものとしては、複数のプロジェクトで interactjs をかなり長く使ってきた。
みなさんありがとう。今日 HN に載って驚いた。質問には答えられる。
draggable="true"属性とドラッグイベントを見たことがあるのか気になる。組み込み機能がこのコードと比べて足りない点があるのかも気になる。
https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement...
https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement...
https://developer.mozilla.org/en-US/docs/Web/HTML/Global_att...
簡単な質問がある。
dragstartのような組み込み DOM イベントで、ドラッグの動きを 1 つの軸に制限するにはどうすればよいのか気になる。dragstart/dragenter/dragover/dropイベントでドラッグ&ドロップ機能を実装したが、動きを x 軸に制限する手早い方法を見つけられなかった。JQuery のドラッグ&ドロップ API はこれをサポートしていたと記憶している。ネイティブの DOM イベント/API だけを使おうとしているので、関連情報や手がかりがあればありがたい。Safari が 2020 年にサポートを追加したことを考えると、ブラウザがこれをサポートしていないのではと心配しないのか気になる。macOS Mojave ユーザーも Safari 13 や 14 を使っているはずなので、Safari 12 は実際にはもう使われていないことを願うが、一般の Web ユーザー向けのプロダクションアプリで、ドラッグのような単純な機能が動かないとかなりまずい。
ドラッグ中に
documentにイベントハンドラーを追加するのは昔からの慣行であり、ブラウザは特定のユースケースを単純化しようとする新機能を追加し続けているが、それぞれ例外や落とし穴がある。記事でも、まだ完全には扱っていないと言っている。最終結果にもポインターイベントとタッチイベントの組み合わせが残っている。コードが減るという意味での「単純さ」が、追加の例外、低いブラウザサポート、そして開発者がマウスイベントよりも理解や文書化が進んでいないかもしれないポインターイベントの詳細やブラウザ差異を知る必要があるというコストを払うほど価値があるのか気になる。
抜けているものが1つある:アクセシビリティ。キーボード操作でドラッグをどう可能にすべきだろうか?
私の例でアクセシビリティの答えは、その作業を完了するためのキーボード操作を追加で提供することだ。可能なら、キーを押し続けなくてもよい方式が望ましい。たとえばショートカットキーでパンモードに入り、矢印キーでビューポートをキャンバス上で移動させれば解決する。
ドラッグ可能なオブジェクトの場合、作業は順序変更だ。これをアクセシブルにするには、順序を変える代替手段を提供すればよい。たとえば TAB でオブジェクト間のフォーカスを巡回し、キーでフォーカスされたオブジェクトを選択したあと、矢印キーでドラッグのプレビューをその方向の最も近い有効位置へ移動する、といった具合だ。
つまり、ドラッグ実装の中にキーボード対応を無理に押し込むのではなく、ドラッグ機能とは別に意味のあるキーボード機能を追加すべきだ。
いいね。今週の初めに Vue アプリで SVG 要素のドラッグを実装していたのだが、筆者が説明したほぼすべてを同じ順序で自分で発見する必要があり、最終的にもほぼ同じコンポーネントになった。
ただし私は関数コンポーネントではなく、
useDraggingという composable ユーティリティとして書いた。