导读
在上一篇文章中,我们详细介绍了Vision动效平台的渲染引擎——Crab,并分享在复杂动效渲染场景下积累的实践经验和精彩案例。今天,我们将揭秘如何将「动效描述翻译为动效代码」——从Lottie导出CSS/Animated代码。
在进行前端页面开发中,经常需要涉及到元素动效的开发,比如按钮的呼吸状态动效,弹窗的出现和消失动效等等,这些动效为用户在页面交互过程中获得良好的体验起到重要的作用。
1.1 元素动效开发的痛点
Total Dur: 1200ms
≡ 盒子.png ≡
- 缩放 -
Delay: 0ms
Dur: 267ms
Val: 0% ›› 189.6%
(0.33, 0, 0.67, 1)
- 缩放 -
Delay: 267ms
Dur: 500ms
Val: [189.6,189.6]%››[205.4,173.8]%
(0.33, 0, 0.83, 1)
- 缩放 -
Delay: 767ms
Dur: 67ms
Val: [205.4,173.8]%››[237,142.2]%
(0.17, 0, 0.83, 1)
- 缩放 -
Delay: 833ms
Dur: 100ms
Val: [237,142.2]%››[142.2,237]%
(0.17, 0, 0.83, 1)
- 缩放 -
Delay: 933ms
Dur: 167ms
Val: [142.2,237]%››[205.4,173.8]%
(0.17, 0, 0.83, 1)
- 缩放 -
Delay: 1100ms
Dur: 100ms
Val: [205.4,173.8]%››[189.6,189.6]%
(0.17, 0, 0.67, 1)
- 位置 -
Delay: 833ms
Dur: 100ms
Val: [380,957]››[380,848]
(0.33, 0, 0.67, 1)
- 位置 -
Delay: 933ms
Dur: 133ms
Val: [380,848]››[380,957]
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 267ms
Dur: 73ms
Val: 0° ››› -3°
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 340ms
Dur: 73ms
Val: -3° ››› 3°
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 413ms
Dur: 73ms
Val: 3° ››› -3°
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 487ms
Dur: 73ms
Val: -3° ››› 3°
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 560ms
Dur: 73ms
Val: 3° ››› -3°
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 633ms
Dur: 67ms
Val: -3° ››› 3°
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 700ms
Dur: 67ms
Val: 3° ››› 0°
(0.33, 0, 0.67, 1)
Total Dur: 500ms
≡ 盖子_关.png ≡
- 位置 -
Delay: 0ms
Dur: 500ms
Val: [74,13]››[74,13]
No Change
- 旋转 -
Delay: 0ms
Dur: 28ms
Val: 0.75° ››› 0°
(0.33, 0.54, 0.83, 1)
- 旋转 -
Delay: 28ms
Dur: 72ms
Val: 0° ››› -2°
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 100ms
Dur: 72ms
Val: -2° ››› 2°
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 172ms
Dur: 72ms
Val: 2° ››› -2°
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 244ms
Dur: 72ms
Val: -2° ››› 2°
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 317ms
Dur: 72ms
Val: 2° ››› -2°
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 389ms
Dur: 72ms
Val: -2° ››› 2°
(0.33, 0, 0.67, 1)
- 旋转 -
Delay: 461ms
Dur: 39ms
Val: 2° ››› 0.75°
(0.33, 0, 0.67, 0.55)
(盒子.png是盖子_关.png父级)
Total Dur: 1633ms
≡ 盖子_开.png ≡
- 位置 -
Delay: 0ms
Dur: 1633ms
Val: [113,5]››[113,5]
Linear
(在第1633ms,切换 盖子_开.png和盒子.2.png)
Total Dur: 267ms
≡ 盒子.2.png ≡
- 缩放 -
Delay: 0ms
Dur: 267ms
Val: 189.6% ›› 0%(0.17, 0, 0.83, 1)
表格动效参数交付示例:
要解决这个痛点,我们可以考虑将「从动效描述翻译为动效代码」的工作通过自动化的方式完成。而要实现这个自动化的流程,首先要解决的就是设计师提供的动效描述没有统一格式的问题。
最适合用作动效描述统一格式的方案就是Lottie,Lottie是一个基于JSON的动画文件格式,它可以使用Bodymmovin解析导出Adobe After Effects动画,并在移动设备上渲染它们。通过它,设计师可以创造和发布酷炫的动画,且无需工程师费心的手工重建动画效果。
它具有以下优点:
标准化:Lottie的JSON格式中,每个属性的含义和数据类型都很明确,相比于自然语言的描述方式,更加清晰明确。
无感知:设计师在AE中完成动效的编辑后,可以直接使用AE的BodyMovin插件导出我们期望Lottie格式动效描述,导出过程不会为设计师引入额外的成本。
透明化:Lottie的运行库是开源的,这意味着我们可以通过它的代码和文档完全弄清楚json中每一个字段的具体含义和处理方式。
在进行代码转换之前,我们首先来介绍下Lottie的JSON格式。
首先在Lottie格式的Root层,会存储动画的全局信息,比如动效的展示宽高,播放帧率,引用的图片等资源描述以及动画细节描述等。
interface LottieSchema {
/**
* Adobe After Effects 插件 Bodymovin 的版本
* Bodymovin Version
*/
v: string;
/**
* Name: 动画名称
* Animation name
*/
nm: string; // name
/**
* Width: 动画容器宽度
* Animation Width
*/
w: number; // width
/**
* Height: 动画容器高度
* Animation Height
*/
h: number; // height
/**
* Frame Rate: 动画帧率
* Frame Rate
*/
fr: number; // fps
/**
* In Point: 动画起始帧
* In Point of the Time Ruler. Sets the initial Frame of the animation.
*/
ip: number; // startFrame
/**
* Out Point: 动画结束帧
* Out Point of the Time Ruler. Sets the final Frame of the animation
*/
op: number; // endFrame
/**
* 3D: 是否含有3D特效
* Animation has 3-D layers
*/
ddd: BooleanType;
/**
* Layers: 特效图层
* List of Composition Layers
*/
layers: RuntimeLayer[]; // layers
/**
* Assets: 可被复用的资源
* source items that can be used in multiple places. Comps and Images for now.
*/
assets: RuntimeAsset[]; // assets
// ......
}
2.1 AE中动画的实现方式
图层拆分 | 最终效果 |
小手图层 | |
背景光晕图层 | |
外圈光晕图层 | |
内圈光晕图层 |
layers是一个数组,其中的每一项会描述来自AE的一个图层的具体动画信息和展示信息。AE中有许多不同的图层类型,每种有不同的特性和用途,Lottie中最常用的图层类型有:文本图层、图像图层、纯色图层、空图层以及合成图层等,所有图层有一些通用的属性,其中比较重要的属性如下:
type LottieBaseLayer {
/**
* Type: 图层类型
* Type of layer
*/
ty: LayerType;
/**
* Key Frames: Transform和透明度动画关键帧
* Transform properties
*/
ks: RuntimeTransform;
/**
* Index: AE 图层的 Index,用于查找图层(如图层父级查找和表达式中图层查找)
* Layer index in AE. Used for parenting and expressions.
*/
ind: number;
/**
* In Point: 图层开始展示帧
* In Point of layer. Sets the initial frame of the layer.
*/
ip: number;
/**
* Out Point: 图层开始隐藏帧
* Out Point of layer. Sets the final frame of the layer.
*/
op: number;
/**
* Start Time: 图层起始帧偏移(合成维度)
* Start Time of layer. Sets the start time of the layer.
*/
st: number;
/**
* Name: AE 图层名称
* After Effects Layer Name
*/
nm: string;
/**
* Stretch: 时间缩放系数
* Layer Time Stretching
*/
sr: number;
/**
* Parent: 父级图层的 ind
* Layer Parent. Uses ind of parent.
*/
parent?: number;
/**
* Width: 图层宽度
* Width
*/
w?: number;
/**
* Height: 图层高度
* Height
*/
h?: number;
}
所有图层中都含有描述Transform关键帧的ks属性,这也是我们在做动效代码转换时着重关注的属性。ks属性中会描述图层的位移、旋转、缩放这样的Transform属性以及展示透明度的动画,其中每一帧(每一段)的描述格式大致如下:
// keyframe desc
type KeyFrameSchema<T extends Array<number> | number> {
// 起始数值 (p0)
s: T;
// 结束数值 (p3)
e?: T;
// 起始帧
t: number;
// 时间 cubic bezier 控制点(p1)
o?: T;
// 时间 cubic bezier 控制点(p2)
i?: T;
// 路径 cubic bezier 控制点(p1)
to?: T;
// 路径 cubic bezier 控制点(p2)
ti?: T;
}
图层的关键帧信息中会包含每个关键点的属性数值,所在帧,该点上的控制缓动曲线的出射控制点和入射控制点,另外,对于位移的动画,AE还支持路径运动,在Lottie中的体现就是to和ti两个参数,它们是和当前控制点相关的路径贝塞尔曲线的控制点。
2.2 可复用资产 assets
3.1 CSS代码生成
// in layers
{
"ddd": 0,
"ind": 2,
"ty": 2,
"nm": "截图103.png",
"cl": "png",
"refId": "image_0",
"sr": 1,
"ks": {
"o": {
"a": 1,
"k": [
{
"i": {
"x": [
0.667
],
"y": [
1
]
},
"o": {
"x": [
0.333
],
"y": [
0
]
},
"t": 16,
"s": [
100
]
},
{
"t": 20,
"s": [
1
]
}
],
"ix": 11
},
"r": {
"a": 0,
"k": 0,
"ix": 10
},
"p": {
"a": 1,
"k": [
{
"i": {
"x": 0.874,
"y": 1
},
"o": {
"x": 0.869,
"y": 0
},
"t": 8,
"s": [
414.8,
907.857,
0
],
"to": [
-251.534,
-388.714,
0
],
"ti": [
16,
-336.878,
0
]
},
{
"t": 20,
"s": [
90,
1514.769,
0
]
}
],
"ix": 2,
"l": 2
},
"a": {
"a": 0,
"k": [
414,
896,
0
],
"ix": 1,
"l": 2
},
"s": {
"a": 1,
"k": [
{
"i": {
"x": [
0.667,
0.667,
0.667
],
"y": [
1,
1,
1
]
},
"o": {
"x": [
0.333,
0.333,
0.333
],
"y": [
0,
0,
0
]
},
"t": 8,
"s": [
100,
100,
100
]
},
{
"t": 20,
"s": [
15,
15,
100
]
}
],
"ix": 6,
"l": 2
}
},
"ao": 0,
"ip": 0,
"op": 49,
"st": -95,
"bm": 0
}
.hash5edafe06{
transform-origin: 50% 50%;
animation: hash5edafe06_kf 0.667s 0s linear /**forwards**/ /**infinite**/;
}
@keyframes hash5edafe06_kf {
0% {
opacity: 1;
transform: matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1);
}
15% {
opacity: 1;
transform: matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1);
}
30% {
opacity: 1;
transform: matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1);
}
45% {
opacity: 1;
transform: matrix3d(0.983,0,0,0,0,0.983,0,0,0,0,1,0,-0.847,-1.301,0,1);
}
60% {
opacity: 1;
transform: matrix3d(0.78,0,0,0,0,0.78,0,0,0,0,1,0,-16.751,-23.566,0,1);
}
75% {
opacity: 1;
transform: matrix3d(0.47,0,0,0,0,0.47,0,0,0,0,1,0,-82.509,-56.177,0,1);
}
90% {
opacity: 0.3824875;
transform: matrix3d(0.213,0,0,0,0,0.213,0,0,0,0,1,0,-146.717,120.698,0,1);
}
100% {
opacity: 0.01;
transform: matrix3d(0.15,0,0,0,0,0.15,0,0,0,0,1,0,-162.4,303.456,0,1);
}
}
Tips
虽然是逐帧的方案,但是每秒对应30个甚至更多 CSS keyframes 中的关键帧的话,一方面在效果上没有明显提升,另一方面,也会导致生成的CSS 代码片段更大,因此是没有必要的,更好的方式是每秒采样5-10个关键帧,然后通过设置easing function来将关键帧之间的插值方式设置为线性插值,这样在拟合效果的同时,生成的CSS代码量更少。
生成代码量大:因为是每秒固定间隔采样关键帧,当动画的总时长较长的时候,采样的关键帧会比较多,导致生成的代码量也比较大。
可读性差,不易修改:逐帧方案采样的是每帧的最终transform和透明度,相比原始的Lottie描述,会增加一些冗余信息,不利于人类理解,并且因为采样的关键帧密度比较大且距离近的关键帧相关性高,因此导出的CSS代码很难手动修改,比如一个只包含起点和终点关键帧的路径移动的动画,在Lottie的json中,只需要修改两个数值就可以自然的改变动画的终点,而要在导出的逐帧CSS中实现同样的修改则需要修改者修改多个关键帧的数值,且数值的内容需要自行计算才能得到。
逐帧方案虽然可以拟合Lottie中的动画效果,但有着生成代码量大和可读性差,不易修改的缺点,因此只适合时长较短且比较简单的动效。
从每一个帧动画信息的描述方式来说,Lottie中的动画描述基本都在关键帧信息中进行描述,包括关键帧对应的时间(帧数),属性数值,时间样条曲线(三次贝塞尔控制点)和路径样条曲线(应用在位移的三次贝塞尔控制点)。
变量CSS效果:
代码片段:
<style>
.hash5edafe06_0{
transform-origin: 50% 50%;
animation: hash5edafe06_0_keyframe_0 0.4s 0.267s cubic-bezier(0.333, 0, 0.667, 1) /* forwards */;
}
@keyframes hash5edafe06_0_keyframe_0 {
0% {
transform: scale(1.000,1.000);
}
66.667% {
opacity: 1.000;
}
100% {
opacity: 0.010;
transform: scale(0.150,0.150);
}
}
.hash5edafe06_1{
transform-origin: 50% 50%;
animation: hash5edafe06_1_keyframe_0 0.4s 0.267s cubic-bezier(0.869, 0.774, 0.874, 0.951) /* forwards */;
}
@keyframes hash5edafe06_1_keyframe_0 {
0% {
transform: translateX(0.000px);
}
100% {
transform: translateX(-162.400px);
}
}
.hash5edafe06_2{
transform-origin: 50% 50%;
animation: hash5edafe06_2_keyframe_0 0.4s 0.267s cubic-bezier(0.869, -0.64, 0.874, 0.445) /* forwards */;
}
@keyframes hash5edafe06_2_keyframe_0 {
0% {
transform: translateY(0.000px);
}
100% {
transform: translateY(303.456px);
}
}
</style>
<!-- ....... -->
<!-- order matters -->
<div class='hash5edafe06_2'>
<div class='hash5edafe06_1'>
<div class='hash5edafe06_0'>
<!-- real content -->
</div>
</div>
</div>
Tips
「路径的实现方式」
在上面展示的demo效果还原中涉及到了路径动画的还原,从起点到终点的位移并不是沿着直线移动,而是沿着特定的曲线移动。在还原这个效果前,我们首先观察下路径动画在Lottie中的原始描述方式:
{
{
// 时间插值曲线控制点
"i": {
"x": 0.874,
"y": 1
},
"o": {
"x": 0.869,
"y": 0
},
"t": 8,
"s": [
414.8,
907.857,
0
],
// 路径曲线控制点
"to": [
-251.534,
-388.714,
0
],
"ti": [
16,
-336.878,
0
]
},
{
"t": 20,
"s": [
90,
1514.769,
0
]
}
}
Lottie中的路径曲线也是由三次贝塞尔曲线来进行描述的,而三次贝塞尔曲线则通过它的两个控制点进行描述。而根据贝塞尔曲线的定义,我们可以发现,N维贝塞尔曲线的维度之间是互相独立的,这意味着2D平面上的曲线路径可以通过拆分的x轴和y轴位移来进行重现,如上面demo中.hash5edafe06_1 和 .hash5edafe06_2 中的内容可以重现原Lottie的曲线路径。
不过需要注意的是,路径曲线上的时间曲线并不是简单对应于路径贝塞尔曲线的变量t , 而是对应于路径曲线长度的百分比位置,因此路径曲线上的时间插值曲线并不能完全重现,只能尽量拟合。
变量方案
关键帧CSS:
代码片段:
@property --translateX {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
@property --translateY {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
@property --scaleX {
syntax: '<number>';
inherits: false;
initial-value: 1;
}
@property --scaleY {
syntax: '<number>';
inherits: false;
initial-value: 1;
}
@property --opacity {
syntax: '<number>';
inherits: false;
initial-value: 1;
}
.ba522056 {
transform: translateX(calc(1px *var(--translateX))) translateY(calc(1px *var(--translateY))) scaleX(calc(var(--scaleX))) scaleY(calc(var(--scaleY)));
opacity: calc(var(--opacity));
animation: ba522056_opacity_0 0.13333333333333333s 0.5333333333333333s cubic-bezier(0.333, 0, 0.667, 1) forwards, ba522056_translateX_0 0.4s 0.26666666666666666s cubic-bezier(0.869, 0.774, 0.874, 0.951) forwards, ba522056_translateY_0 0.4s 0.26666666666666666s cubic-bezier(0.869, -0.64, 0.874, 0.445) forwards, ba522056_scaleX_0 0.4s 0.26666666666666666s cubic-bezier(0.333, 0, 0.667, 1) forwards, ba522056_scaleY_0 0.4s 0.26666666666666666s cubic-bezier(0.333, 0, 0.667, 1) forwards
}
@keyframes ba522056_opacity_0 {
0% {
--opacity: 1;
}
100% {
--opacity: 0.01;
}
}
@keyframes ba522056_translateX_0 {
0% {
--translateX: 0;
}
100% {
--translateX: -162.4;
}
}
@keyframes ba522056_translateY_0 {
0% {
--translateY: 0;
}
100% {
--translateY: 303.456;
}
}
@keyframes ba522056_scaleX_0 {
0% {
--scaleX: 1;
}
100% {
--scaleX: 0.15;
}
}
@keyframes ba522056_scaleY_0 {
0% {
--scaleY: 1;
}
100% {
--scaleY: 0.15;
}
}
优点
3.2 总结
总的来说,最理想的解决方案是变量方案,但因为使用了比较新的CSS功能,所以兼容性不佳。关键帧方案适合动画拆解比较简单不会引入辅助嵌套Dom的场景或不介意引入辅助Dom的场景。逐帧方案适合动画持续时间不长且不需要关键帧数值修改的场景,也可作为兜底的解决方案。
代码片段:
function useLayerAnimated() {
const opacityVal = useRef(new Animated.Value(1.00)).current;;
const translateXVal = useRef(new Animated.Value(0.00)).current;;
const translateYVal = useRef(new Animated.Value(0.00)).current;;
const scaleXVal = useRef(new Animated.Value(1.00)).current;;
const scaleYVal = useRef(new Animated.Value(1.00)).current;
const getCompositeAnimation = useCallback(() => {
const opacityAnim =
Animated.timing(opacityVal, {
toValue: 0.01,
duration: 133.333,
useNativeDriver: true,
delay: 533.333,
easing: Easing.bezier(0.333, 0, 0.667, 1),
})
;
const translateXAnim =
Animated.timing(translateXVal, {
toValue: -162.40,
duration: 400,
useNativeDriver: true,
delay: 266.667,
easing: Easing.bezier(0.869, 0.774, 0.874, 0.951),
})
;
const translateYAnim =
Animated.timing(translateYVal, {
toValue: 303.46,
duration: 400,
useNativeDriver: true,
delay: 266.667,
easing: Easing.bezier(0.869, -0.64, 0.874, 0.445),
})
;
const scaleXAnim =
Animated.timing(scaleXVal, {
toValue: 0.15,
duration: 400,
useNativeDriver: true,
delay: 266.667,
easing: Easing.bezier(0.333, 0, 0.667, 1),
})
;
const scaleYAnim =
Animated.timing(scaleYVal, {
toValue: 0.15,
duration: 400,
useNativeDriver: true,
delay: 266.667,
easing: Easing.bezier(0.333, 0, 0.667, 1),
})
return Animated.parallel([
opacityAnim, translateXAnim, translateYAnim, scaleXAnim, scaleYAnim
]);
}, []);
const style = useRef({
transform: [
{translateX: translateXVal}, {translateY: translateYVal}, {scaleX: scaleXVal}, {scaleY: scaleYVal},
],
opacity: opacityVal
}).current;
const resetAnimation = useCallback(() => {
opacityVal.setValue(1.00);
translateXVal.setValue(0.00);
translateYVal.setValue(0.00);
scaleXVal.setValue(1.00);
scaleYVal.setValue(1.00)
}), [];
return {
animatedStyle: style,
resetAnim: resetAnimation,
getAnim: getCompositeAnimation,
}
};
在下期内容中,我们将重点介绍Vision 动效平台在序列帧动效格式转换方面的能力和流程:动效平台通过提供多种序列帧格式自动转换功能,优化动效交付流程,提高动效的兼容性和性能。敬请期待!
- END -
【往期回顾】
……
更多文章,敬请期待!