Angular 17+ 高级教程 – Animation 动画

前言

Angular 有一套 built-in 的 Animation 方案。这套方案的底层实现是基于游览器原生的 Web Animation API。

CSS Transition -> CSS Animation -> Web Animation API -> Angular Animation 这 4 套方案的关系并不是互相替代,而是互相弥补。

越靠后的方案就越复杂,所以对于使用者来说,如果不是真的有必要,应该要尽可能使用靠前面的方案。

本篇会假设你对 CSS Transition、CSS Animation、Web Animation API 已经熟悉,会在这些基础之上讲解 Angular Animation。

不熟悉它们的朋友可以先看下面这两篇文章。

  1. CSS – Transition & Animation
  2. DOM – Web Animation API

Angular Animation 虽然是对原生 Web Animation API 的封装和扩展,但 Angular 并没有完全公开所有 Web Animation API 的接口,

也就是说,我们不一定可以使用 Angular Animation 做出 Web Animation API 的效果,或者说使用 Angular Animation 可能会更难达到效果。

所以在面对不同动画需求时,我们一定要慎选方案。

本篇将从 Angular Animation 比较底层的使用方式开始,大致对比 Angular Animation 和 Web Animation API 的区别,看看它们的共同点和不同之处。

然后在深入 Angular Animation 独有的功能和日常使用方式,最后微微逛一下源码,开始吧 🚀。

 

AnimationBuilder

AnimationBuilder 是一个 Root Level Dependancy Injection Provider。

它比较底层,日常开发中很少会用到,但是从它开始理解 Angular Animation 会比较轻松,所以我们从它开始。

搭环境

我们搭个测试环境,对比 Angular Animation 和 Web Animation API。

app.config.ts

import { type ApplicationConfig } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';

export const appConfig: ApplicationConfig = {
  providers: [provideAnimationsAsync()],
};

要使用 Angular Animation 需要提供 Provider。

注:虽然方法名 endswith Async,不过它不是异步,也不返回 Promise 哦,它的意思是 Animation Module 会 lazyload (细节下面会讲,这里不关心先)。

App Template

<div class="box-list"  >
  <div #nativeBox class="box"></div>
  <div #ngBox class="box"></div>
</div>

两个 div box 做 compare。

第一个是 native box,将使用 Web Animation API,

第二个是 ng box,将使用 Angular Animation。

App Styles

:host {
  display: block;
  padding: 56px;
}

