【Angular】アニメーションをつけようとしてハマったけど、trackByを使うと一発解決した話

この記事は [Angular Advent Calendar 2019 ](https://qiita.com/advent-calendar/2019/angular)12日目の記事です:santa_tone1::raised_hands_tone2:
今年は1つめのカレンダーが速攻で埋まって、勢いの良さに気持ちが盛り上がりました:dancer_tone2::dancer_tone3::dancer_tone4:
2つめのカレンダーも全枠バッチリ埋まっていて、嬉しいですね:clap::sparkles:
アドカレ開始前からテンション上がりました:angel_tone2:

# はじめに
先日、Angularを使ったアプリケーションでアニメーションのcssを書いてみた際、うまく動かずハマったので、その話を書きます。解決に手間取ったので、同じことでハマってる方の役に立つと嬉しいです。
認識違いや、もっと良い方法等あれば、ご指摘いただけると嬉しいです:santa_tone2::christmas_tree:

#やりたいこと
Webアプリケーションの画面左側にメニューバー的なものを表示し、カテゴリごとにアイテムの表示／非表示を切り替えたい。その際、アニメーションさせたい。

**理想の動き**
<img width="200px" alt="navigation_animation.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103464/2a88981e-3495-4dbc-ba3c-79e5b90d2723.gif">

一見、cssを書くだけで簡単にできそうなのですが、いざやってみると以下のような動きをしていました。

**うまくいかなかったバージョンの動き**
<img width="200px" alt="navigation_animation.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103464/84254572-645f-9ecb-2f72-61a156aa7422.gif">

アニメーションできてない。。:confounded:

# アプリケーションの説明
説明のため、ナビゲーション部分だけのアプリケーションを用意したのでその部分の実装の説明を軽くします。

### サンプルアプリ
[サンプルアプリ](https://github.com/k-shirahama/angular-animation-app/tree/feature/navigation-sample)
### 諸々のバージョン
<img width="500px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103464/c2c700d3-22ae-c423-2d22-6004e3b9ab45.png">

### やっていること
* ナビゲーションにはカテゴリーとカテゴリーに属するアイテムを表示します。
* カテゴリーをクリックするとそのカテゴリーに属するアイテムの表示／非表示が切り替わります。（以下、カテゴリーを開く／閉じると表現します。）
* カテゴリーの開閉状態はNgRxのstoreで管理しています。

#まずやってみたこと
このアプリケーションでは、NgRxを使っており、カテゴリー開閉状態をstoreに突っ込んでいます。
カテゴリーをクリックするとクリックしたカテゴリーの開閉状態が切り替わり、それに伴いItem部分のclassをつけるようにしています。cssのアニメーションでいけるでしょと思って、最初は以下のようにしていました。


**navigation.component.html**
1.カテゴリーをクリックすると「onClickCategory()」が呼ばれる
2.「onClickCategory()」でcategory.isOpeningの値（boolean）が切り替わる

```navigation.component.html
<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.component.scss
.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の通りで、意図した動きになりませんでした:confounded:

そもそもアニメーションのCSSをほぼ使ったことないので、アニメーション自体の書き方が間違ってるのか？それともAngularだからうまくいかないのか？切り分けが必要でした。

#素のJavascriptで試す
Angularの何かしらの影響なのか、そもそもcssの書き方が間違っているのかを確認するため、StackBlitzで素のJavascriptで書いてみたところうまくアニメーションされました。どうやらcssの書き方はあっているようです。ということはAngularの仕組みが影響していそうです。。

#Angular Animation を使ってみる
調査方法の見当が付いていなかったのと、Angular Animation だと勝手が違うのかちょっとやってみたかっというのもあり、Angular Animationを入れてみました。対応内容は割愛しますが、結果、自分が試した範囲ではこちらでもうまくアニメーションされませんでした。かなりサクッと試しただけなので、良い方法があるのかもですが、この時点では見つけられておらず、結局使わずに対応することにしました。

#結局何が原因だったのか
結局、原因となっていたのは`*ngFor`の仕組みでした。`*ngFor`に渡されるコレクションが一部だけでも変更された場合、`*ngFor`で描画している箇所のDOMが全て再描画されます。つまり、今回の場合だとカテゴリーをクリックするたびに `*ngFor` に渡されている配列が更新され、`*ngFor`を使っている箇所のDOMが再描画されていました。

今回アニメーションで使っている[トランジション](https://developer.mozilla.org/ja/docs/Web/CSS/CSS_Transitions/Using_CSS_transitions)は、CSSプロパティの変更を即座に変更するのではなく、一定期間に渡って発生させる、というものす。しかし、`*ngFor`を使っていることにより、「CSSプロパティの変更」ではなく、「DOMを再描画」している状態になっていました。それはアニメーションされないよな。。という感じでした:confounded:

#対応
デフォルトでは`*ngFor`に渡されるコレクションに一部でも変更があった場合、`*ngFor`で描画している箇所のDOMは再描画されます。しかし`trackBy`を使うことで任意の条件を指定することができます。

## *ngForとtrackBy
繰り返しになりますが、`*ngFor`に渡されるコレクションに変更があった場合、`*ngFor`で描画している箇所のDOMを全て再描画します。例えば、複数のアイテムのうち、1件のみ変更が入ったとしても全てのアイテムが再描画されます。

trackByを使う前ののサンプルアプリを見てみると、こんな感じです。
**trackByを使う前のサンプルアプリ**
![画面収録 2019-12-11 1.18.54.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103464/656f5925-f4fe-7861-586e-e4138068c8d4.gif)


これを任意の条件とする方法があります。そのための設定項目が`trackBy`です。

**trackByを使った場合のサンプルアプリ**
![画面収録 2019-12-11 1.02.14.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103464/98c31df3-3b9f-c2cd-ee6b-67be16c8c861.gif)



修正後のコードはこちらです。

**navigation.component.html**
「trackBy: trackByViewModels」 追加

```navigation.component.html
<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()追加

```navigation.component.ts
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;
  }
}
```

これでアニメーションが意図した動きになりました:clap:

## trackByの使い方
テンプレートで`*ngFor`を使用している箇所に`trackBy`と任意のメソッドを指定します。
ここでは`trackByViewModels`というメソッドを指定しています。

```navigation.component.html
    <ng-container
      *ngFor="
        let category of queries.categoryOpeningStatus$ | async;
        trackBy: trackByViewModels
      "
    >
```
`trackByViewModels`の処理はこちらです。`trackBy` は、引数にインデックスと要素を受け取ります。
この場合、どのような値を取得するか`console.log()`で出力して確認してみました。

```navigation.component.ts
  trackByViewModels(index: number, item: NavigationViewModel): number {
    console.log(index); // 追記
    console.log(item); // 追記
    return item.categoryId;
  }
```
出力結果は以下。インデックスと配列の要素が取れていることがわかります。

![画面収録 2019-12-11 1.51.45.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103464/d2c8cd60-06db-164b-4452-cd177c8fb148.gif)


今回の場合、`trackByViewModels()`で`categoryId`というカテゴリーごとに一意の値を返すようにしています。これにより、`categoryId`が変わったときにDOMの再描画が行われる、という状態になっています。よって、カテゴリーがクリックされるたびに`isOpening`が変更されますが、再描画は行われなくなりました。

# 参考URL
この記事ではアニメーションに絡めて`trackBy`の話を書きましたが、`*ngFor`の`trackBy`はパフォーマンスチューニングの目的で紹介されていることが多いようです。`*ngFor`で大量のコレクションを扱う場合は、`trackBy`の使用を見当してみると良さそうです。以下に参考にさせていただいたサイトのURLを掲載します。

* [公式ドキュメント NgForOf](https://angular.io/api/common/NgForOf)
* [公式ドキュメント Template Syntax *ngFor with trackBy](https://angular.io/guide/template-syntax#ngfor-with-trackby)
* [Improving Angular *ngFor Performance Through trackBy](https://medium.com/better-programming/improving-angular-ngfor-performance-through-trackby-ae4cf943b878)
* [【Angular】ngFor trackBy の実装](https://kakkoyakakko2.hatenablog.com/entry/2018/12/29/143957)
* [Angularリファレンス](http://www.webcreativepark.net/angular/ngfor/)

# おまけ：Angular Animation を使うと嬉しいことって何？

Angularを使ったアプリケーションでアニメーションのcssを書くのも、Angular Animationを使ってみるのも初めてだったのですが、単純なことしか試していないため、Angular Animationを使った時何が嬉しいのか掴めていませんでした。そこで、コミュニティの詳しい方々に聞いてみたところ、嬉しいことをいくつか教えていただきましたので以下にメモっておきます。（[@laco2net](https://twitter.com/laco2net)さん、[＠kawakami0717](https://twitter.com/kawakami0717)さん、ありがとうございました！）

* Angular Animationを使うとページ遷移の時アニメーションをつけられる
* cssだと何がどこに影響しているか判断しにくく、捨てるのも容易ではないが、Angular Animationを使えば、どのソースがどのアニメーションの処理かが分かりやすい。可読性が良く、捨てる時も捨てやすい。
* cssでアニメーションを書くのに慣れていない場合、Angular Animationを使った方が簡単に書ける

今回は、複雑なアニメーションは書いていませんし、cssでできるならAngular Animationを入れなくて良いかなという判断（依存を少なくしようという意図）で、cssのみでアニメーションを書きましたが、Angular Animationを使うと嬉しい理由を聞いて、より複雑なアニメーションをする際は再検討したいと思いました。

#まとめ
今回の件は、アニメーションの書き方云々は問題なく、`*ngFor`で参照しているコレクションが一部でも変更された場合は`*ngFor`の影響するDOM全体が再描画されることが原因でした。

`*ngFor`の変更追跡仕様を理解していれば、すぐにピンとくる話だろうとは思います。
今後は、`*ngFor`を使う際、この仕組みを念頭におき、必要に応じて`trackBy`を使用する等の対応していきたいと思いました。

ちなみに、解決策は[詳しい方](https://twitter.com/okunokentaro)に相談してわかりました。ハマった時等すぐ相談できる環境は本当に恵まれているなと感謝の日々です。:pray::sparkles:
同じことでハマった方の参考になれば幸いです。:santa_tone2::christmas_tree:

明日、13日は[@YuKimura45z](https://qiita.com/YuKimura45z)さんです！:clap: