Angularアニメーションの備忘録

ネット上の情報がないので公開
（あくまで実際の挙動から推測しただけでソースを追っていないため、鵜呑みにせず自分で検証してください）

 1. queryする際、{ optional: true }は実質必須です。
 2. queryする際、animateChild()に対して:enter,:leaveを指定しても機能しません。
 3. 状態遷移時に該当するtransitionが一つもない場合、瞬時に新しいstateになります。
 4. void stateは素のstyleになります。
 5. 子要素に設定されたアニメーションは、親要素が:enterする際、明示的にanimateChild()が呼ばれなければ、親のアニメーション終了後に瞬時に新しいstateになります。
 6. 子要素に設定されたアニメーションは、親要素が:leaveする際、groupで並列に実行しない限り実施されません。
 7. 子要素に設定されたアニメーションは、親要素が:leaveする際、animateChild()がgroupで並列にされ該当するtransitionがない場合、瞬時にvoidである素のstyleになります。
 8. 「アニメーション中の再transition」は、通常はマージされますが、animateChild()中に発生した場合、マージされずに表示とstateの不一致が起きます。アニメーション時間が長い場合に頻出します。
 9. 表示とstateの不一致が起きている状態からアニメーションする場合、不適切に再生されます。
 10. 表示とstateの不一致が起きている状態から（不適切な再生中の）「アニメーション中の再transition」しても、表示とstateの不一致が継続されます。
 11. 2つのstate間に表示差がない場合でも、実際にはアニメーションが実施されています。この最中にアニメーションを発生させると「アニメーション中の再transition」扱いになります。
 12. 8～11の典型的な例は、「:enter」で表示差がなく長い時間のアニメーションがanimateChild()される時です。:enterアニメーション時間を短くすることで発生させにくくできます。

例

Animation定義

```ts
import { trigger, transition, style, query, group, animate, animateChild, AnimationMetadata, state } from '@angular/animations';

function optionalQuery(selector: string, animation: AnimationMetadata | AnimationMetadata[]) {
  return query(selector, animation, { optional: true });
}

export class AppAnimations {

  /**
   * 典型的なFloatIn
   * @param triggerName 
   * @param enterStart 新しい状態の横初期位置
   * @param leaveDuration 古い状態が消えるまでの動作時間
   * @param enterDelay 新しい状態の動作開始までのディレイ
   * @param enterDuration 新しい状態のディレイ後の動作時間
   * @returns 
   */
  static floatIn(triggerName: string, enterStart: string = '-1%', leaveDuration: string = '100ms', enterDelay: string = '100ms', enterDuration: string = '300ms') {
    return trigger(triggerName, [
      transition('* <=> *', [
        style({ position: 'relative' }),
        optionalQuery(':enter, :leave', [style({ position: 'absolute', top: 0, left: 0, width: '100%' })]),
        optionalQuery(':enter', [style({ left: enterStart, opacity: 0 })]),
        group([
          optionalQuery(':leave', [animate(`${leaveDuration} ease-out`, style({ opacity: 0 }))]),
          optionalQuery(':enter', [animate(`${enterDuration} ${enterDelay} ease-out`, style({ left: '0%', opacity: 1 }))]),
        ]),
        optionalQuery('@*', animateChild()),
      ])
    ]);
  }

  /**
   * 典型的なOpenClose。状態：open, closed
   * 
   * Angular14以降、このアニメーションを実施するとConsoleに警告が出ます。  
   * （'pointer-events'やoverflowの指定は一般的だが、警告が出てしまう）  
   * 詳細は以下のissueを確認してください。  
   * https://github.com/angular/angular/issues/46928  
   * @param triggerName 
   * @param duration 動作時間
   * @param ease '', 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'cubic-bezier(x1, y1, x2, y2)'
   * @param enterDuration animateChild()で:enterする際の挙動対策。短い時間を設定することで問題を発生させづらくする
   * @returns 
   */
  static openClose(triggerName: string = 'openClose', duration: string = '300ms', ease: string = 'ease', enterDuration: string = '100ms') {
    return trigger(triggerName, [
      state('open', style({ height: '*', opacity: 1 })),
      state('closed', style({ height: '0px', opacity: 0, 'pointer-events': 'none', padding: 0, overflow: 'hidden' })),
      transition(':enter', [animate(`${enterDuration} ${ease}`)]),
      transition('* <=> *', [animate(`${duration} ${ease}`)]),
    ]);
  }
}
```

使用側（app.component.ts / html）
※ ルーティングはページごとに{ animation: '固有のstate名' } を設定してください

```ts
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
  animations: [AppAnimations.floatIn('routeAnimations')]
})
export class AppComponent {
...
  getRouteAnimationData() {
    return this.contexts.getContext('primary')?.route?.snapshot?.data?.['animation'];
  }
...
```
```html
...
<div [@routeAnimations]="getRouteAnimationData()">
  <router-outlet></router-outlet>
</div>
```

使用側（ページ ts / html）

```ts
@Component({
  selector: 'app-sample-a2',
  templateUrl: './sample-a2.component.html',
  styleUrls: ['./sample-a2.component.scss'],
  animations: [AppAnimations.openClose('slowOpenClose', '2000ms', 'cubic-bezier(0.5, 0, 0.1, 1)'), AppAnimations.openClose('fastOpenClose', '100ms')]
})
export class SampleA2Component {

  isOpen1: boolean = true;
  isOpen2: boolean = false;

...

  changeIsOpen1_click(): void {
    this.isOpen1 = !this.isOpen1;
  }

  changeIsOpen2_click(): void {
    this.isOpen2 = !this.isOpen2;
  }
```
```html
<div [@slowOpenClose]="isOpen1 ? 'open' : 'closed'">
   <!-- etc. -->
</div>
<div [@fastOpenClose]="isOpen2 ? 'open' : 'closed'">
   <!-- etc. -->
</div>
```

上記において、enterDurationに2000msなど長時間を設定した場合、
8~11が起きていると思われる挙動になります。