.box-list {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.box {
  width: 100px;
  height: 100px;
  background-color: red;
}

效果

The Simplest Aniamtion

我们为 2 个 box 添加一个简单的 animation。

native box 使用 Web Animation API,ng box 使用 Angular Animation。

App 组件

export class AppComponent {
  // 1. query both boxes
  readonly nativeBox = viewChild.required<string, ElementRef<HTMLElement>>('nativeBox', { read: ElementRef });
  readonly ngBox = viewChild.required<string, ElementRef<HTMLElement>>('ngBox', { read: ElementRef });

  constructor() {
    // 2. inject AnimationBuilder
    const builder = inject(AnimationBuilder);

    afterNextRender(() => {
      // 3. delay 1 second (只是为了比较容易看效果)
      window.setTimeout(() => {
        // 4. get box HTMLElement
        const nativeBox = this.nativeBox().nativeElement;
        const ngBox = this.ngBox().nativeElement;

        // 5. use Web Aniamtion API for native box
        const nativePlayer = nativeBox.animate([{ width: '200px', backgroundColor: 'yellow' }], {
          duration: 1000,
          fill: 'forwards',
          easing: 'ease',
        });

        // 6. use Angular Animation for ng box
        const factory = builder.build(animate('1s ease', style({ width: '200px', 'background-color': 'yellow' })));
        const ngPlayer = factory.create(ngBox);
        ngPlayer.play();
      }, 1000);
    });
  }
}

效果

动画效果是一模一样的。

Commonalities and Differences

我们看看它们的共同点和不同之处:

  1. Angular Animation use many small functions

    const factory = builder.build(animate('1s ease', style({ width: '200px', 'background-color': 'yellow' })));
    const ngPlayer = factory.create(ngBox);

    Angular Animation 需要调用许多小方法:build, animate, style, create,每一个传入一部分的 config。 

    反观 Web Animation API 就只调用一个 animation 方法,传入一个 config。

  2. timings shorthand

    Angular Animation 支持用 string 表达 timings,比如 ‘1s 2s ease’ 相等于 Web Aniamtion API 的 { duration: 1000, delay: 2000, easings: 'ease' }。

  3. fill: 'forwards'

    Angular Animation 不支持所有的 Web Animation API config,比如 fill 一定是 'forwards',这个我们不能改。

  4. kebeb-case

    Angular 支持 camelCase 也支持 kebab-case,像 CSS 那样 'background-color' 是可以接受的,

    反观 Web Animation API 支支持 JavaScript 的 camelCase,不支持 CSS 的 kebab-case。

  5. no auto play

    Angular Animation 默认不会启动 animation,我们需要调用 play 方法让它启动。

    ngPlayer.play();

    反观 Web Animation API 默认会自动启动 animation,如果我们不希望这个行为,则需要调用 pause 方法将它暂停。

从上面几点,大家应该可以感受到,它俩的区别还是挺多的。所以我建议大家不要用 Web Animation API 的思路去揣摩 Angular Animation 的接口设计。

虽然 Angular Animation 底层是 Web Animation API,但它上层做了很多改动,接着往下看会看到更多不同之处。

 

AnimationPlayer

Web Animation API 调用 HTMLElement.animation 返回的是 player 类型是 Animation。

Angular Animation 调用 AnimationFactory.create 返回的 player 类型是 AnimationPlayer。

它俩不是继承关系,甚至可以说没什么关系😅。

AnimationPlayer 有自己的一套方法,我们一个一个看:

play, pause 

ngPlayer.play();
ngPlayer.pause()

play 是启动 animation,pause 是暂停。

这和 Web Animation API 是一样的。

reset

reset 相等于 Web Animation API 的 finish。

restart

restart 等价于 reset + play。

finish

reset 相等于 Web Animation API 的 cancel。

reverse

不好意思,AnimationPlayer 没有 reverse 功能,Angular Animation 要实现 reverse 需要使用一些上层手法,下面会教。

onStart

ngPlayer.onStart(() => console.log('started'));

在 AnimationPlayer.play 之后触发。

onDone

onDone 监听的是 Web Animation API 的 finish event。

beforeDestroy, destroy, onDestroy

reset 表示重来,之后还会 play。

destroy 是完全销毁,之后不会再 play 了才使用。

在 destroy 之前最好先调用一下 beforeDestroy 方法。

onDestroy, onDone 都会在 destroy 后触发。

这里有个知识点:

reset 会调用 Web Animation API 的 cancel,但 Angular Animation 没有监听 cancel event。

reset 也不会触发 onDestroy,所以我们是没有方法可以监听到 reset 的。

init

在 AnimationFactory.create 返回 AnimationPlayer 以后,

我们看似已经有了 player,但其实内部的 Web Animation API player 是还没有创建的。

我们可以调用 init 方法创建它,或者之后调用 play 或其它方法,这些方法大部分会先判断是否已经 initialized,如果没有就会调用 init 方法。

相关代码在 web_animations_player.ts

注:domPlayer 就是 HTMLElement.animate 创建出来的原生 player,Angular Animation 没有公开这个对象,我们无法直接操作它。

getPosition, setPosition

getPosition 是 Web Animation API currentTime 的变种。

比如说,duration 是 10 秒,当前 currentTime 是 2 秒,那么 2 / 10 = 0.2。

所以 getPosition 返回的是一个百分比,1 代表完成,0.x 代表完成了多少八仙。

setPosition 也是一样的概念,duration 10 秒,setPosintion(0.5) 等价于 currentTime = 5000。

提醒:别忘了 delay 也要算进去哦,getPosition = currentTime / (duration + delay)。

总结

就目前介绍到的功能来看,Angular Animation 没有什么特别大的亮点,用 Web Animation API 还更灵活。

但好戏在后头嘛,下一 part 开始就会慢慢看到 Angular Animation 的各大强项了,继续吧🚀。

 

keyframes

Angular Animation 自然也可以设置 keyframes,但逻辑和 Web Animation API 有微微的不一样。

const nativePlayer = nativeBox.animate(
  [
    { width: '200px', offset: 0.5 },
    { width: '300px', backgroundColor: 'yellow', offset: 1 },
  ],
  {
    duration: 3000,
    fill: 'forwards',
    easing: 'linear',
  },
);

const factory = builder.build(
  animate(
    '3s linear',
    // 1. 在 animate 里面使用 keyframes 把多个 style 包起来, 每个 style 里 set offset
    keyframes([
      style({ width: '200px', offset: 0.5 }),
      style({ width: '300px', 'background-color': 'yellow', offset: 1 }),
    ]),
  ),
);

调用的方式还算挺直观的,多使用了一个 keyframes 函数。

效果

Web Animation API 的 background-color 比较早开始转变成黄色。

Web Animation API 对这个 command 

[
  { width: '200px', offset: 0.5 },
  { width: '300px', backgroundColor: 'yellow', offset: 1 },
]

的理解是,从 0 到 1 渐变成黄色。

而 Angular Animation 对这个 command 的理解是,从 0 到 0.5 和 background-color 无关,从 0.5 到 1 渐变成黄色,所以它俩的效果不一致。

谁比较合理,我也说不上来,反正 Angular Animation 的行为就是这样。

官网的声明

 

group

接下来要介绍的都是 Angular Animation 对 Web Animation API 的扩展功能,Web Animation API 没有 built-in 这些。

const factory = builder.build([
  // 1. 传入 2 个 animate
  animate('1.5s ease', style({ width: '200px', 'background-color': 'yellow' })),
  animate('1s ease', style({ transform: 'rotate(360deg)' })),
]);
const ngPlayer = factory.create(ngBox);
ngPlayer.play();

build 方法支持传入 array,同时传入 2 个 animate 效果是怎样的呢?

先执行第一个 aniamtion,等第一个结束后再执行第二个 animation。

如果要使用 Web Animation API 实现这个效果,我们需要自己控制两个 animation player,超级麻烦的。

那如果我不要 step by step 一个接着一个执行,要一起同步执行两个 animation 可以吗?

a piece of cake,只要加一个 group 函数调用就可以了。

const factory = builder.build(
  // 1. group 函数内的 animation 会一起同步执行
  group([
    animate('1.5s ease', style({ width: '200px', 'background-color': 'yellow' })),
    animate('1s ease', style({ transform: 'rotate(360deg)' })),
  ),
]);

效果

 

style without animate & emtpy style

如果我只放一个 style,完全没有 animate 会怎样?

const factory = builder.build(style({ width: '200px', 'background-color': 'yellow' }));

它的效果就是直接显示样式,等价于 Web Animation API duration 设置 0 的效果。

那什么样的情况下,会用 Angular Animation 做一个不需要 animation 的效果呢?

通常直接用 style 是为了做初始化样式

const factory = builder.build([
  style({ opacity: 0 }), 
  animate('10s', style({ opacity: 1 }))
]);

一开始直接 opacity element,然后在慢慢显示出来

empty style

上面的例子,我们可以通过 empty style 达到 remove opacity 0 的效果

const factory = builder.build([
  style({ opacity: 0 }), 
  animate('10s', style({ })) // style 传入 empty object
  // animate('10s')) // 或者直接没有 style
]);

empty style 会把之前所有 style 清空,不管是 direct style 还是 animation 里的 style 都清空。

 

Asterisk (*) use in style value

假设有一个 div,width 360px 高度是 152px (依据内容)

const factory = builder.build([
  animate('1s ease', style({ height: '200px' })),
  animate('1s ease', style({ height: '*' })),
  animate('1s ease', style({ height: '300px' })),
]);

第 2 个 animation 的 style height 使用了星号 *,它代表原本 element 的高度,也就是 152px (依据内容)。

星号 * 不只是可以用在属性 height,任何属性都可以用 * 表示原本的 value。比如 width,opacity,color 通通都可以。

效果

 

sequence

group 是同步执行多个 animation,sequence 则是 step by step 一个接着一个执行。

上面我们有提到,默认它就是一个接一个执行的,那为什么还需要 sequence 函数呢?

因为 sequence 是用来 group 里头的。

const factory = builder.build(
  group([
    // 1. 外面三个 aniamtion 会一起执行
    animate('1.5s ease', style({ width: '200px', 'background-color': 'yellow' })),
    animate('1s ease', style({ transform: 'rotate(360deg)' })),
    sequence([
      // 2. 里头的这两个会 step by step 执行
      animate('1s ease', style({ opacity: 0.5 })),
      animate('1s ease', style({ height: '200px', opacity: 1 })),
    ]),
  ]),
);

效果

 

query

顾名思义,猜的出来 query 函数的作用吗?

没错就是用来 query child element 的。

在我们 box 里面添加一个 headline

<div class="box-list">
  <!-- <div #nativeBox class="box"></div> -->
  <div #ngBox class="box">
     <h1>Hello World</h1>
  </div>
</div>

给它一点 Styles

.box {
  width: 300px;
  height: 100px;
  background-color: pink;
  display: flex;
  justify-content: center;
  align-items: center;

  h1 {
    color: red;
    font-size: 40px;
  }
}

默认效果

添加 animation with query

const factory = builder.build([
  animate('1.5s ease', style({ 'background-color': 'yellow' })),
  // 1. query h1 element,给它添加 aniamtion 颜色变成黑色
  query('h1', animate('1.5s ease', style({ color: 'black' }))),
]);

效果

step by step 先把 box 的背景变黄色,接着把子层 h1 文字变黑色。

query options

默认情况下,如果 query 不到任何 element 是会报错的。

我们可以设置 optional: true 让它不报错。

query('h1', animate('1.5s ease', style({ color: 'black' })), { optional: true, limit: 2 })

另外,还可以设置 limit: n 来限制 element 的数量。

 

stagger

stagger 是搭配 query 一起使用的。

query 没有限制 element 的数量,selector 配对到几个就 query 几个出来,然后全部一起跑 animation。

如果我们不希望全部一起跑,可以使用 stagger 让它分段执行。

比如 query 到三个 element,第一个先跑,第二个慢 500ms 才跑,第三个又再慢 500ms 才跑。

看例子,有三个 headline

<div class="box-list">
  <div #ngBox class="box">
     <h1>Item 1</h1>
     <h1>Item 2</h1>
     <h1>Item 3</h1>
  </div>
</div>

CSS Styles

.box {
  width: 300px;
  padding: 16px;
  background-color: pink;
  display: flex;
  flex-direction: column;
  gap: 16px;

  h1 {
    color: red;
    font-size: 40px;
  }
}

Scripts

const factory = builder.build(
  group([
    animate('3s ease', style({ 'background-color': 'yellow' })),
    query('h1', [
      // 1. 首先全部 headline 立刻执行 opacity 和 translateX without animation
      style({ opacity: 0, transform: 'translateX(-50px)' }),
      // 2. 接着每个 headline 间隔 1 秒用 empty style 的方式消除 opacity 和 translateX
      stagger('1s', [animate('0.4s ease')]),
    ]),
  ]),
);

效果

 

animation & useAnimation (reuse aniamtion)

animation 和 useAnimation 是用来封装和复用 Animation 的。我们直接看例子

const myAnimation = animation(
  group([
    animate('{{ durationInSecond }}s', style({ 'background-color': '{{ bgColor }}' })),
    animate('2s', style({ width: '{{ width }}' })),
  ]),
  { params: { durationInSecond: 0, bgColor: 'default', width: 0 } },
);

const factory = builder.build(
  useAnimation(myAnimation, { params: { durationInSecond: 1, bgColor: 'yellow', width: '300px' } }),
);

animation 函数用来封装 Animation,可以把整个 builder.build 参数 array 封装起来。

想复用的时候就调用 useAnimation 函数。

另外,它还可以传入一些 parameters 做微调整,比如 {{ bgColor }} 对应 params.bgColor。

提醒:只有 value 可以使用 parameters,像下面这样是错误语法

animate('2s', style({ '{{ widthOrHeight }}': '300px' }))

parameters can't use for style property,only value available。

animation 函数的第二参数可以设置 parameters 的 default value。

 

Infinite Animation Workaround

CSS Animation 有 built-in 的 infinite 功能。

Web Animation API 没有,Angular Animation 也没有扩展。

这个 Github Feature Request 已经 6 年多了,估计 Angular Team 是不可能再去扩展的了。

目前比较 common 的 workaround 是监听 onDone 然后 restart。 

我们用 CSS & JS Effect – 脉冲 Pulse Play Button 作为例子。

App Template

<div class="button-list">
  <button class="native-pulse">
    <mat-icon fontIcon="play_arrow" />
    <div class="pulse"></div>
  </button>
  
  <button class="ng-pulse">
    <mat-icon fontIcon="play_arrow" />
    <div class="pulse"></div>
  </button>
</div>

Styles

.button-list {
  display: flex;
  flex-direction: column;
  gap: 56px;

  button {
    border-width: 0;

    width: 152px;
    height: 152px;
    border-radius: 50%;

    background-color: hsl(7deg 96% 46%);
    color: white;

    display: flex;
    justify-content: center;
    align-items: center;

    mat-icon {
      width: 4rem;
      height: 4rem;
      font-size: 4rem;
    }

    position: relative;

    .pulse {
      position: absolute;
      inset: 0;
      z-index: -1;
      border: 4px solid hsl(7deg 96% 46%);
      border-radius: 50%;
    }

    &.native-pulse .pulse {
      animation: pulse 2s infinite ease;

      @keyframes pulse {
        from {
          transform: scale(0.9);
          opacity: 1;
        }

        to {
          transform: scale(1.3);
          opacity: 0;
        }
      }
    }
  }
}
View Code

效果

App 组件

export class AppComponent {
  readonly ngPulse = viewChild.required<string, ElementRef<HTMLElement>>('ngPulse', { read: ElementRef });
  constructor() {
    const iconRegistry = inject(MatIconRegistry);
    iconRegistry.setDefaultFontSetClass('material-symbols-rounded', 'mat-ligature-font');

    const builder = inject(AnimationBuilder);

    afterNextRender(() => {
      const ngPulse = this.ngPulse().nativeElement;
      const factory = builder.build(
        query('.pulse', animate(
          '2s ease',
          keyframes([style({ transform: 'scale(0.9)' }), style({ transform: 'scale(1.3)', opacity: 0 })]),
        )),
      );
      const ngPlayer = factory.create(ngPulse);
      // 1. 使用 onDone + restart 制造 infinite 效果
      ngPlayer.onDone(() => ngPlayer.restart());
      ngPlayer.play();
    });
  }
}

效果

 

state, trigger, transition

上面所有例子我们都是用 AnimationBuilder 完成的。

AnimationBuilder 是比较底层功能,属于直接 DOM Manipulate。

Angular 一向不鼓励直接 DOM Manipulate,因此它另外提供了几个上层接口让我们避开使用 AnimationBuilder。

注:这些上层接口底层并不是调用 AnimationBuilder,它俩属于平级,底层正真工作的是 AnimationEngine,下面逛源码时会讲到。

The concept of Angular Animation

我们先看看 Angular 是如何理解 Animation 的。

一个场景:user hover 一个 div,这个 div 的 width 从 100px 长到 300px,背景色从红色变成黄色,整个过程不是一步到位的,而是在 3 秒钟内,一点点的发生转变。

这个场景里有三主角:

  1. state 状态

    还没有 hover 之前,div 是 100px 红色,这是一个状态。

    hover 以后,div 变成 300px 黄色,这是另一个状态。

  2. transition 过渡

    状态间的切换需要 transition 才会好看,transition 指的是变化的时间 (duration),节奏 (easing),甚至是动画 (animation)。

    这里的 transition 并不等同于 CSS Transition,不要搞混哦。

    比如说,状态 A 是红色,状态 B 是黄色,使用 CSS Transition 的话,那就是红色渐变去黄色,仅此而已。

    但是这里的 transition,我们除了可以做到相同的渐变 duration,easing 我们甚至可以在渐变的过程中加入其它样式,比如 opacity 1 到 0.5 再回到 1,这样渐变颜色的同时还会闪烁。 

  3. trigger 触发器

    触发器指的是状态变化的管理者,用 RxJS 来表达就是 state$.pipe(...)

以上就是 Angular 对 Animation 的理解。上面三位主角分别对应 Angular Animation 的三个函数 state,transition,trigger。

state and trigger – simple use

App Template

<h1 class="title">Hello World</h1>

一个简单的 Hello World。

App Styles

.title {
  width: max-content;
  padding: 16px;
  font-size: 48px;
  font-weight: 700;
}

效果

这个 Hello World 会有 2 个状态:

  1. normal -- 背景浅蓝色,文字蓝色,

  2. highlight -- 背景红色,文字白色。

通过 @Component.animations define 这 2 个状态

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [A11yModule, OverlayModule, MatButtonModule, MatIconModule],
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
  // 1. define all state
  animations: [
    trigger('highlightState', [
      state('normal', style({ 'background-color': 'lightblue', color: 'blue' })),
      state('highlight', style({ 'background-color': 'red', color: 'white' })),
    ]),
  ],
})

