使用 Angular 进行项目开发的程序员应该都很熟悉 Angular Animation。这是一个 Angular 原生的动画库,它可以替代或者辅助完成原本需要使用 css 的动画功能。
Angular 在国内的运用是很有限的,可借鉴的文档并不很丰富。尤其对于 Angular 动画模块的应用类文档更是少见。我认为原因可能是大家普遍认为动画应当是由 css 去实现的,毕竟它有非常完善并且兼容性极强的动画功能。而且作为前端工程师,精通 css 是基本功,使用 css 完成页面的动画功能成本低、效率高。
既然如此那又为什么需要 Angular Animation 呢?我在实际的项目中体会到,相比 css 动画,Angular Animation 最大的优点是能够提供一系列很准确的关键帧回调函数(callback)。
下面是我模仿项目中的功能写的一个例子。我会罗列出所遇到的问题,并且逐一阐述我的解决方案。
代码环境:
{"name": "blog-angular-animation","version": "0.0.0","scripts": {"ng": "ng","start": "ng serve","build": "ng build","watch": "ng build --watch --configuration development","test": "ng test"},"private": true,"dependencies": {"@angular/animations": "^17.1.0","@angular/common": "^17.1.0","@angular/compiler": "^17.1.0","@angular/core": "^17.1.0","@angular/forms": "^17.1.0","@angular/platform-browser": "^17.1.0","@angular/platform-browser-dynamic": "^17.1.0","@angular/router": "^17.1.0","lodash": "^4.17.21","rxjs": "~7.8.0","tslib": "^2.3.0","zone.js": "~0.14.3"},"devDependencies": {"@angular-devkit/build-angular": "^17.1.0","@angular/cli": "^17.1.0","@angular/compiler-cli": "^17.1.0","@types/jasmine": "~5.1.0","@types/lodash": "^4.14.202","jasmine-core": "~5.1.0","karma": "~6.4.0","karma-chrome-launcher": "~3.2.0","karma-coverage": "~2.2.0","karma-jasmine": "~5.1.0","karma-jasmine-html-reporter": "~2.1.0","typescript": "~5.3.2"}
}
Demo 效果如下所示,这是一个简单的列表元素添加删除功能:
- 点击 Add 会在列表末尾添加一个元素
- 点击 Delete 会从列表中删除当前元素,并且调整后续元素的序号
Animation Demo
现在我们为这两个行为添加一些动画。下面是对动画过程的详细描述:
1. add
添加节点动画分为3个步骤
2.宽度从1%增加到100%
3.渐入式显示内部元素
这三个步骤可以使用一个transition来完成,请看下面的代码:
typescript
trigger('addNode', [transition(':enter', [style({ height: '0px', width: '1%' }),query('.list-item-index', [style({ opacity: 0 })], { optional: true }),query('.list-item-value', [style({ opacity: 0 })], { optional: true }),query('.list-item-btn', [style({ opacity: 0 })], { optional: true }),group([style({ height: '0px', width: '1%' }),animate('0.2s ease-in-out', style({ height: '50px' }))]),group([style({ width: '1%' }),animate('0.2s 0.1s ease-in-out', style({ width: '100%' }))]),group([query('.list-item-index', [style({ opacity: 0 }),animate('0.2s 0.3s ease-in-out', style({ opacity: 1 }))], { optional: true }),query('.list-item-value', [style({ opacity: 0 }),animate('0.2s 0.3s ease-in-out', style({ opacity: 1 }))], { optional: true }),query('.list-item-btn', [style({ opacity: 0 }),animate('0.2s 0.3s ease-in-out', style({ opacity: 1 }))], { optional: true })])])
])
这里有两个问题需要注意:
初始的状态需要被设定
可以看到最上面的四行代码
typescript
style({ height: '0px', width: '1%' }),
query('.list-item-index', [style({ opacity: 0 })], { optional: true }),
query('.list-item-value', [style({ opacity: 0 })], { optional: true }),
query('.list-item-btn', [style({ opacity: 0 })], { optional: true }),
它们的作用就是设定动画开始时的各个元素的初始状态。目的是防止动画开始时的抖动。
因为触发动画的状态是:enter,所以当第一次渲染整个列表时,所有的节点都会触发动画。这可能不是我们需要看到的。此时我们需要其它的参数来标识出不需要动画的节点
如下所示,当我刷新了页面后,所有节点的 add animation 都执行了
我们可以利用 Angular animation 的 disabled 属性来禁用非必要动画
html
<div class="my-animation-container"><div class="list-container" [@fadeIndexMarke]="fadeIndexMarkeStatus" (@fadeIndexMarke.done)="fadeIndexMarkeDone()"><div class="list-item-container"*ngFor="let item of list"[@.disabled]="animationNodeIndex !== item.index"[@addNode][@deleteNode](@addNode.done)="addAnimationDone()"(@deleteNode.done)="deleteAnimationDone()"><div class="list-item-index" [ngStyle]="{ opacity: animationRunning ? 0 : 1 }">{{item.index}}</div><div class="list-item-value">{{item.value}}</div><div class="list-item-btn" (click)="handleDelete(item.index)">Delete</div></div></div><div class="list-active" (click)="handleAdd()">Add</div>
</div>
typescript
handleAdd() {this.animationNodeIndex = this.list?.length || 0;this.addNode(); // Push a node in list
}addAnimationDone() {if (this.animationNodeIndex >= 0) {this.animationNodeIndex = -1;}
}
这样就可以在我们需要动画的时候再执行它
2. delete
这个动画看似与 add animation 相似,但是过程却比它要复杂一些。下面是完整的动画:
这个动画分为3个步骤:
- 隐藏所有的序号
- 删除指定节点
- 显示节点前的序号
这一组动画有很强的顺序性,必须是上一个动画执行完后才能执行下一个动画。特别要注意的是删除节点的操作需要在第二步完成,所以我们需要监听第一个步骤完成时的回调。
这在 css 中很难实现,可能需要借助 setTimeout。在 Angular 中,定时器并非是解决问题的一个好的选择。
Angular Animation 为我们提供了一个更好的方案。我们可以将动画拆分成两部分绑定在不同的元素上
typescript
animations: [trigger('fadeIndexMarke', [transition('fadeIn => fadeOut', [query('.list-item-index', [style({ opacity: 1 }),animate('0.2s ease-in-out', style({ opacity: 0 }))], { optional: true })]),transition('fadeOut => fadeIn', [query('.list-item-index', [style({ opacity: 0 }),animate('0.2s ease-in-out', style({ opacity: 1 }))], { optional: true })])]),trigger('deleteNode', [transition(':leave', [style({ width: '100%', height: '50px', overflow: 'hidden' }),query('.list-item-index', style({ opacity: 0 }), { optional: true }),group([query('.list-item-value', [style({ opacity: 1 }),animate('0.2s ease-in-out', style({ opacity: 0 }))], { optional: true }),query('.list-item-btn', [style({ opacity: 1 }),animate('0.2s ease-in-out', style({ opacity: 0 }))], { optional: true })]),group([animate('0.2s 0.2s ease-in-out', style({ width: '0%' })),animate('0.2s 0.3s ease-in-out', style({ height: '0px' }))])])])]
上面这两个动画分别绑定在最外围的列表元素和每一个节点元素上
html
<div class="list-container" [@fadeIndexMarke]="fadeIndexMarkeStatus"(@fadeIndexMarke.done)="fadeIndexMarkeDone()"><div class="list-item-container"*ngFor="let item of list"[@deleteNode](@deleteNode.done)="deleteAnimationDone()">
...
我们可以先执行隐藏索引的动画,然后监听 animation.done,此时再删除指定节点。
typescript
fadeIndexMarkeDone() {if (this.fadeIndexMarkeStatus === 'fadeOut') {// Step 2this.animationRunning = true;this.fadeIndexMarkeCallBack();}
}handleDelete(index: number) {// Step 1this.fadeIndexMarkeCallBack = () => {// Step 3this.deleteNode(index);};this.fadeIndexMarkeStatus = 'fadeOut';this.animationNodeIndex = index;
}deleteAnimationDone() {// Step 4if (this.animationRunning) {this.animationRunning = false;this.fadeIndexMarkeStatus = 'fadeIn';this.animationNodeIndex = -1;this.fadeIndexMarkeCallBack = () => {};}
}
这样动画的执行顺序就可以按照我们的需求来规划了。下面是完整的代码:
html
<div class="my-animation-container"><div class="list-container" [@fadeIndexMarke]="fadeIndexMarkeStatus" (@fadeIndexMarke.done)="fadeIndexMarkeDone()"><div class="list-item-container"*ngFor="let item of list"[@.disabled]="animationNodeIndex !== item.index"[@addNode][@deleteNode](@addNode.done)="addAnimationDone()"(@deleteNode.done)="deleteAnimationDone()"><div class="list-item-index" [ngStyle]="{ opacity: animationRunning ? 0 : 1 }">{{item.index}}</div><div class="list-item-value">{{item.value}}</div><div class="list-item-btn" (click)="handleDelete(item.index)">Delete</div></div></div><div class="list-active" (click)="handleAdd()">Add</div></div>
less
.my-animation-container {width: 100%;height: 100%;overflow: hidden;display: flex;flex-flow: column;justify-content: center;align-items: center;.list-container {width: 400px;height: 600px;border: 2px solid gray;overflow-x: hidden;overflow-y: auto;padding: 20px;.list-item-container {width: 100%;height: 50px;border: 1px solid #CCCCCC;display: flex;flex-flow: row nowrap;justify-content: space-between;align-items: center;padding: 0 20px;.list-item-index {font-size: 24px;font-weight: 800;color: #666666;opacity: 1;&.hide-index {opacity: 0;}}.list-item-value {font-size: 20px;font-weight: 500;color: #666666;}.list-item-btn {font-size: 14px;font-weight: 500;color: #666666;border: 2px solid skyblue;border-radius: 5px;padding: 5px;cursor: pointer;&:hover {background-color: skyblue;color: white;}&:active {background-color: white;color: skyblue;}}}}.list-active {font-size: 20px;font-weight: 500;color: #666666;border: 2px solid skyblue;border-radius: 5px;padding: 5px;cursor: pointer;margin-top: 20px;&:hover {background-color: skyblue;color: white;}&:active {background-color: white;color: skyblue;}}
}
typescript
import { Component, OnInit } from '@angular/core';
import { animate, style, transition, trigger, state, group, query } from '@angular/animations';import * as _ from 'lodash';@Component({selector: 'my-animation',templateUrl: './animation.component.html',styleUrls: ['./animation.component.less'],animations: [trigger('fadeIndexMarke', [transition('fadeIn => fadeOut', [query('.list-item-index', [style({ opacity: 1 }),animate('0.2s ease-in-out', style({ opacity: 0 }))], { optional: true })]),transition('fadeOut => fadeIn', [query('.list-item-index', [style({ opacity: 0 }),animate('0.2s ease-in-out', style({ opacity: 1 }))], { optional: true })])]),trigger('addNode', [transition(':enter', [style({ height: '0px', width: '1%' }),query('.list-item-index', [style({ opacity: 0 })], { optional: true }),query('.list-item-value', [style({ opacity: 0 })], { optional: true }),query('.list-item-btn', [style({ opacity: 0 })], { optional: true }),group([style({ height: '0px', width: '1%' }),animate('0.2s ease-in-out', style({ height: '50px' }))]),group([style({ width: '1%' }),animate('0.2s 0.1s ease-in-out', style({ width: '100%' }))]),group([query('.list-item-index', [style({ opacity: 0 }),animate('0.2s 0.3s ease-in-out', style({ opacity: 1 }))], { optional: true }),query('.list-item-value', [style({ opacity: 0 }),animate('0.2s 0.3s ease-in-out', style({ opacity: 1 }))], { optional: true }),query('.list-item-btn', [style({ opacity: 0 }),animate('0.2s 0.3s ease-in-out', style({ opacity: 1 }))], { optional: true })])])]),trigger('deleteNode', [transition(':leave', [style({ width: '100%', height: '50px', overflow: 'hidden' }),query('.list-item-index', style({ opacity: 0 }), { optional: true }),group([query('.list-item-value', [style({ opacity: 1 }),animate('0.2s ease-in-out', style({ opacity: 0 }))], { optional: true }),query('.list-item-btn', [style({ opacity: 1 }),animate('0.2s ease-in-out', style({ opacity: 0 }))], { optional: true })]),group([animate('0.2s 0.2s ease-in-out', style({ width: '0%' })),animate('0.2s 0.3s ease-in-out', style({ height: '0px' }))])])])]
})export class MyAnimationComponent implements OnInit {list: { index: number; value: string; }[] = [];animationRunning = false;animationNodeIndex: number = -1;fadeIndexMarkeStatus = 'fadeIn';fadeIndexMarkeCallBack = () => {};ngOnInit() {this.list = _.chain(3).range().map((num) => ({ index: num, value: `This is the ${num + 1}'s item` })).value();}fadeIndexMarkeDone() {if (this.fadeIndexMarkeStatus === 'fadeOut') {// Step 2this.animationRunning = true;this.fadeIndexMarkeCallBack();}}handleAdd() {this.animationNodeIndex = this.list?.length || 0;this.addNode();}handleDelete(index: number) {// Step 1this.fadeIndexMarkeCallBack = () => {// Step 3this.deleteNode(index);};this.fadeIndexMarkeStatus = 'fadeOut';this.animationNodeIndex = index;}addAnimationDone() {if (this.animationNodeIndex >= 0) {this.animationNodeIndex = -1;}}deleteAnimationDone() {// Step 4if (this.animationRunning) {this.animationRunning = false;this.fadeIndexMarkeStatus = 'fadeIn';this.animationNodeIndex = -1;this.fadeIndexMarkeCallBack = () => {};}}private addNode() {const targetIndex = (this.list?.length || 0);this.list = _.concat(this.list, [{ index: targetIndex, value: `This is the ${targetIndex + 1}'s item` }]);}private deleteNode(index: number) {this.list = _.reduce(this.list, (result: { index: number; value: string; }[], curr, currIndex) => {if (currIndex > index) {curr.index -= 1;curr.value = `This is the ${curr.index + 1}'s item`;result.push(curr);} else if (currIndex < index) {result.push(curr);} else {// currIndex === index, exclude node}return result;}, []);}
}
以上所谈到的是我在项目中遇到的主要问题以及解决的方案。
下面还有一些在后期优化时所遇到的问题:
动画的回调函数的时机
Angular Animation 的 done 可以监听动画完成时的回调。这是官方文档的说法,但实际上它监听的是 animation state 的改变。组件在初始化后动画的状态就会改变,如下所示:
我没有执行任何操作但是 done 就被调用了,所以在监听这个回调的时候我们需要额外的参数来进行判断。
过多的 DOM 元素导致过多的渲染
列表中的节点越多,重新渲染的性能就越低。甚至当组件过于复杂或者嵌套的子组件过多的时候,动画会出现卡顿。
解决的方法是对组件进行优化,尽量减少 DOM 元素。或者降低子组件数量和嵌套层数。Angular 在渲染时会解析组件中所有的子组件,这在性能上会造成极大的损耗,所以应当尽量减少动画所影响到的组件。
节点宽高不定时,如何设定动画宽高的变化值
如果节点的宽高是自适应的,那么我们动画关键帧的 style 就最好使用百分比来表示。或者使用 transform: scale 来进行缩放。
简单的动画细节使用 animation 过于繁琐
定义一个动画需要 trigger, state, style 等一系列属性,即便完成一个很细节的动画也需要写很多代码。这时可以使用 transition 来替代动画,减少代码量。
元素的定位问题
这是一个很容易被忽略的问题。当我们的元素中包含绝对定位时,不同的定位方向可能导致动画的错乱。有些元素可能在动画中被截断,也有一些会发生意想不到的偏移。所以如果绑定动画的组件中存在不同的定位,最好是都统一成一个方向的绝对定位。