アー 今年もー 夏が来たー (来たね来たよ来たゼ 来たわ来たの)
というわけで、たのしい自由研究のコーナーです。
高すぎる目標
こういうものを作りたかった……。
レイアウト、ビジュアライゼーション、計算幾何アルゴリズムや、それらを駆使したUIコンポーネント実装の文献が充実してほしいなーと思っています(´・ω・`)
— 𝚃𝚎𝚛𝚌𝚎𝚕 (@tercel_s) November 30, 2018
たとえば、こういうUIを手作りするときのベストプラクティスってなかなか表に出てこないですよね、、、 pic.twitter.com/40l16jnX7r
とりあえずの成果
早速ネタバレしますが、ここまで作るだけで精一杯でした。 ホントゆるしてください。
CSS Grid + Angular CDK + Angular Animation の練習。のびちぢみ用のハンドルをつけてみる。 #Angular pic.twitter.com/YJ3AExIFQB
— 𝚃𝚎𝚛𝚌𝚎𝚕 (@tercel_s) August 3, 2019
努力の軌跡
研究動機
矩形状のコンポーネントを整然とグルーピングして表示するレイアウトについては、これまでの試みによって CSS グリッドで容易に実現可能であることが解っています。
ここでさっそく問題にブチ当たるわけですが、CSS ではレイアウトを組むことはできても動きをつけることはできません。
もののけ姫という映画の中で、主人公のアシタカが「シシ神は、傷は癒しても痣は消してくれなかった」とつぶやくシーンがありますが、まさに今そんな気持ち(伝われ)。
閑話休題。
ユーザ心理としては、画面上にいかにも動かせそうなコンポーネントがあったら「なんかマウスで摑めそう」って思うじゃないですか。 やはり何らかのインタラクションを作り込む方法を研究してみたいなぁと思いました。
動きは Angular CDK
Angular には、Component Dev Kit (CDK) という UI 向けの開発基盤が用意されています。
Over the course of working on Angular Material, an important goal has been crafting a general, reusable foundation upon which components can be built. We've reached the point now where we want to start sharing this foundation with everyone as a standalone package: the Angular CDK.
ちょっと何て書いてあるのかよくわからないのですが、これを使えばドラッグ & ドロップや無限スクロールといったインタラクションが簡単に作れるぞ! きっと!!
…… などと考えていた頃が僕にもありました。
Angular CDK の DragDropModule は、単なる 1次元のリストに対しては絶大な威力を発揮しますが、2次元のグリッドに適用しようとした途端に力を失います。
API 仕様を読むうちに、コンポーネントの座標をマウスに追従させる以外のほとんどすべての処理を自力で実装しなくてはならず、思った以上の手間暇がかかりそうな予感がしてきました。
悲劇の始まり
公式のサンプルを見ると、Reordering Lists というやりたい事に限りなく近いデモが置いてありました。
早速これをグリッドで囲まれた <div>
タグへの適用を試みましたが、おそらく内部の座標計算がバグっているのか狙った場所にドロップできない問題が速攻で発覚しました。 どうにもグリッドとの相性は今ひとつなようです。
幸い、cdkDrag
ディレクティブと、いくつかのイベント(cdkDragStarted
、cdkDragMoved
、cdkDragReleased
)は問題なく使えるようなので、これらをうまく組み合わせればドラッグ & ドロップのインタラクションを作り込めそうです。
グリッドとの重なり判定
まずはドラッグの対象物がグリッドのどの位置にあるかを調べる処理から作っていきましょう。
矩形同士の重なり判定については高速なアルゴリズムがあるので、それを実装したライブラリ (box-intersect) をありがたく使わせて頂くことに。
グリッドの領域との重なり判定を試したのが先週のこと。 pic.twitter.com/9cVn4u4uds
— 𝚃𝚎𝚛𝚌𝚎𝚕 (@tercel_s) July 31, 2019
DOM 要素の矩形の位置とサイズはElement.getBoundingClientRect() という API を使って取得できます。 ただし、処理コストがバカにならないため、一度取得した値はキャッシュしておき、要素の位置やサイズが変わったときだけ再取得するようにします。
また、要素のサイズ変更は ResizeObserver で監視します。 ちなみにブラウザのウィンドウの縁をドラッグすると、リサイズイベントが大量に飛び狂うので、rxjs
のdebounceTime
オペレータで適宜間引いてやります。
ここまで材料が揃えば、ドラッグ中のコンポーネントに最も近いセルを、以下の手順で容易に求めることができます。
① 行方向の当たり判定
② 列方向の当たり判定
③ 先頭セルの確定
④ 最寄の占有領域の確定
グリッドの縁にスナップさせるよ
続いて、コンポーネントをドロップしたあと、グリッドの縁にぴったりくっつける実装について考えていきます。
まずは既知のパラメータとして、ドラッグを開始する直前にコンポーネントが配置されていた位置を 、ドラッグによる総移動量を、そしてスナップさせたい目的地の座標を とおきます。
すると現在位置は と計算できるので、マウスを離した瞬間の から までの移動をアニメーションで補間してやることになります。
で、マウスを放したときに、適当なグリッドの領域に吸い付くようにぴったりフィットさせたいと思い、さっきまでAngularのアニメーションをあれこれ試していた。 pic.twitter.com/EanxNLn16r
— 𝚃𝚎𝚛𝚌𝚎𝚕 (@tercel_s) July 31, 2019
さりげないアニメーションはよいものです。 特に、にゅるんとした動きは地味に心地よい。
Angular アニメーションと奇蹟の融合
Angular のアニメーションは、状態遷移の契機となるトリガー、遷移前のスタイル、遷移後のスタイル、アニメーションの所要時間を記述する必要があります(詳しくは公式ドキュメント)。
特にハマりやすいのが遷移前後のスタイル定義です。 遷移の前と後で、配置先の番地(grid-row
、grid-column
)が変化することに加え、transform
が相対座標指定なので、どの位置を基準にしてアニメーションのパラメータを設定すればよいのか迷うかも知れません。
遷移前のスタイル
遷移後のスタイル
以下のように、パラメータtranslate_x
、translate_Y
を利用して遷移のスタイルを動的に制御します。
app.component.html
<div [@drag-drop]="{value: dragEnded ? 'dropped' : 'none', params: {translate_X: pos.x, translate_Y: pos.y}}" cdkDrag cdkDragBoundary=".container" (cdkDragStarted)="dragStarted($event)" (cdkDragMoved)="dragMoved($event)" (cdkDragReleased)="dragDropped($event)" [style.gridRowStart]="index.rowStart" [style.gridRowEnd]="index.rowEnd" [style.gridColumnStart]="index.columnStart" [style.gridColumnEnd]="index.columnEnd"> (略) </div>
app.component.ts
@Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'], animations: [ trigger('drag-drop', [ state('none', style({ transform: 'translate({{translate_X}}px, {{translate_Y}}px)', }), { params: { translate_X: 0, translate_Y: 0 } }), state('dropped', style({ transform: 'translate(0px, 0px)', })), transition('none => dropped', [ animate('0.1s'), ]), ]), ] }) export class AppComponent implements AfterViewInit, OnDestroy { pos: { x: number, y: number } = { x: 0, y: 0 }; index: { rowStart: number, rowEnd: number, columnStart: number, columnEnd: number } = { rowStart: 1, rowEnd: 3, columnStart: 1, columnEnd: 1 }; /* (略) */ }
translate_x
、translate_Y
には、フィールドpos.x
、pos.y
がそれぞれ紐づいています。 アニメーションをトリガーする直前に、pos.x
、pos.y
の値を更新すれば OK なはずです。
CSSグリッド + Angular CDK (DragDrop) + Angular アニメーションは鬼門だ(・ω・ lll)
— 𝚃𝚎𝚛𝚌𝚎𝚕 (@tercel_s) August 1, 2019
マウスを離した際のイベント処理の中で座標を再計算しつつアニメーションをトリガーすると、座標は合っているはずなのに、トンでもない位置から飛び込んでくるように見えてしまう。
一呼吸おくと、マトモになる。 pic.twitter.com/YNj3eLfpj9
上記のツイートにもある通り、cdkDragReleased
イベント内でスタイルにバインドする変数の更新とアニメーションのトリガーを一度に行うと、どういうわけか初期位置がおかしくなる事象に遭遇しました。
“一呼吸”の置き方は、最初、rxjsのhttps://t.co/FeO8VjX2eh()で値を送りつけ、subscribe()の中でアニメーションをトリガーする方法を考えたがうまくいかなかった。
— 𝚃𝚎𝚛𝚌𝚎𝚕 (@tercel_s) August 1, 2019
内部的にフラグを立てて、Window. requestAnimationFrame()でフラグを回収しつつアニメーションをトリガーする方法ならうまくいく。
これがベストな回避策かどうかは非常に怪しいですが、以下の2段階のフラグを設けてそれぞれタイミングをちょっとだけずらして更新することでアニメーションのバグが鎮まりました。
- アニメーションを実行する準備ができたことを表す内部フラグ
- アニメーションを直接トリガーするフラグ
Window.requestAnimationFrame() を使って内部フラグが立ったかどうかを毎フレームポーリングし、立っていたらアニメーションをトリガーするフラグを立てつつ内部フラグを回収するというややこしすぎるソリューションです。 どう考えても間違いなくバッドノウハウだと思います。
dragEnded = false; // アニメーションのトリガーとなるフラグ private readyToAnimation = false; // 世を忍ぶ仮のフラグ dragDropped(event: { source: CdkDrag }) { /* 略 */ this.readyToAnimation = true; // ここで仮のフラグを true にする } animationLoop() { window.requestAnimationFrame(() => this.animationLoop()); if (!this.readyToAnimation) { return; } this.dragEnded = true; // 仮のフラグを回収しつつ、アニメーションをトリガーする this.readyToAnimation = false; }