trigger 表示一个我们要管理的状态集合,里面定义了不同状态下的样式。

接着在 App 组件定义一个属性来表示当前的状态,初始状态是 normal,待会儿我们会将它切换成 highlight。

export class AppComponent {
  readonly highlightState = signal<'normal' | 'highlight'>('normal');
}

接着把 trigger,current state,element 关联在一起。

<h1 class="title" [@highlightState]="highlightState()">Hello World</h1>

@triggerName 是 animation 专用的 Template Binding Syntax。

效果

注意:如果你用 ɵprovideZonelessChangeDetection 关闭了 Zone.js,效果将出不来。估计是还不支持或者是 bug,相关 Github Issue – Animation not initialized when using ZonelessChangeDetection

接着,我们监听 hover 事件并且把状态切换成 highlight

<h1 class="title" 
  [@highlightState]="highlightState()"
  (mouseenter)="highlightState.set('highlight')"
  (mouseleave)="highlightState.set('normal')"
>Hello World</h1>

效果

当前状态的样式会以 inline style 的方式添加到 element 上。

state and trigger – how it work?

它的流程很简单,element 渲染的时候拿 current state 去 trigger 里配对拿样式出来,然后 apply to element。

当 current state 变化后再去 trigger 里配对拿样式出来,然后 apply to element。

