この記事は Angular Advent Calendar 2019 12日目の記事です
今年は1つめのカレンダーが速攻で埋まって、勢いの良さに気持ちが盛り上がりました
2つめのカレンダーも全枠バッチリ埋まっていて、嬉しいですね
アドカレ開始前からテンション上がりました
はじめに
先日、Angularを使ったアプリケーションでアニメーションのcssを書いてみた際、うまく動かずハマったので、その話を書きます。解決に手間取ったので、同じことでハマってる方の役に立つと嬉しいです。
認識違いや、もっと良い方法等あれば、ご指摘いただけると嬉しいです
#やりたいこと
Webアプリケーションの画面左側にメニューバー的なものを表示し、カテゴリごとにアイテムの表示/非表示を切り替えたい。その際、アニメーションさせたい。
一見、cssを書くだけで簡単にできそうなのですが、いざやってみると以下のような動きをしていました。
アニメーションできてない。。
アプリケーションの説明
説明のため、ナビゲーション部分だけのアプリケーションを用意したのでその部分の実装の説明を軽くします。
サンプルアプリ
諸々のバージョン
やっていること
- ナビゲーションにはカテゴリーとカテゴリーに属するアイテムを表示します。
- カテゴリーをクリックするとそのカテゴリーに属するアイテムの表示/非表示が切り替わります。(以下、カテゴリーを開く/閉じると表現します。)
- カテゴリーの開閉状態はNgRxのstoreで管理しています。
#まずやってみたこと
このアプリケーションでは、NgRxを使っており、カテゴリー開閉状態をstoreに突っ込んでいます。
カテゴリーをクリックするとクリックしたカテゴリーの開閉状態が切り替わり、それに伴いItem部分のclassをつけるようにしています。cssのアニメーションでいけるでしょと思って、最初は以下のようにしていました。
navigation.component.html
1.カテゴリーをクリックすると「onClickCategory()」が呼ばれる
2.「onClickCategory()」でcategory.isOpeningの値(boolean)が切り替わる
<nav class="Navigation">
<ul>
<!-- 「queries.categoryOpeningStatus$」には以下のようなデータが入ってきます。 -->
<!--
[
{
"categoryId": 1,
"categoryLabel": "category 1",
"isOpening": false
},
{
"categoryId": 2,
"categoryLabel": "category 2",
"isOpening": false
},
{
"categoryId": 3,
"categoryLabel": "category 3",
"isOpening": false
},
{
"categoryId": 4,
"categoryLabel": "category 4",
"isOpening": false
}
]
-->
<ng-container
*ngFor="let category of queries.categoryOpeningStatus$ | async"
>
<li class="Navigation_Category">
<!-- ①カテゴリーをクリックすると「onClickCategory()」が呼ばれる -->
<div
class="Navigation_CategoryTitle"
(click)="onClickCategory(category.categoryId)"
>
{{ category.categoryLabel }}
</div>
<!-- ②「onClickCategory()」でcategory.isOpeningの値(boolean)が切り替わる -->
<ul
class="Navigation_CategoryItems"
[class.Navigation_CategoryItems-hidden]="!category.isOpening"
>
<li Class="Navigation_CategoryItem">
<!-- サンプルなので未実装 -->
Item
</li>
</ul>
</li>
</ng-container>
</ul>
</nav>
navigation.component.scss
.Navigation_CategoryItems
、.Navigation_CategoryItems.Navigation_CategoryItems-hidden
にカテゴリ開閉時のcssを指定。
.Navigation {
width: 200px;
}
.Navigation_CategoryTitle {
padding: 10px;
background-color: oldlace;
color: black;
font-weight: bold;
}
.Navigation_CategoryItem {
padding: 10px 10px 10px 30px;
background-color: oldlace;
color: black;
}
// カテゴリーが開いている状態のCSS
.Navigation_CategoryItems {
visibility: visible;
opacity: 1;
max-height: 4000px; // 値にautoを指定するとうまくアニメーションしないため、heightではなくmax-heightを指定している。
transition: visibility 500ms, opacity 500ms, max-height 500ms;
transition-timing-function: ease-in-out;
}
// カテゴリーが閉じている状態のCSS
.Navigation_CategoryItems.Navigation_CategoryItems-hidden {
max-height: 1px;
visibility: hidden;
opacity: 0;
transition: visibility 275ms, opacity 275ms, max-height 280ms;
transition-timing-function: ease-out;
}
この結果は最初にあったうまくいかなかったバージョンの動きのgifの通りで、意図した動きになりませんでした
そもそもアニメーションのCSSをほぼ使ったことないので、アニメーション自体の書き方が間違ってるのか?それともAngularだからうまくいかないのか?切り分けが必要でした。
#素のJavascriptで試す
Angularの何かしらの影響なのか、そもそもcssの書き方が間違っているのかを確認するため、StackBlitzで素のJavascriptで書いてみたところうまくアニメーションされました。どうやらcssの書き方はあっているようです。ということはAngularの仕組みが影響していそうです。。
#Angular Animation を使ってみる
調査方法の見当が付いていなかったのと、Angular Animation だと勝手が違うのかちょっとやってみたかっというのもあり、Angular Animationを入れてみました。対応内容は割愛しますが、結果、自分が試した範囲ではこちらでもうまくアニメーションされませんでした。かなりサクッと試しただけなので、良い方法があるのかもですが、この時点では見つけられておらず、結局使わずに対応することにしました。
#結局何が原因だったのか
結局、原因となっていたのは*ngFor
の仕組みでした。*ngFor
に渡されるコレクションが一部だけでも変更された場合、*ngFor
で描画している箇所のDOMが全て再描画されます。つまり、今回の場合だとカテゴリーをクリックするたびに *ngFor
に渡されている配列が更新され、*ngFor
を使っている箇所のDOMが再描画されていました。
今回アニメーションで使っているトランジションは、CSSプロパティの変更を即座に変更するのではなく、一定期間に渡って発生させる、というものす。しかし、*ngFor
を使っていることにより、「CSSプロパティの変更」ではなく、「DOMを再描画」している状態になっていました。それはアニメーションされないよな。。という感じでした
#対応
デフォルトでは*ngFor
に渡されるコレクションに一部でも変更があった場合、*ngFor
で描画している箇所のDOMは再描画されます。しかしtrackBy
を使うことで任意の条件を指定することができます。
*ngForとtrackBy
繰り返しになりますが、*ngFor
に渡されるコレクションに変更があった場合、*ngFor
で描画している箇所のDOMを全て再描画します。例えば、複数のアイテムのうち、1件のみ変更が入ったとしても全てのアイテムが再描画されます。
trackByを使う前ののサンプルアプリを見てみると、こんな感じです。
trackByを使う前のサンプルアプリ
これを任意の条件とする方法があります。そのための設定項目がtrackBy
です。
修正後のコードはこちらです。
navigation.component.html
「trackBy: trackByViewModels」 追加
<nav class="Navigation">
<ul>
<!-- 「trackBy: trackByViewModels」 追加 -->
<ng-container
*ngFor="
let category of queries.categoryOpeningStatus$ | async;
trackBy: trackByViewModels
"
>
<li class="Navigation_Category">
<div
class="Navigation_CategoryTitle"
(click)="onClickCategory(category.categoryId)"
>
{{ category.categoryLabel }}
</div>
<ul
class="Navigation_CategoryItems"
[class.Navigation_CategoryItems-hidden]="!category.isOpening"
>
<li Class="Navigation_CategoryItem">
<!-- サンプルなので未実装 -->
Item
</li>
</ul>
</li>
</ng-container>
</ul>
</nav>
navigation.component.ts
trackByViewModels()追加
import { Component } from '@angular/core';
import { NgrxNavigationCommands } from './navigation-commands.service';
import { NavigationViewModel, NavigationQueries } from './navigation.queries';
@Component({
selector: 'app-navigation',
templateUrl: './navigation.component.html',
styleUrls: ['./navigation.component.scss']
})
export class NavigationComponent {
constructor(
public readonly queries: NavigationQueries,
private readonly commands: NgrxNavigationCommands
) {}
onClickCategory(categoryId: number) {
this.commands.onClickCategory(categoryId);
}
// trackByViewModels()追加
trackByViewModels(index: number, item: NavigationViewModel): number {
return item.categoryId;
}
}
これでアニメーションが意図した動きになりました
trackByの使い方
テンプレートで*ngFor
を使用している箇所にtrackBy
と任意のメソッドを指定します。
ここではtrackByViewModels
というメソッドを指定しています。
<ng-container
*ngFor="
let category of queries.categoryOpeningStatus$ | async;
trackBy: trackByViewModels
"
>
trackByViewModels
の処理はこちらです。trackBy
は、引数にインデックスと要素を受け取ります。
この場合、どのような値を取得するかconsole.log()
で出力して確認してみました。
trackByViewModels(index: number, item: NavigationViewModel): number {
console.log(index); // 追記
console.log(item); // 追記
return item.categoryId;
}
出力結果は以下。インデックスと配列の要素が取れていることがわかります。
今回の場合、trackByViewModels()
でcategoryId
というカテゴリーごとに一意の値を返すようにしています。これにより、categoryId
が変わったときにDOMの再描画が行われる、という状態になっています。よって、カテゴリーがクリックされるたびにisOpening
が変更されますが、再描画は行われなくなりました。
参考URL
この記事ではアニメーションに絡めてtrackBy
の話を書きましたが、*ngFor
のtrackBy
はパフォーマンスチューニングの目的で紹介されていることが多いようです。*ngFor
で大量のコレクションを扱う場合は、trackBy
の使用を見当してみると良さそうです。以下に参考にさせていただいたサイトのURLを掲載します。
- 公式ドキュメント NgForOf
- 公式ドキュメント Template Syntax *ngFor with trackBy
- Improving Angular *ngFor Performance Through trackBy
- 【Angular】ngFor trackBy の実装
- Angularリファレンス
おまけ:Angular Animation を使うと嬉しいことって何?
Angularを使ったアプリケーションでアニメーションのcssを書くのも、Angular Animationを使ってみるのも初めてだったのですが、単純なことしか試していないため、Angular Animationを使った時何が嬉しいのか掴めていませんでした。そこで、コミュニティの詳しい方々に聞いてみたところ、嬉しいことをいくつか教えていただきましたので以下にメモっておきます。(@laco2netさん、@kawakami0717さん、ありがとうございました!)
- Angular Animationを使うとページ遷移の時アニメーションをつけられる
- cssだと何がどこに影響しているか判断しにくく、捨てるのも容易ではないが、Angular Animationを使えば、どのソースがどのアニメーションの処理かが分かりやすい。可読性が良く、捨てる時も捨てやすい。
- cssでアニメーションを書くのに慣れていない場合、Angular Animationを使った方が簡単に書ける
今回は、複雑なアニメーションは書いていませんし、cssでできるならAngular Animationを入れなくて良いかなという判断(依存を少なくしようという意図)で、cssのみでアニメーションを書きましたが、Angular Animationを使うと嬉しい理由を聞いて、より複雑なアニメーションをする際は再検討したいと思いました。
#まとめ
今回の件は、アニメーションの書き方云々は問題なく、*ngFor
で参照しているコレクションが一部でも変更された場合は*ngFor
の影響するDOM全体が再描画されることが原因でした。
*ngFor
の変更追跡仕様を理解していれば、すぐにピンとくる話だろうとは思います。
今後は、*ngFor
を使う際、この仕組みを念頭におき、必要に応じてtrackBy
を使用する等の対応していきたいと思いました。
ちなみに、解決策は詳しい方に相談してわかりました。ハマった時等すぐ相談できる環境は本当に恵まれているなと感謝の日々です。
同じことでハマった方の参考になれば幸いです。
明日、13日は@YuKimura45zさんです!