@angular/cdk でグリッドの上をドラッグ & ドロップ

アー 今年もー 夏が来たー (来たね来たよ来たゼ 来たわ来たの)

というわけで、たのしい自由研究のコーナーです。

高すぎる目標

こういうものを作りたかった……。


とりあえずの成果

早速ネタバレしますが、ここまで作るだけで精一杯でした。 ホントゆるしてください

努力の軌跡

研究動機

矩形状のコンポーネントを整然とグルーピングして表示するレイアウトについては、これまでの試みによって CSS グリッドで容易に実現可能であることが解っています。

tercel-tech.hatenablog.com

ここでさっそく問題にブチ当たるわけですが、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 仕様を読むうちに、コンポーネントの座標をマウスに追従させる以外のほとんどすべての処理を自力で実装しなくてはならず、思った以上の手間暇がかかりそうな予感がしてきました。
f:id:tercel_s:20190803171312p:plain

悲劇の始まり

公式のサンプルを見ると、Reordering Lists というやりたい事に限りなく近いデモが置いてありました。

早速これをグリッドで囲まれた <div> タグへの適用を試みましたが、おそらく内部の座標計算がバグっているのか狙った場所にドロップできない問題が速攻で発覚しました。 どうにもグリッドとの相性は今ひとつなようです。

幸い、cdkDragディレクティブと、いくつかのイベント(cdkDragStartedcdkDragMovedcdkDragReleased)は問題なく使えるようなので、これらをうまく組み合わせればドラッグ & ドロップのインタラクションを作り込めそうです。

グリッドとの重なり判定

まずはドラッグの対象物がグリッドのどの位置にあるかを調べる処理から作っていきましょう。

矩形同士の重なり判定については高速なアルゴリズムがあるので、それを実装したライブラリ (box-intersect) をありがたく使わせて頂くことに。

DOM 要素の矩形の位置とサイズはElement.getBoundingClientRect() という API を使って取得できます。 ただし、処理コストがバカにならないため、一度取得した値はキャッシュしておき、要素の位置やサイズが変わったときだけ再取得するようにします。

また、要素のサイズ変更は ResizeObserver で監視します。 ちなみにブラウザのウィンドウの縁をドラッグすると、リサイズイベントが大量に飛び狂うので、rxjsdebounceTimeオペレータで適宜間引いてやります。

ここまで材料が揃えば、ドラッグ中のコンポーネントに最も近いセルを、以下の手順で容易に求めることができます。

① 行方向の当たり判定
f:id:tercel_s:20190803195214p:plain

② 列方向の当たり判定
f:id:tercel_s:20190803195231p:plain

③ 先頭セルの確定
f:id:tercel_s:20190803195249p:plain

④ 最寄の占有領域の確定
f:id:tercel_s:20190803195306p:plain

グリッドの縁にスナップさせるよ

続いて、コンポーネントをドロップしたあと、グリッドのへりにぴったりくっつける実装について考えていきます。

まずは既知のパラメータとして、ドラッグを開始する直前にコンポーネントが配置されていた位置を  { \boldsymbol{x}_{0}}、ドラッグによる総移動量を { \boldsymbol{d}}、そしてスナップさせたい目的地の座標を  { \boldsymbol{x}_{1}}とおきます。

すると現在位置は  { \boldsymbol{x}_{0} + \boldsymbol{d}} と計算できるので、マウスを離した瞬間の  { \boldsymbol{x}_{0} + \boldsymbol{d}} から  { \boldsymbol{x}_{1}} までの移動をアニメーションで補間してやることになります。

f:id:tercel_s:20190803174219p:plain

さりげないアニメーションはよいものです。 特に、にゅるんとした動きは地味に心地よい。

Angular アニメーションと奇蹟の融合

Angular のアニメーションは、状態遷移の契機となるトリガー、遷移前のスタイル、遷移後のスタイル、アニメーションの所要時間を記述する必要があります(詳しくは公式ドキュメント)。

特にハマりやすいのが遷移前後のスタイル定義です。 遷移の前と後で、配置先の番地(grid-rowgrid-column)が変化することに加え、transform が相対座標指定なので、どの位置を基準にしてアニメーションのパラメータを設定すればよいのか迷うかも知れません。

遷移前のスタイル
f:id:tercel_s:20190803182250p:plain

遷移後のスタイル
f:id:tercel_s:20190803182308p:plain

以下のように、パラメータtranslate_xtranslate_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_xtranslate_Yには、フィールドpos.xpos.y がそれぞれ紐づいています。 アニメーションをトリガーする直前に、pos.xpos.yの値を更新すれば OK なはずです。

上記のツイートにもある通り、cdkDragReleasedイベント内でスタイルにバインドする変数の更新とアニメーションのトリガーを一度に行うと、どういうわけか初期位置がおかしくなる事象に遭遇しました。

これがベストな回避策かどうかは非常に怪しいですが、以下の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;
  }

まとめ

まだ全く目標に到達していないものの、なんだかんだでブログの記事ひとつ分を消費してしまいました。

こういうインタラクションを売り物に組み込んでいるエンジニアのみなさまには頭が下がる思いです。

とりあえず github にソースを置きました。

では、また。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.