用代码来描述:

export class AppComponent {
  constructor() {
    // 1. trigger 是状态的集合
    const trigger = [
      {
        state: 'normal',
        // 2. 每个状态都有自己的样式
        style: { 'background-color': 'lightblue', color: 'blue' },
      },
      {
        state: 'highlight',
        style: { 'background-color': 'red', color: 'white' },
      },
    ];
    // 3. 当前是哪一个状态
    const currentState = signal<'normal' | 'highlight'>('normal');
    // 4. 当前状态对应的样式
    const currentStyle = computed(() => trigger.find(t => t.state === currentState())!.style);

    effect(() => {
      // 5. 每当状态改变,样式就改变,这时就需要更新 DOM
      for (const [key, value] of Object.entries(currentStyle())) {
        // remove previous styles from element
        // add new styles to element
      }
    });
  }
}

transition

上一 part 我们只设置了 state 和 style,没有 transition,所以状态在切换的时候很生硬

颜色突然就变了。

好,我们现在加入 transition

animations: [
  trigger('highlightState', [
    state('normal', style({ 'background-color': 'lightblue', color: 'blue' })),
    state('highlight', style({ 'background-color': 'red', color: 'white' })),
    transition('normal => highlight', animate('2s ease')),
  ]),
],

transition 函数有 2 个参数:

