lacolaco's marginalia

Angular: CDK Overlay でフェードアウトするポップアップを作る

Angular CDK の Overlay APIは非常に便利で、ポップアップのように画面の中でレイヤーが一段上のオーバーレイ表示を簡単に実現できる。

このOverlayを使ったことがある開発者なら一度はぶつかったことのある壁といえば、オーバーレイに表示したコンポーネントが閉じるときにどうやってアニメーションするかだろう。たとえば、ユーザーになにかのメッセージを一定時間だけ表示してフェードアウトする、いわゆるトーストメッセージというやつを作ってみよう。

テキストを表示して一定時間後にフェードアウトするトーストメッセージ
テキストを表示して一定時間後にフェードアウトするトーストメッセージ

トーストとして表示されるビューをToastContainerコンポーネントとし、素朴にCDK Overlayを使うと次のようなコードになる。動的に生成したコンポーネントはそれを破棄するのも開発者の責任である。overlay.dispose() を呼び出すことでオーバーレイとその上に表示されていたコンポーネントがすべて破棄される。これでトーストが一定時間後に消えるようになった。

@Component({
  template: `
    <div>{{message()}}</div>
  `,
  styles: `
  :host {
    display: block;
    padding: 16px;
    border-radius: 8px;
    background-color: lightblue;
  }
  `,
})
export class ToastContainer {
  readonly message = input('');
}

@Component({
  selector: 'app-root',
  template: `
  <h1>Toast Demo</h1>

  <button (click)="openToast()">open toast</button>
  `,
})
export class App {
  readonly #cdkOverlay = inject(Overlay);

  openToast() {
    const overlay = this.#cdkOverlay.create({
      positionStrategy: this.#cdkOverlay
        .position()
        .global()
        .centerHorizontally()
        .centerVertically(),
    });
    const toast = overlay.attach(new ComponentPortal(ToastContainer));
    // set toast message
    toast.setInput('message', 'Hello Angular!');
    // show toast for 2000ms
    setTimeout(() => {
      overlay.dispose();
    }, 2000);
  }
}

さて、ここでトーストが消えるときにフェードアウトアニメーションを挟むとする。トーストとして表示するのは2000msだが、そこでオーバーレイを消してしまうとアニメーションするまえにコンポーネントが消えてしまう。アニメーションの完了を待ってからオーバーレイを破棄するにはどうするといいだろうか。

今回紹介するのは CSS Animations APIの animationend イベントを使ったアプローチである。

その名前のとおり、CSSアニメーションが終了したときにJavaScriptを実行できる機能で、当然Angularのコンポーネントでも使用できる。このイベントを使い、ToastContainerが呼び出し元に対してトーストの退出が終わったことを通知できるようにしよう。この方法は、Angular Materialで同様のUIを提供しているSnackBar APIの実装をベースにしている。

registerOnCompleteExit

まずは呼び出し元との間のインターフェースを作成する。アニメーションが終了してトーストが完全に消えたときに呼び出されるコールバック関数を、registerOnCompleteExitというメソッドで登録できるようにしておこう。この命名はAngularのControlValueAccessorで定義されているregisterOnChangeを意識して似せている。また、トーストの退出アニメーションを開始するためのexitメソッドも用意する。

呼び出し元では、registerOnCompleteExitメソッドに渡すコールバック関数でオーバーレイの破棄を行うようにしておき、トーストの表示時間が経ったあとにexitメソッドを呼び出している。これでインターフェースは揃った。

export class ToastContainer {
	//...

  #onCompleteExit?: () => void;

  registerOnCompleteExit(fn: () => void) {
    this.#onCompleteExit = fn;
  }
  
  exit() {
    // todo
  }
}

export class App {
  readonly #cdkOverlay = inject(Overlay);

  openToast() {
    //...
    // clean up the overlay on toast exit
    toast.instance.registerOnCompleteExit(() => {
      overlay.dispose();
    });
    // show toast for 2000ms
    setTimeout(() => {
      toast.instance.exit();
    }, 2000);
  }
}

animationend

残りはToastContainerの実装である。やることは2つある。

  • exitメソッドが呼び出されたらフェードアウトアニメーションを開始する
  • フェードアウトアニメーションが完了したら登録されたコールバック関数を呼び出して通知する

まずはアニメーションを開始させる仕組みを作ろう。使うのはCSSとSignal、クラスバインディングだけでいい。内部的にanimationStateフィールドを持ち、初期値を設定しておく。exitメソッドが呼び出されたらこの値がexitに変更する。あとはanimationStateの値に連動してtoast-container-exitクラスをコンポーネントのホスト要素に付与し、このクラスを使ってCSSアニメーションを書けばよい。


@Component({
  styles: `
  :host {
    opacity: 1;
  }

  :host(.toast-container-exit) {
    animation: toast-exit 500ms linear forwards;
  }
   
  @keyframes toast-exit {
    from {
      opacity: 1;
    }

    to {
      opacity: 0;
    }
  }
  `,
  host: {
    '[class.toast-container-exit]': "animationState() === 'exit'",
  },
})
export class ToastContainer {
  protected readonly animationState = signal<'visible' | 'exit'>('visible');

  exit() {
    this.animationState.set('exit');
  }
}

続いて、toast-exitアニメーションが終わったときにanimationendイベントを受け取る。アニメーションが実行されるホスト要素にanimationendイベントバインディングを追加し、コンポーネントのonAnimationEndメソッドを呼び出す。その中で最初に受け取っていた退出完了時のコールバック関数を呼び出せば完成だ。

@Component({
  host: {
    '[class.toast-container-exit]': "animationState() === 'exit'",
    '(animationend)': 'onAnimationEnd($event)',
  },
})
export class ToastContainer {
  //...

  #onCompleteExit?: () => void;

  protected onAnimationEnd($event: AnimationEvent) {
    this.#onCompleteExit?.();
  }
}

実装の細かい部分はStackblitzのサンプルコードを見てほしい。

Why not @angular/animations ?

今回の方法ではCSSアニメーションを使っているが、Angularフレームワークにはアニメーション機能を提供するAPIもある。

Angular Materialでも少し前まではSnackBarなどのアニメーションにAngular Animations APIを使っていた。しかしv19に入ったあたりからその使用箇所をCSSアニメーションに置き換えている。

そしてとうとうすべてのコンポーネントでAngular Animationsを使っていない状態となり、Angular Materialの依存関係から完全に消えることになった。Animations依存だったAPIはv19系で非推奨となり、v21で削除される見込みだ。

なぜこのような書き換えを行ったのか、背景のすべてはわからないが、少なくともそのひとつはAngular Animations APIが抱えるメモリリーク問題である。Angular Materialの中でアニメーションを多用するMatSortコンポーネントにはメモリリークの問題が指摘されていた。

これ以外にもMatSortには多くのアニメーション実装の複雑さに起因する問題があり、MatSortのアニメーション実装をシンプルにする修正が入った。これがこの脱Angular Animationsの端緒であった。

こうした状況を踏まえて、今後は可能ならCSSアニメーションによる実装を選択するのが安心だろう。Web標準の機能でシンプルに解決できるならわざわざフレームワークのAPIを間に挟む必要はない。

まとめ

  • Angular CDKのOverlayを使ったポップアップUIにフェードアウトアニメーションを実装する方法を紹介した
  • CSSアニメーションと animationend イベントを組み合わせることで、アニメーション完了を待ってからオーバーレイを破棄する実装が可能
  • Angular MaterialのSnackBarと同様のアプローチを採用し、シンプルで信頼性の高い実装を実現できた