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と同様のアプローチを採用し、シンプルで信頼性の高い実装を実現できた