第一个参数用来表示,从哪个状态切换到哪个状态要使用这个 transition。

'normal => highlight' 表示从状态 normal 切换到状态 highlight 要使用这个 transition。

第二参数是 transition 的具体动画效果。

还记得 AnimationBuilder.build 方法吗?它俩的参数类似是一模一样的。

效果

transition – animate 和 state 的关系

上面例子中,animate 只定义了 duration 和 easing 没有定义样式

transition('normal => highlight', animate('2s ease'))

这和我们上面学 AnimationBuilder 时,使用 animate 的方式不同呀。

这是因为 transition 有一个潜规则,当 animate 没有定义样式时,那就表示使用状态的样式。

我们试试在 aniamte 定义样式看看它的效果

transition('normal => highlight', animate('2s ease', style({ transform: 'rotate(90deg)' })))

效果

有 2 个规则:

  1. 如果 animate 没有定义样式,那它会拿状态的样式来补,如果有定义样式,那就只用它自己定义的样式。

  2. 当 aniamte 结束后,不会有 fill: 'forwards' 的效果,只有状态的样式最终会被添加到 element 上。

所以我们看到的效果是,

  1. rotate 90deg in 2 seconds

    因为 animate 定义了样式

  2. animate 结束后,立刻跳回 rotate 0deg

    因为 animate 样式不会被保留在 element 上

  3. animate 结束后,立刻变成红色

    因为状态样式会被添加

transition – state expression

'normal => highlight' 这个叫 state mapping expression,它有几个潜规则。

=> 和 <=>

transition('normal => highlight', animate('2s ease'))

'normal => highlight' 表示从状态 normal 切换到状态 highlight。

transition('normal <=> highlight', animate('2s ease')),

'normal <=> highlight' 表示 'normal => highlight' 同时 'highlight => normal'。

我们想写 2 行来表达也是可以的

transition('normal <=> highlight', animate('2s ease')),
// 等价于下面 2 行
transition('normal => highlight', animate('2s ease')),
transition('highlight => normal', animate('2s ease')),

multiple mapping

state expression 可以写多个 mapping,用逗号做分割就可以了。

transition('normal => highlight, highlight => normal', animate('2s ease')),
// 上面一行,等价于下面 2 行
transition('normal => highlight', animate('2s ease')),
transition('highlight => normal', animate('2s ease')),

提醒:'state1, state2 => state 3' 这样逗号在前面是错误的语法哦。

Asterisk (*) for state

* 星号可以用来代表 whatever state。

transition('normal => *', animate('2s ease'))

'normal => *' 表示从状态 normal 切换到任何状态都使用这个 transition。

’* <=> *‘ 表示不管什么状态,只要有任何状态切换,一律使用这个 transition。

mapping sequence

transition 的 mapping 顺序是从上到下

transition('* => *', animate('2s ease')),
transition('normal => highlight', animate('1s ease')),

开头第一个是 * => * 那下面的 transition 就永远都 match 不到了。

正确的顺序应该是倒过来

transition('normal => highlight', animate('1s ease')),
transition('* => *', animate('5s ease')),

想优先的就放上面

void

有一种状态叫 void,它表示 element 不存在 (或许还没有被 append 或许已经被 remove)。

我们来看一个经典的例子 -- fade-in

App Template

@if (shown()) {
  <h1 class="title" @fadeIn>Hello World</h1>
}

我们不需要 current state。

App 组件

export class AppComponent {
  shown = signal(false);
  constructor() {
    window.setTimeout(() => {
      this.shown.set(true);
    }, 2000);
  }
}

2 秒钟后,append h1。

Animation

animations: [
  trigger('fadeIn', [
    transition('void => *', [
      style({ opacity: 0 }), 
      animate('2s ease')
    ])
  ])
],

'void => *' 表示从没有 element 到有 element (无论它是什么状态,即便是 “无状态” 也可以)

transition 的过程是:

  1. 立马给它一个 opacity 0 隐身

  2. 接着 animate 2s ease 过渡到 “没有定义样式”

    当 animate 和 state 都没有定义样式时,效果是过渡到原本 CSS 定义的样式。

效果

提醒:如果 element 的出现或消失是因为 parent,那就不会触发 animation。

比如说,下面这样是会触发 animation 的

  @if (shown()) {
    <h1 class="title" @fadeIn>Hello World</h1>
  }

但是下面这样就不会了

@if (shown()) {
  <div class="container">
    <h1 class="title" @fadeIn>Hello World</h1>
  </div>
}

因为是 parent container 被 append 和 remove,不是 @fadeIn 指定的 element。

:enter 和 :leave

'void => *' 表示从无到有

'* => void' 表示从有到无

':enter' 是 'void => *' 的 alias,只是另一个写法而已,为了提升可读性。

':leave' 是 '* => void' 的 alias。

:increment 和 :decrement

效果

号码增加就红色,减少就蓝色。

Animation Definition

animations: [
  trigger('numberChange', [
    transition(':increment', [
      animate('0.2s ease', style({ 'background-color': 'red' })),
      animate('0.2s ease', style({ 'background-color': '*' })),
    ]),
    transition(':decrement', [
      animate('0.2s ease', style({ 'background-color': 'blue' })),
      animate('0.2s ease', style({ 'background-color': '*' })),
    ]),
  ]),
],

状态是一个 number。

<div class="card" [@numberChange]="number()">

其实 increment,decrement 主要是用在 control flow @for 而且是搭配 query 函数来使用的,下面我讲解 Multilayer Animation 的时候会给一个更完整的例子。

multiple trigger apply to same element

一个 element 可以放多个 @trigger,如果 trigger 同时触发,那 animation 也会同时运行。

如果有重叠,那后 apply 的 @triiger 会赢。

举例

Animation Definition

animations: [
  trigger('hoverBgColor', [
    state('true', style({ 'background-color': 'red' })),
    transition('* => true', animate('2s ease')),
  ]),
  
  trigger('fadeIn1', [
    transition('void => *', [
      style({ 'background-color': 'yellow', border: '16px solid black' }),
      animate('3s ease', style({ opacity: '*' })),
    ]),
  ]),
  trigger('fadeIn2', [
    transition('void => *', [
      style({ opacity: 0, 'background-color': 'blue' }),
      animate('1s ease', style({ opacity: '*' })),
    ]),
  ]),
],

Template

<button (click)="shown.set(true)">show</button>

@if (shown()) {
  <h1 [@hoverBgColor]="hovered()" @fadeIn1 @fadeIn2 (mouseenter)="hovered.set(true)" (mouseleave)="hovered.set(false)">Hello World</h1>
}

hoverBgColor 和 fadeIn1, fadeIn2 触发的时机不同,所以它俩各自执行就可以了。

fadeIn1 和 fadeIn2 会同时触发,它的 apply 顺序是这样,先 apply fadeIn1 的 animation,因为按指令顺序它先于 fadeIn2。

所以样式是 background-color yellow 和 border,然后在 apply fadeIn2 的 animation,opacity 和 background-color blue。

fadeIn2 的 background-color 会覆盖掉 fadeIn1 的。

1 秒钟后,fadeIn2 animation 结束,此时 background-color 会变会 fadeIn1 的 yellow。(哎呀,挺厉害的嘛)

 

Passing Parameters

上面我们有提到 reuse Animation 概念。

使用 animation 函数可以封装定义的 animation 并且可包含 {{ parameter }},然后使用 useAnimation 函数时可以传入对应的 parameter。

这个机制也适用于 @Component.animations 的定义,和 @triggerName 的调用上。

animations: [
  trigger('highlight', [
    state('normal', style({ 'background-color': 'lightblue', color: 'blue' })),
    state('highlight', style({ 'background-color': '{{ bgColor }}', color: 'white' }), {
      params: { bgColor: 'red' },
    }),
    transition('normal <=> highlight', animate('{{ duration }} ease'), { params: { duration: '0s' } }),
  ]),
],

定义了 {{ bgColor }} 和 {{ duration }} parameters。

state 和 transition 函数结尾的参数可用来设置 default parameter value。

提醒:state 一定要提供 default value,不然会报错哦。

传入参数的方式是这样

<h1 class="title" 
  [@highlight]="{ value: highlightState(), params: { bgColor: 'red', duration: '2s' } }" 
>Hello World</h1>

搭配 animation 和 useAnimation 函数

const myAnimate = animation(animate('{{ duration }} ease'), { params: { duration: '2s' } });
transition('normal <=> highlight', useAnimation(myAnimate)),

只有 transition 可以配 useAnimation 哦,state 和 trigger 是不可以的。

animation 负责 default value,@triggerName 会把参数传到里面去。

提醒:通常这种情况 useAnimation 不会设置 params,如果 useAnimation 设置了 params,那 @triggerName 传入的 params 就无效了,会使用 useAnimation 设置的 params。

 

Multilayer Animation

<div class="card" [@hoverBgColor]="hovered()" (mouseenter)="hovered.set(true)" (mouseleave)="hovered.set(false)">
  <h1 [@hoverColor]="hovered()">Title</h1>
  <p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Cum, vel.</p>
</div>

上面有 2 个 trigger -- @hoverBgColor 和 @hoverColor,它们 apply 在不同的 element,element 是 parent child 关系。

current state 用的是同一个 hovered 属性。

下面是它们的 Animation Definition

animations: [
  trigger('hoverBgColor', [
    state('true', style({ 'background-color': 'red' })),
    transition('* => true', animate('1s ease')),
  ]),
  trigger('hoverColor', [state('true', style({ color: 'white' })), transition('* => true', animate('2s ease'))]),
],

效果

依据 Angular 官网的解释

parent trigger 的 animation 会先跑,这个时候 child 会被 blocked 着。所以呈现出的效果就是上面那样。

query child trigger and manual run animation

如果我们希望它们一起执行,可以利用 query 和 animateChild 函数。

animations: [
  trigger('hoverBgColor', [
    state('true', style({ 'background-color': 'red' })),
    transition('* => true', group([animate('1s ease'), query('@*', animateChild())])),
  ]),
  trigger('hoverColor', [state('true', style({ color: 'white' })), transition('* => true', animate('2s ease'))]),
],

在 parent transition 中使用 query('@*') 获取到子层带有 @trigger 的 element,然后利用 animateChild 手动执行它们的 animation,在 wrap 一层 group 函数,它们就会一起开始执行了。

效果

special query selector

query 我们在上面学习底层 AnimationBuilder 时就已经学习过了,但是搭配 trigger, state, transition 模式下,它多了几个特别 syntax。

query('@*') – 找出所有有 apply trigger 的 element 

query('@triggerName') – 找出指定的 trigger element

query(':animating') – 找出正在执行动画的 element

query(':enter') –  找出新 append 进来的 element

query(':leave) – 找出被 remove 掉的 element

query(':enter, :leave') – 通过逗号可以提供多个 selector

query(':enter, :leave, :self') – 也包含自己

Add and Remove Item List Animation

这里给一个日常会用到的 Animation 案例。

App Template

<div class="button-list">
  <button (click)="addItem()" >add</button>
  <button (click)="removeItem()">remove</button>
</div>
<ul class="item-list">
  @for (item of items(); track item) {
    <li class="item">{{ item }}</li>
  }
</ul>

App Styles

:host {
  display: block;
  padding: 128px;
}

.button-list {
  display: flex;
  gap: 16px;

  button {
    padding: 12px 16px;
    background-color: lightblue;
    border-radius: 4px;
    width: 96px;
  }
}

.item-list {
  margin-top: 16px;
  width: 256px;
  min-height: 256px;

  display: flex;
  flex-direction: column;
  gap: 1px;
  border: 1px solid black;
  padding-block: 4px;

  .item {
    padding: 12px 16px;
  }
}
View Code

App 组件

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

@Component({
  selector: 'app-root',
  standalone: true,
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [],
})
export class AppComponent {
  private readonly allItems = ["Apple", "AWS", "Facebook", "IBM", "Intel", "Oracle", "NVIDIA", "Microsoft", "Google", "Twitter"];

  items = signal<string[]>([]);

  addItem() {
    const { items, allItems } = this;
    const availableItems = allItems.filter(allItem => !items().some(item => allItem === item));
    availableItems.sort(() => Math.random() - 0.5);
    items.set([...items(), availableItems[0]]);
  }

  removeItem() {
    const randomNumber = Math.floor(Math.random() * (this.items().length - 1));
    this.items.set(this.items().filter(i => i !== this.items()[randomNumber]));
  }
}

效果

目前是没有 animation 的,我们要让 item 在添加和删除的时候 "滑" 进来和出去。

Animation Definition

animations: [
  trigger('itemAddAndRemove', [
    transition(
      ':increment',
      query(':enter', [
        style({ opacity: 0, transform: 'translateX(-48px)' }),
        animate('0.4s ease', style({ opacity: '*', transform: '*' })),
      ]),
    ),
    transition(
      ':decrement',
      query(':leave', [animate('0.4s ease', style({ opacity: 0, transform: 'translateX(-48px)' }))]),
    ),
  ]),
],

用到了 :increment, :decrement, query, :enter, :leave

<ul class="item-list" [@itemAddAndRemove]="items().length">

用 item 的数量作为状态。

希望这个例子能让你悟道 Angular Animation 的玩法。

 

Animation Event Listening

AnimationPlay 有 onStart 和 onDone 方法可以监听 animation 开始和结束。

trigger, state, transition 模式下可以利用 @Output 的方式监听 start 和 done

<div class="card" 
  [@hover]="hovered()" 
  (mouseenter)="hovered.set(true)" 
  (mouseleave)="hovered.set(false)"
  (@hover.start)="handleAnimationStart($event)"
  (@hover.done)="handleAnimationDone($event)"
>

AnimationEvent 会含有相关的 event 信息

export class AppComponent {
  hovered = signal(false);

  handleAnimationStart(event: AnimationEvent) {
    console.log(event.triggerName); // trigger 的名字
    console.log(event.fromState);   // 从什么状态
    console.log(event.toState);     // 切换到什么状态
    console.log(event.phaseName);   // 'start' | 'done' 这个 event 是 onStart 还是 onDone
  }

  handleAnimationDone(event: AnimationEvent) {
    console.log(event);
  }
}

OnCancel?

Angular Animation 无法直接监听到 animation cancel。

举例

有 3 个颜色可以切换,每一次切换需要 2 秒钟 animation。

如果我切换非常快,低于 2 秒钟,start done event 触发的顺序和次数会是怎样的呢?

假如是监听 CSS transitioncancel 和 transitionend,它会触发多次 cancel,最终触发一次 end。

但是 Angular Animation 没有 cancel,所以它每次都会触发 done。

CSS:run > start > cancel > run > start > cancel > run > start > end (cancel 代表跑一半,end 代表跑完)

Angular:start > done > start > done > start > done (没有 cancel,done 代表跑一半或者跑完)

如果我们想监听 cancel,唯一的方法是检查开始到结束的用时。

afterNextRender(() => {
  const element = this.boxElementRef().nativeElement;
  // 1. 监听 animation start by RxJS
  const start$ = fromRendererEvent<AnimationEvent>(this.renderer, element, '@boxColor.start');
  // 2. 监听 animation end by RxJS
  const done$ = fromRendererEvent<AnimationEvent>(this.renderer, element, '@boxColor.done');

  // 3. start 和 done 一定是一 pair 的,所以可以用 zip
  zip(
    start$.pipe(map(event => [event, performance.now()] as const)), // 4. 记入执行的时间
    done$.pipe(map(event => [event, performance.now()] as const)), // 5. 记入执行的时间
  ).subscribe(([[startEvent, startTime], [_endEvent, endTime]]) => {
    // 6. event.totalTime 指的是 animation 设置的 duration millisecond
    const totalTime = startEvent.totalTime;

    // 7. 相减得出 animation 跑了多久
    const runningTime = Math.abs(endTime - startTime);
    // 8. running time 肯定是不精准的,比如 animation 设置 5 seconds
    //    即便是完整跑完,也不会是准准 5 seconds,可能是 499x ms 或者 50xx ms
    if (Math.abs(runningTime - totalTime) >= 50) {
      console.log('may be cancel');
    }
  });
});

非常麻烦,而已不精准😅。

 

Disable Animation

@.disabled 可以关闭 animation。

<div [@.disabled]="disabled()" class="card" 
  [@hover]="hovered()" 
  (mouseenter)="hovered.set(true)" 
  (mouseleave)="hovered.set(false)"
>
  <h1>Title</h1>
  <p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Cum, vel.</p>
</div>

三个知识点:

  1. @.disabled 可以放在任何 element 身上,该 element 不一定要有 @trigger。

  2. 其下所有子孙 @trigger 的 transition 都会被 disabled。

  3. 只是 disable transition 动画而已,状态的样子依然会 apply。

如果我们想 disable 所有的 animation,可以把 @.disabled apply 到 App host element 上。

@Component({
  selector: 'app-root',
  standalone: true,
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [],
  host: {
    // 1. disable all animation
    '[@.disabled]': 'disabled()',
  },
  animations: [
    trigger('hover', [
      state('true', style({ 'background-color': 'red', color: 'white' })),
      transition('* => true', animate('2s ease')),
    ]),
  ],
})
export class AppComponent {
  hovered = signal(false);
  disabled = signal(true);
}

 

Angular Animation 源码逛一逛

真的只是随便逛一逛而已,没有要深入。

app.config.ts

import { type ApplicationConfig } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';

export const appConfig: ApplicationConfig = {
  providers: [provideAnimationsAsync()],
};
View Code

app.component.ts

import { animate, state, style, transition, trigger } from '@angular/animations';
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [],
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
  animations: [
    trigger('highlight', [
      state('normal', style({ 'background-color': 'lightblue', color: 'blue' })),
      state('highlight', style({ 'background-color': 'red', color: 'white' })),
      transition('normal <=> highlight', [animate('2s ease')]),
    ]),
  ],
})
export class AppComponent {
  readonly highlightState = signal<'normal' | 'highlight'>('normal');
  handleAnimationDone() {
    console.log('animation done');
  }
}
View Code

app.component.html

<h1 class="title" 
  [@highlight]="highlightState()" 
  (mouseenter)="highlightState.set('highlight')" 
  (mouseleave)="highlightState.set('normal')"
  (@highlight.done)="handleAnimationDone()"
>Hello World</h1>
View Code

app.component.scss

:host {
  display: block;
  padding: 128px;
}

.title {
  width: max-content;
  padding: 16px;
  font-size: 48px;
  font-weight: 700;
}
View Code

run compilation 

yarn run ngc -p tsconfig.json

app.component.js

首先,在 App Definiton 里多了一个 data 属性,用来记入我们 define 的 @Component.animations

另外一个和 Animation 有关的是 property binding 和 event listening

Animation 也使用了 ɵɵproperty 和 ɵɵlistener 函数,这和普通的属性 binding,event listening 是一样的。

可是以前我们逛过 ɵɵproperty 和 ɵɵlistener 函数,没有看见任何和 Animation 相关的处理啊🤔。

其实关键在 app.config.js 里的 provideAnimationsAsync 函数 (源码在 providers.ts)。

它替换了 RendererFactory2 Provider。

AsyncAnimationRendererFactory 会创建出 AnimationRenderer,

而 AnimationRenderer 重载了 setProperty 和 listen 方法。

setProperty 方法

listen 方法

那 setProperty 是如何关联到 trigger 的呢?

trigger 的定义可是在 ComponentDef.data.animation 里哦。

刚才 setProperty 最终是把任务交给 AnimationEngine,它就是老大。

每一个组件在 create LView 前都会创建 renderer,而在创建 renderer 的过程中就会把 trigger register 到 AnimationEngine 里。

甚至是 AnimationBuilder 在初始化时也会 create renderer

AnimationRendererFactory.createRenderer 方法会把 trigger register 到 AnimationEngine。

到这里,我们大致上有点眉目了,AnimationEngine 是整个 Angular Animation 最底层的功能实现。

trigger 和 AnimationBuilder 底层都是靠它工作的。

AnimationEngine 里面有 WebAnimationsDriver 里面有 WebAnimationsPlayer 里面有 domPlayer,这就是原生 Web Animation API 了。

 

Lazy Loading Animation Module

参考:Lazy-loading Angular's animation module

Angular Animation bundle size 非常大,gzip 后都要 16kb。

Angular v17.0.0 版本推测了 Lazy Loading Animation Module 功能,它会分开打包,至少在 first load 时可以快那一丢丢。

当然,first load 快的代价就是 animation 会慢,如果你的 first load 需要 animation 那不推荐使用 Lazy Loading 的方式。

把 app.config.ts 的 provideAnimationsAsync 换成 provideAnimations 就可以了。

import { type ApplicationConfig } from '@angular/core';
import { provideAnimations } from '@angular/platform-browser/animations';

export const appConfig: ApplicationConfig = {
  providers: [provideAnimations()],
};

provideAnimationsAsync  源码逛一逛

provideAnimationsAsync 是怎样 lazy load 的,它又会如何影响 animation 呢?

我们稍微逛一逛相关源码了解一下。

上一 part 我们有提到,Animation 会替换 RendererFactory2 Provider。

然后在 createRenderer 的时候

在加载 Animation Module 期间,Angular 并不会停止渲染,它会用普通的 DomRenderer 完成,这个时候 @triggerName 将不会被处理,也就不会出现任何动画效果了。

等到 Animation Module 加载完成后,它会利用 NgZone 做 refresh LView,这是 @triggerName 才会被处理,动画效果才开始跑。

也因为它依赖 NgZone 做第二轮的 refresh LView,所以目前在 Zoneless 的情况下,第二轮 refresh LView 不会被执行,动画效果不会跑,这是它的 Github Issue – Animation not initialized when using ZonelessChangeDetection

目前视乎没有 workaround,建议先不要使用 Lazy Loading Animation Module。

 

 

目录

上一篇 Angular 17+ 高级教程 – HttpClient

下一篇 Angular 17+ 高级教程 – Reactive Forms

想查看目录,请移步 Angular 17+ 高级教程 – 目录

 

posted @ 2024-03-17 13:05  兴杰  阅读(75)  评论(0编辑  收藏  举报