前言
《年度听歌报告》是网易云音乐每年年底都会为大家带来的保留节目,2023 年也不例外,你是不是也被它刷屏了?不知不觉,这是我参与的第七个年度听歌报告项目了,同样还是负责页面内的动效设计和一部分动效代码编写,下面就和大家分享一下这个项目中的动效是怎么做的。
由于作者不是专业程序员,叙述逻辑和代码细节可能没那么严谨,恳请各位看官斧正。
如果您对这个项目的整体前端架构也感兴趣,欢迎参阅《云音乐2023年度听歌报告前端大揭秘》
本文篇幅较长,阅读时间预计大于 15 分钟。
(如果你很遗憾的错过了,现在扫码查看还来得及↓↓↓)
动效设计的流程
我们先来看看在通常一个项目中是怎么做动效设计的,传统流程是:
动效设计师用设计软件设计并制作动画效果,并与视觉设计师、需求方讨论和修改打磨; 根据技术方案产出视频 Demo 以及对应的动效资源、标注给到开发; 开发人员根据 Demo 用代码编写动效;
如果是动效工作量较少或者对质量要求不高的小型项目,这个流程没什么问题。但对于一些重量级项目,不仅工作量大,对质量要求也会比较高,中间还会有多轮反复的修改,那么这个流程的效率是比较低的,而《年度听歌报告》毫无疑问就是个重量级项目,所以我们需要对动效流程做一些调整。在项目初期我们就把动效部分拆出来,由动效设计师直接用代码编写动效,并与视觉/策划老师沟通和修改;同时前端老师可以更专注于业务逻辑/数据处理等更高优的部分,并行推进项目。动效部分打磨的差不多后,再和前端老师一起 review 并合并代码。
在整体氛围上,听歌报告的动效倾向是温暖、轻快、甚至细微的,不需要过于酷炫炸裂的呈现,因而动效代码我们使用较为基础的 React + CSS 来编写,再加上一些动图一起构成页面整体效果。
聊完整体框架,接下来我们聚焦到具体的动效实现吧。
转场翻页动效
除了部分页面进行特殊处理,听歌报告中主要的翻页动效是「淡入淡出」,但也不是简单的「淡入淡出」。
这里我们使用了 React 官方出品的 CSSTransition 组件[1],它的作用是通过在不同时机应用不同类名来控制其子组件样式。React 代码参考:
<CSSTransition
// in 的值从 false 变为 true 时触发'页面进入',反之触发'页面消失'
in={match}
timeout={100}
classNames={{
// 页面进入前初始化
enter: styles.reportEnter,
// 页面进入完成
enterDone: styles.reportEnterDone,
// 页面离开前初始化
exit: styles.reportExit,
// 页面离开完成
exitDone: styles.reportExitDone,
}}
appear
unmountOnExit>
{/* 页面DOM */}
</CSSTransition>
当对应类名下的样式中设置了 transition 属性,则可以在属性改变时触发及控制动画过渡效果。CSS 关键代码如下:
.reportEnter {
opacity: 0;
}
.reportEnterDone {
opacity: 1;
transition: opacity 2000ms;
}
.reportExit {
transition: opacity 300ms;
opacity: 0;
}
.reportExitDone {
opacity: 0;
}
未设置 transition 效果如下图:
设置 transition 后:
此时「淡入淡出」的翻页效果就基本完成了,但我们体验过程中发现,页面直接的背景是不一样的,在翻页的过程中会有一瞬间露出整个听歌报告的底色(默认是白色),在快速翻页的时候会感觉一直在「闪」,不是很舒服。于是我们就再加入亿点点细节,将每次翻页过程中「露出」的底色设为即将翻过来的页面的底色。
关键 React 代码如下:
{pages.map((item, index) => {
// 遍历数组,获取容器背景色
const bgCol = item.backgroundColor;
return (
<div style={{
...
background: match ? bgCol : '',
}}>
<CSSTransition>
{/* 页面DOM */}
</CSSTransition>
</div>
)
}
)}
这样效果就比较合适了。
文字动效
文字出现动效和往年一样,用透明度渐现 + 向上位移逐行出现的方案,根据页面风格微调了动画时长和「缓动曲线」。这里的主角不是动效而是文字的内容,所以我们希望它恰到好处而又不喧宾夺主。
这部分简单用 CSS Animation[2] 来实现,关键代码如下:
.textAni{
animation-name:textAniKey;
animation-duration:1.5s;
animation-timing-function:cubic-bezier(0, 0, 0.5, 1);
animation-iteration-count:1;
animation-direction:normal;
animation-fill-mode:both;
}
@keyframes textAniKey{
0% {
transform:translateY(2vw);
opacity: 0;
}
100% {
transform:translateY(0vw);
opacity: 1;
}
}
这里比较关键的是 「animation-timing-function」[3]属性,也就是我们常说的「缓动曲线」、「时间插值」,可以用关键字设置值,例如如
匀速运动「linear」 加速运动「ease-in」 减速运动「ease-out」 先加速后减速「ease」 特化的三阶贝塞尔函数「cubic-bezier(x1,y1,x2,y2)」
其中「贝塞尔函数」是用来更精细化调整运动节奏的,例如之前代码中的「cubic-bezier(0, 0, 0.5, 1)」就是比 「ease-out」更「剧烈」一点的减速运动。关于「animation-timing-function」更深入的资料可以参阅 MDN 文档中缓动函数[4]相关章节,以及可以在Cubic-Bezier[5]这个网站可视化的调整并获取自定义的缓动效果。
这个三阶贝塞尔函数我们后边还会聊到,不过不是用在控制动画的节奏上,而是用于塑造曲线的形状,或者说这才是它更常见的用途。
这里还有个属性也值得说一下:「animation-delay」,也就是时间延迟。一个非常常用的 CSS 动画小技巧,即给同类的一组图片资源(一组音符、一组星星...)应用同一个 CSS 动画,但设置不同的「animation-delay」 ,通常是一个小幅度的递增或递减,用来表现物体漂浮,摇曳等效果非常方便,甚至可以将值设为负数,这样动画就能一开始就存在。
这个技巧在后续页面的动效中也经常能看到。
简单页面动效
下面我们来一点点拆解各个页面的动效吧,先从比较简单的部分聊起。
「初次相遇」
这个页面承接了开场动画的结尾,进入时有一颗星星从天而降,爆闪一次,然后循环轻微闪烁。
「从天而降」是一组动画,「爆闪」是一组动画,轻微闪烁也是一组动画。这颗流星的结构是一个父容器+若干子容器(图片资源),各自应用了一些动画,具体可以看下参考代码实现:React 部分:
// 最外层只负责整体位移和透明度动画,通过 transition 定义
<div
className={styles.starOnSky}
style={{
position: 'absolute',
transition: 'all 1.5s cubic-bezier(0,0,0,1) 0.2s',
opacity: starIn ? 1 : 0,
transform: `translateY(${starIn ? 0 : -20}vw)`,
}}>
// 循环轻微闪烁的星星
<img src={bigStarWithGlow} className={styles.bigStarWithGlow} />
// 拖尾
<img src={bigStarTail} className={styles.bigStarTail} />
// 最开始爆闪的那一颗大星星。只播一次动画就消失。
<img src={starLight}
className={
starIn ? `${styles.starLight} ${styles.starLightAni}` : styles.starLight
} />
</div>
CSS 部分:
/* 循环轻微闪烁的星星动画 */
.bigStarWithGlow {
/* 省略部分静态样式代码 */
animation: starGlowAniKey 3s cubic-bezier(0.25, 0, 0.75, 1) 0s infinite normal both;
}
@keyframes starGlowAniKey {
0% {
transform: scale(0.8);
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(0.8);
}
}
/* 最开始爆闪的那一颗大星星。只播一次动画就消失。 */
.starLight {
/* 省略部分静态样式代码 */
transform: scale(0);
}
.starLightAni {
animation: starLightAnikey 2s ease-in-out 0s 1 normal both;
}
@keyframes starLightAnikey {
0% {
transform: scale(0);
}
50% {
transform: scale(1.5);
}
100% {
transform: scale(0);
}
}
背景上我们将海浪拆分出三层,给其中两层添加一个上下位移动画,通过「animation-delay」属性稍稍错开。
CSS 动画代码参考:
.waveAni {
animation: waveAniKey 8s cubic-bezier(0.25, 0, 0.75, 1) 0s infinite normal both;
}
@keyframes waveAniKey {
0% {
transform: translateY(-10vw);
}
50% {
transform: translateY(-4vw);
}
100% {
transform: translateY(-10vw);
}
}
同时通过动图来添加一些细节,比如溅起的浪花,闪烁的星星等:
「听歌总览」
这个页面中,飞溅的水珠依然是用动图表现:
下落的水滴却有不同处理:背景的水滴动效相对简单(位移+透明度变化),通过 CSS 可以完成,然后通过在构造时生成随机数,让它们的分布、长短在每一次进入这个页面时都有所不同,这个技巧在之后的页面也会经常用到;创建「水滴」同样用了「animation-delay」属性来将每一组水滴下落的时间间隔开,使得效果更接近现实。
React 动画代码参考:
// 生成随机数数组
const lineCount = 10;// 水滴数量
const animationDuration = 25;// 每组水滴下落动画总时长
// 预生成随机数,存入数组
const lineRandomGroup = useMemo(() => Array.from(
{ length: lineCount },
() => [Math.random(), Math.random(), Math.random()]
), [lineCount]);
// 省略部分代码
// 生成「水滴」
{lineRandomGroup.map((item, index) => {
// 每组雨滴 delay 间隔
const step = -animationDuration / lineCount;
return (
<div
className={styles.rainGroup}
key={`lineRandomGroup_${index}`}
// 直接行内设置样式,部分覆盖 CSS 文件中的默认属性
style={{
left: `${-20 + 120 * item[0]}vw`,
animationDuration: `${animationDuration * (0.5 + 0.5 * item[1])}s`,
animationDelay: `${index * step}s`,
}}>
<div
className={styles.rainLine}
style={{
opacity: item[2],
height: `${50 * item[1]}vw`,
}} />
<img
src={fallingStar}
className={styles.fallingStar}
style={{
opacity: 0.5 + 0.5 * item[2],
}} />
</div>
);
})}
CSS 动画代码参考:
.rainGroup {
position: absolute;
width: 0.2vw;
height: 50vw;
/* 为简洁计将多个animation属性简写至一行,后同 */
animation: lineFallAniKey 10s linear 0s infinite normal both;
}
@keyframes lineFallAniKey {
0% {
opacity: 0;
transform: translateY(-50vw);
}
30% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(200vw);
}
}
而几滴滴到小人手上溅起音符的动效相对复杂一些,则是又换成了动图,这样制作和调试效率更高。
「曲风排行」
在这个页面我们希望这些「花朵」有一种在风中摇曳的效果。首先我们将每一朵花旋转的中心通过「transform-origin」设为图中花枝条与地面相接之处(本例中皆为左下角),这样花朵看起来才像是「长」在地面;
transform-origin: 0% 100%;
然后为它加上轻微的旋转动画。CSS 动画代码参考:
.flowerAni {
animation: flowerAniKey 8s cubic-bezier(0.3, 0, 0.7, 1) 0s infinite normal both;
}
@keyframes flowerAniKey {
0% {
transform: rotate(-2deg);
}
50% {
transform: rotate(2deg);
}
100% {
transform: rotate(-2deg);
}
}
可能你也注意到了背景的白色枝条也在一起晃动,但这里我们并没有拆成这么多条白杆图层,而是对他们整体应用了一个 skew 动画。
CSS 动画代码参考:
.bgFlowerAni {
animation: bgFlowerAniKey 8s cubic-bezier(0.3, 0, 0.7, 1) 0s infinite normal both;
}
@keyframes bgFlowerAniKey {
0% {
transform: skewX(-3deg);
}
50% {
transform: skewX(0deg);
}
100% {
transform: skewX(-3deg);
}
}
调整一下动画时长和节奏,当然还有 delay,让它匹配上曲风之花的动画,就做出很多花在随风摇曳的效果了。这页还有个飘散的花瓣动画,我们后边再一起聊聊。
「四季听歌」
我们将春、夏、秋、冬几个字进行了艺术化处理,然后将他们的笔画拆分开,对每个拆出来的笔画应用相同的位移动画。
CSS 动画代码参考:
.wallUpAni {
animation: wallUpAniKey 2.5s cubic-bezier(0, 0, 0.2, 1) 0s 1 normal both;
}
@keyframes wallUpAniKey {
0% {
transform: translateY(25vw);
}
100% {
transform: translateY(0);
}
}
然后同样用 animation-delay 将它们错开播放,就呈现笔画依次慢慢升起组成文字的动效。
「最晚听歌」
这个页面主要是模拟极光的明暗变化。我们将极光元素拆分成多组图片:
为它们加上透明度、亮度(filter:brightness())、位移、缩放等组合的动画。当然这里也少不了「animation-delay」的设置。CSS 动画代码参考:
.auroraAni {
transform-origin: 50% 100%;
animation: auroraAniKey 10s cubic-bezier(0.2, 0, 0.8, 1) 0s infinite normal both;
}
@keyframes auroraAniKey {
0% {
transform: translateY(0) scale(1, 1);
filter: brightness(0.5);
opacity: 0.5;
}
50% {
transform: translateY(-3vw) scale(1, 1.1);
filter: brightness(1.5);
opacity: 1;
}
100% {
transform: translateY(0) scale(1, 1);
filter: brightness(0.5);
opacity: 0.5;
}
}
但这还是少了点发光的感觉,于是我们将整组极光复制一层,对其中一层使用模糊滤镜(filter:blur()),另一层设置混合模式为「屏幕」(mix-blend-mode:screen);CSS 滤镜可以为元素叠加多种视觉效果,感兴趣可以看看 MDN 文档中 CSS 滤镜[6]相关章节; 而混合模式则是描述元素的内容应该与元素的直系父元素的内容和元素的背景如何混合,感兴趣可以参考 MDN 文档中 CSS 混合模式[7]相关章节。这些属性极大的丰富了 CSS 的视觉处理手段,但也要注意兼容性和中低端设备的性能问题。
<div style="mix-blend-mode: screen;">{/* 极光元素 DOM */}</div>
<div style="filter: blur(15px);">{/* 极光元素 DOM */}</div>
「发光」何必是发光,可能「模糊」在伪装...
还有一些页面的动效也是 CSS Animation + delay 的简单组合应用,限于篇幅就不做进一步拆解了
简单粒子动效
在「曲风排行」页面飘散的花瓣,以及在「司机听歌」页面中「秋」飘的叶子、「冬」下的雪,其本质上也是引入了随机函数去构造的一系列 CSS 动画。
可以说这是一个基于 CSS「手搓」的「粒子系统」。我们先构造单独的一个粒子动画,对于部分需要改变的属性,不是写死数值,而是用「 CSS 变量」给它赋值。CSS 代码参考:
.snow {
position: absolute;
width: 15px;
height: 15px;
border-radius: 50%;
animation:snowAniKey 10s linear 0s infinite normal both;
}
@keyframes snowAniKey {
0%{
transform:translate(0,-20px)
scale(var(--snow-scale))
rotate(0deg);
}
100%{
transform:translate(var(--snow-end-x),var(--snow-end-y))
scale(var(--snow-scale))
rotate(360deg);
}
}
接下来,生成一些随机数存入数组。React 代码参考:
const data = useMemo(() => {
return Array.from({
length: smallSnowCount}, (temp, i) => {
const xExtend = windX>=0?windX:0;
const x = (100+Math.abs(windX))*Math.random() - xExtend;
const start = 100*Math.random();
const opacity = Math.random();
const duration = basicDur+0.5*basicDur*Math.random();
const snowScale = 1*Math.random();
const snowEndX = windX;
const snowEndY = (frameH+5*Math.random());
const snowBg = `radial-gradient(circle at 50%,${snowColor}, rgba(255,255,255,0) 70%)`;
return {
x,
start,
opacity,
duration,
snowScale,
snowEndX,
snowEndY,
snowBg,
}
}
)
}, [smallSnowCount]);
然后在生成DOM元素的时候,用这些随机数给这些变量赋值,这样就能让每一颗粒子有自己独一无二的动画了。同样也给「animation-delay」赋予随机值,这样他们才能满屏飘散。React 代码参考:
data.map((item, index) => {
const {
x,
start,
opacity,
duration,
snowScale,
snowEndX,
snowEndY,
snowBg,
} = item || {};
return (
<div key={index}
className='snow'
style={{
"--snow-scale":blur?snowScale:(0.6*snowScale),
"--snow-end-x":`${snowEndX}vw`,
"--snow-end-y":`${snowEndY}vh`,
left:`${x}vw`,
width:`${size1}px`,
height:`${size1}px`,
opacity:opacity,
animationDelay:`-${start}s`,
animationDuration:`${duration}s`,
background:blur?snowBg:snowColor,
}}/>
)
})
题外话:如果你所在的地区正在下雪,打开云音乐APP,有机会看到首页也在下雪哦~而年终盘点项目里的「粒子组件」就是从这个项目中的修改而来。不过现在前端老师已经将这个「粒子系统」用 webgl 方案重构了,性能更好,也加强了扩展性,这里就不展开了。
简单3D动效
CSS 除了能做 2D 动效,它还有一定的 3D 能力。开启 CSS3D 的方法是:
对于需要做 3D 效果的元素 B,对它的直接父容器 A 设置 perspective 属性为一个带单位的数字值,例如 800px,值越小镜头畸变越夸张,透视感就越强,反之画面就越「正」; B 元素本身设置 transform-style: preserve-3d ,然后设置 transform 中涉及 3D 的变换,例如 rotetaX() 、translateZ() 等,就能看到效果了。 如果元素的子元素 C 也需要做相对的 3D 效果,不需要对A再设置 perspective 的值,B 会继承 A 的 perspective。
可以查看这个 CSS 3D 演示[8]。
HTML 代码参考:
<div class='A'>
<div class='B'>
<div class='C'></div>
</div>
</div>
CSS 代码参考:
.A{
perspective: 800px;
}
.B{
transform-style:preserve-3d;
transform: rotateX(45deg);
}
.C{
transform-style:preserve-3d;
transform: rotateY(45deg);
}
在听歌报告中也有页面用到了这个属性,例如「听歌关键词」页面,雨滴落入水面激起的涟漪就应用了 3D 效果,使得效果更真实。
对应的 CSS 代码参考:
.rainRing {
position: absolute;
width: 100px;
height: 100px;
border-radius: 50%;
/* 去掉一个边的 border,构造「不完美」的环 */
/* border-top: 0.2px white solid; */
border-left: 0.8px white solid;
border-right: 0.8px white solid;
border-bottom: 0.8px white solid;
transform-style: preserve-3d;
transform: rotateX(90deg);
}
.rainRingAni {
animation: rainRingAniKey 1s cubic-bezier(0, 0, 0.5, 1) 0s infinite normal both;
}
@keyframes rainRingAniKey {
0% {
opacity: 1;
transform: rotateX(90deg) scale(0.1);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: rotateX(90deg) scale(3);
}
}
以及结果页的卡片翻转效果,这里是直接设置 transition ,再通过用户手势改变样式值来触发动画。
CSS 代码参考:
.cardPage {
transform-style: preserve-3d;
/* 旋转中心设在左边靠近边缘位置 */
transform-origin: 10% 50%;
transition: transform 1s ease-in-out;
}
React 代码参考
if (showFootPage) {
setCardTransform('translate3d(-10vw,0vw,20vw) rotateY(-100deg)');
} else {
setCardTransform('translate3d(0vw,0vw,0vw) rotateY(0deg)');
}
复杂一点的页面动效
之前聊到的页面动效相对来说是比较简单的,下面我们看看稍微复杂一点的页面。
「音乐多巴胺」
这里我们希望表现多巴胺如泉水般喷涌而出,组成一个可爱的小精灵的效果.
这个小精灵的颜色和花纹需要能根据用户数据生成不同版本。
首先我们看下单个元素的运动,实际上就是简单的位移+旋转,但位移和旋转都是在「transform」属性下设置,绑在一起不太方便做效果,于是如「初次相遇」中用到的技巧,我们在动画元素外再包一层 div,将位移和旋转动画分拆到两个节点上。
同时如前述「粒子系统」的操作,将部分属性用 CSS 变量替代,方便后续生成。CSS 代码参考:
/* 元素基本样式 */
.cross {
width: 382px;
height: 378px;
position: absolute;
left: 8px;
top: 200px;
}
/* 位移动画 */
.crossAni {
--xOffset: 0;
--yOffset: 0;
transform-origin: 50% 50%;
animation: crossAniKey 2s cubic-bezier(0.5, 0, 1, 1) 0s infinite normal both;
}
@keyframes crossAniKey {
0% {
opacity: 0;
transform-origin: 50% 100%;
animation-timing-function: cubic-bezier(0, 0, 0.7, 1);
transform: translate3d(0, 0, 0) scale(0.7);
}
30% {
opacity: 1;
}
50% {
opacity: 1;
transform-origin: 50% 0%;
animation-timing-function: cubic-bezier(0.3, 0, 1, 1);
}
80% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate3d(calc(var(--xOffset) * 1vw), calc(var(--yOffset) * 1vw), 0) scale(0.5);
}
}
/* 旋转动画 */
.crossRotateAni {
--direction: 1;
animation: crossRotateAniKey 2s linear 0s infinite normal both;
}
@keyframes crossRotateAniKey {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(calc(var(--direction) * 359deg));
}
}
然后我们构建一个「多巴胺元素生成器」:
恩,再来点 delay,完美!
React 代码参考:
// 构造一个「多巴胺元素生成器」
const CrossAni = ({ options }: CrossAniPros) => {
const {
picUrl = '',
className = '',
aniClassName = '',
aniClassName2 = '',
count = 3,
duration = 1,
delay = 0,
xOffset = 10,
yOffset = 0,
rotateDirection = 1,
} = options;
const step = duration / count;
return (
<>
{Array.from({ length: count // 一次生成 count 个,丰富数量 }, (_, index) => (
// 外边包一层,应用其中一组动画
<div
className={`${className} ${aniClassName}`}
key={index}
style={{
// 设置元素向哪个方向运动
'--xOffset': xOffset,
'--yOffset': yOffset,
animationDuration: `${duration}s`,
// delay 不可或缺
animationDelay: `${-index * step + delay}s`,
}}>
// 图片本身再应用另一组动画
<img
className={`${className} ${aniClassName2}`}
style={{
direction: rotateDirection === 1 ? 'ltr' : 'rtl',
left: 0,
top: 0,
animationDuration: `${duration}s`,
animationDelay: `${-index * step}s`,
}}
src={picUrl} />
</div>
))}
</>
);
};
// 。。。此处省略其他部分代码
// 通过设置好的元素数量,从画面中心 360° 向四周喷射。
{Array.from({ length: crossCount }, (_, index) => {
const steper = (Math.PI * 2) / crossCount; // 每个元素对应位置的旋转角度
return (
<CrossAni
key={index}
options={{
picUrl: bgUrl,
className: styles.cross, // 基础样式
aniClassName: styles.crossAni, // 位移动画,赋给外层
aniClassName2: styles.crossRotateAni, // 旋转动画,赋给内层
count: 3,
duration,
delay: (-index * duration) / crossCount,
xOffset:
radius
* Math.cos(steper * index) // 利用三角函数计算位移,即运动终点在圆周上的什么位置
* (0.5 + 0.5 * randomGroup[index]), // offset 中加入微小的随机偏移量,使喷射运动看起来更自然
yOffset:
radius
* Math.sin(steper * index)
* (0.5 + 0.5 * randomGroup[index])
+ 40,
rotateDirection: 1, // 可以设置条件改变运动方向,这里暂时用不到这个效果
}} />
);
})}
小精灵上边的弧形轮廓是利用径向渐变 (radial-gradient) 配合 CSS 的遮罩属性「挖空」一个 div 构建的,它其实是盖在上方的一层,但颜色能通过 JS 控制,与背景色结合在一起,视觉效果上和直接挖掉小元素所在的组没有区别,但性能表现更优秀。
CSS 的「遮罩」有不止一种方式,这个页面用到的是「mask-image」属性,它通过一张位图或者渐变作为遮罩层,遮罩层的 alpha 通道将与元素的 alpha 通道相乘,即元素在遮罩层 alpha 为 0 的部分是透明的,反之则显示出来。此处是利用径向渐变快速绘制一个圆形区域作为遮罩层来使用,更直观的理解可以参考这个 CSS Mask 演示[9];后文还会介绍另一种遮罩「clip-path」,它是通过一个矢量形状的轮廓来扣图,这两者的区别可以参阅 Masking vs Clipping[10] 这篇文章。
CSS 代码参考:
.crossMask {
width: 100vw;
height: 100vw;
position: absolute;
overflow: hidden;
/* 这个渐变是构造了一个圆心在元素下方 170% 处的大圆 */
/* 对于 mask-image 有颜色的部分是可见的,transparent 部分是不可见的 */
mask-image: radial-gradient (
circle at 50% 170%,
transparent 0%,
transparent 67.9%,
black 68%
);
}
「听歌时段」
这是一个「数据可视化」页面,我们需要考虑
展示什么数据 与视觉如何结合 动效如何锦上添花
策划老师希望展示 2023 年每个月用户一天当中听歌最多的时段,并有个纵向对比,那么问题来了,我们需要后端老师提供什么样的数据?是不是需要给到每个用户每一天每个小时的有效听歌次数,然后绘制在图表上?后端老师说这个数据量我扛不住,而且就算给到前端,用这么多数据绘制图表,性能压力也不小。我们再看看视觉稿,其实视觉老师希望这个页面看起来像一座座交叠起伏的山峰,而一座山必须有山峰和山脚,且高度差足够大才能明显。所以其实我们只需要取每个月有效听歌次数最大的那一个听歌时段,忽略掉其他时段的数据,将之凸显出来,这不就是一座山了么。绘制图表的思路就是:横轴为一天中的时间段(0 - 23 点),纵轴为有效听歌次数,纵轴的最大值取该用户 12 个月中有效听歌次数最大值,这样只需要和自己比,每个人都能画出能看的图:取一个点绘制折线,折线两端的 Y 值为 0,将折线变成曲线,我们的小山是不是就差不多了:
这样我们需要后端老师提供的数据也就大为简化了,大概是这样:
{
listenTimePeriod: {
totalMaxPlayCount: 92, // 12个月中有效听歌次数最大值
maxTimePeriod: 'MIDNIGHT', // 综合所有数据,计算出用户最喜欢听歌的时段对应的主题
distributions: [
{
month: 1, // 月份
peak: 62, // 当月最大有效听歌次数
hour: 1, // 当月最大有效听歌次数对应的时间段
},
{
month: 2,
peak: 44,
hour: 7,
},
{
month: 3,
peak: 92,
hour: 3,
},
// 省略部分月份
{
month: 12,
peak: 84,
hour: 0,
},
],
}
}
那么问题又来了,折线图好说,这个曲线图又咋整?而且还是要带图案的。还记得前边提到的「三阶贝塞尔函数」么,我们的曲线就靠他了。前边说道「timing-function」中用到的是「特化」的三阶贝塞尔函数,我们只需 4 个数值,即 2 个点就能确定一个「timing-function」,实际上是有 2 个点 [0, 0] 和 [1, 1 ] 被省略了,因为这两个点在此特化情况下是固定值。正常用三阶贝塞尔函数绘制曲线需要给定 4 个点:
上图中有两条三阶贝塞尔曲线
曲线一:[x0,y0] - [cx1,cy1] - [cx2,cy2] - [x1,y1]
;
曲线二:[x1,y1] - [cx3,cy3] - [cx4,cy4] - [x2,y2]
。这里 [x0,y0] - [x3,y3] 是 4 个端点,[cx1,cy1] - [cx4,cy4] 是所谓的「控制点」,在很多绘图软件中,例如 Photoshop(钢笔工具) 、Sketch 、Figma 等都能绘制三阶贝塞尔曲线,这几个「控制点」就是控制曲线弯曲程度的参数。下图这种操作设计师同学应该很熟悉吧:
绘制到页面我们可以用 SVG 的 <path> 元素 ,它支持三阶贝塞尔曲线。而山峰的图案?正巧 SVG 有个 <clipPath> 元素,可以将它所包含的形状,包括 <path> 作为遮罩,在外部的 CSS 元素中通过 「clip-path」 属性引用,而作为遮罩「扣」出所需的形状。感兴趣可以参考 CSS SVG 滤镜[11]这篇文章。这样只需设计师提供山峰的图案:
我们用 clipPath 扣出来。下图中黑色的部分是计算出的山峰轮廓,也就是实际抠出的区域。
最后就是为「山峰」们的出现加上动效,其实就是一个 Y 方向的缩放动画(transform:scaleY())就可以,注意将缩放中心设在图片底部。每个月的图表出现通过我们的老朋友 delay 来错开时间,让动效更有层次感。
梳理一下思路并转化成动效代码。首先通过后端给过来的数据计算三阶贝塞尔曲线的各个坐标点,这里有几个细节:
数据为 0 时有个默认高度,这样不至于没有数据的部分看起来太空; 为了让组成「山峰」的两段曲线在顶部弯曲程度一致,下图中 cy2 = y1 = cy3,且 x1 - cx2 = cx3 - x1 ,这个长度我们用参数 controlerLength 控制; 按理说,一天 24 小时就应该把 x 轴分成 24 个点来画图,但如果这样,当数据是靠近左边(0 点)或者右边(24 点)的情况下,山峰靠近边缘的部分就会变得很尖,所以我们需要适当的将头和尾延伸出去,即多分几段,再对齐坐标上的值。
React 代码片段参考(mask 部分):
const maskWidth = 260; // mask所用svg的宽高,用于计算scale,将mask适配至全背景图
const maskHeight = 65;
const heightLimit = 5; // 数据为0时默认高度
const heightScaler = 1.2; // 每个波高度缩放倍数
const controlerLength1 = 0.05 * maskWidth; // 山峰形状调整参数
const startOffset = 6; // 开头空多少段
const endOffset = 4; // 结尾空多少段
const segment = 24 + startOffset + endOffset; // 分成多少段
const gap = 8; // 每个月图表之间间距
const timeOffset = 0.05; // 每个月动画时间差
// 波形背景图url
const graphBG1 = 'xx1.png';
const graphBG2 = 'xx2.png';
const graphBG3 = 'xx3.png';
// 省略部分代码
{/* svg mask组件中的代码片段 */}
<svg width={maskWidth} height={maskHeight}>
<defs>
{graphData.map((month, i) => { // 12 个月的数据
const x0 = 0;
const y0 = maskHeight - heightLimit;
const x1 = (startOffset + month.hour) * (maskWidth / segment);
const y1 = maskHeight - heightLimit
- (maskHeight - heightLimit) * (month.peak / (heightScaler * graphData.max)) + 1;
const x2 = 2 * x1;
const y2 = maskHeight - heightLimit;
const x3 = maskWidth;
const y3 = y0;
const cx1 = x1 - controlerLength1;
const cy1 = y0;
const cx2 = x1 - controlerLength1;
const cy2 = y1;
const cx3 = x1 + controlerLength1;
const cy3 = y1;
const cx4 = cx3;
const cy4 = y2;
return (
<clipPath
key={`clipPath_${i}`}
id={`mask${i}`}
// 保证 mask 覆盖整个山峰元素
clipPathUnits="objectBoundingBox"
transform={`scale(${1 / maskWidth}, ${1 / maskHeight})`}>
<path
// d 属性的绘图代码中
// M + (1 个坐标点) 表示从该坐标点开始接下来的绘制
// C + (3 个坐标点) 即表示绘制从当前点开始的一段三阶贝塞尔曲线
// L + (1 个坐标点) 绘制绘制从当前点开始的一段直线
// 最后的 Z 表示闭合前述绘制的曲线
d={`M ${x0},${y0}
C ${cx1},${cy1} ${cx2},${cy2} ${x1},${y1}
C ${cx3},${cy3} ${cx4},${cy4} ${x2},${y2}
L ${x3},${y3}
L ${x3},${maskHeight}
L ${x0},${maskHeight}
L ${x0},${y0} Z
`}
fill="#ffffff" />
</clipPath>
);
})}
</defs>
</svg>
图表本体及动效部分:
const [aniTrigger, setAniTrigger] = useState(0);
// 进入页面,DOM 准备好后触发动画播放
useEffect(() => {
setAniTrigger(1);
}, []);
// 省略部分代码
{graphData.map((month, m) => (
<div
key={`graph_month_${m}`}
className="dataGraphGroup"
style={{
position: 'absolute',
width: '100vw',
height: `${(maskHeight * 100) / maskWidth}vw`, // 撑满屏幕宽度,等比放大高度
top: `${gap * m - (0.997 * maskHeight * 100) / maskWidth}vw`, // 部分机型计算误差会导致 mask 遮罩覆盖不全,留下一条细线,这里做了细微的修复
}}>
{/* 图表本体 */}
<div
className="dataGraph"
style={{
position: 'absolute',
width: '100vw',
height: `${(maskHeight * 100) / maskWidth}vw`,
background: `${colorInfo?.colorTheme},${colorInfo?.colorBG}`,
// 应用前述 svg mask
clipPath: `url(#mask${m})`,
WebkitClipPath: `url(#mask${m})`,
// 动效通过 aniTrigger 的变化触发
transformOrigin: '50% 100%',
transform: `translateZ(0) scale(1,${aniTrigger})`,
opacity: `${aniTrigger}`,
transition:
'transform 1s cubic-bezier(0,0,0.3,1),opacity 0.1s linear',
transitionDelay: `${0.2 + m * timeOffset}s`,
}} />
{/* 月份标签 略 */}
</div>
))}
「遗忘的歌」
简单拆解一下这个音符组成的蒲公英随风飘散的效果,它包含:
单个音符绕圈运动 多个音符一起晃动 音符飘走/出现
我们知道 CSS 中做位移动画一般是按直线运动的,如果想让元素绕着圆圈,或者说沿着一段曲线运动常用有几个方案:SVG 的 SMIL 动画中的
动画的导出用到了我之前编写的 AE 插件 AE2CSS[14]
为什么说它「暴力」呢,请看导出的 CSS 动画代码:
.noteAni {
animation: noteAniKey 8s steps(5) 0s infinite normal both;
}
@keyframes noteAniKey {
0.00% { transform: translate3d(0, 0, 0);}
2.00% { transform: translate3d(0.06vw, 0.81939797559448vw, 0);}
4.00% { transform: translate3d(0.19vw, 1.62859833068956vw, 0);}
6.00% { transform: translate3d(0.4vw, 2.41757866865078vw, 0);}
8.00% { transform: translate3d(0.7vw, 3.1755070782217vw, 0);}
10.00% { transform: translate3d(1.08vw, 3.89090077768412vw, 0);}
12.00% { transform: translate3d(1.55vw, 4.55185305228315vw, 0);}
14.00% { transform: translate3d(2.1vw, 5.14633198716433vw, 0);}
16.00% { transform: translate3d(2.72vw, 5.66254803578306vw, 0);}
18.00% {transform: translate3d(3.42vw, 6.08937895023121vw, 0);}
/* 中间省略 */
100.00% {transform: translate3d(0, 0, 0);}
}
就是暴力地将圆拆分成多段直线,然后一点点走完。翻译成小学数学名词应该叫多边形近似画圆法?
OK 这样我们得到了单个音符的运动,现在我们要将这些绕着小圈的音符,分布到一个大圈上,并对音符图案做个随机处理。
当然,怎么少的了 delay :
然后我们要对整体做一个轻微摇晃的效果,和「音乐多巴胺」中的技巧类似,给他「套娃」,在外层 div 中加入晃动动画。
CSS 代码参考:
.flowerWiggleAni {
animation: flowerWiggleKey 5s cubic-bezier(0.3, 0, 0.7, 1) 0s infinite normal both;
}
@keyframes flowerWiggleKey {
0% {
transform: rotate(-3deg);
}
50% {
transform: rotate(4deg);
}
100% {
transform: rotate(-3deg);
}
}
最后的飘散动画,也是「套娃」一个 div,对音符元素添加一个透明度+位移+缩放(模拟z方向位移)的动画。
CSS 代码参考:
.noteOutAni {
animation: noteOutAniKey 8s cubic-bezier(0.5, 0, 1, 1) 0s infinite normal both;
}
@keyframes noteOutAniKey {
0% {
opacity: 0;
transform: translate3d(0, 0, 0) scale(0.8);
}
20% {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
70% {
transform: translate3d(0, 0, 0) scale(1);
opacity: 1;
}
100% {
opacity: 0;
transform: translate3d(150vw, -100vw, 0) scale(0.45);
}
}
综上,用 React 生成音符蒲公英的代码:
// 音符数量
const noteCount = 20;
// 生成随机数并固化进数组(在组件外生成,避免无用刷新)
const randomNum = Array.from({ length: noteCount }, () => Math.random());
const ringRadius = 25;
const offsetX = 40; // 圈圈x方向位置调整
const offsetY = 100; // 圈圈y方向位置调整
const angle = (2 * Math.PI) / noteCount; // 每个音符圆周角度
// 音符url放进一个数组,方便遍历
const noteUrlGroup = [
notePic1,
notePic2,
notePic3,
notePic4,
notePic5,
notePic6,
notePic7,
notePic8,
];
// 省略部分代码
<div
className={styles.flowerWiggleAni} // 摇晃动画
style={{
transformOrigin: '10% 100%',
height: '100vh',
width: '100vh',
perspective: '500px',
}}>
<div
style={{
transformOrigin: '10% 100%',
height: '100vh',
width: '100vh',
transformStyle: 'preserve-3d',
transition: 'all 3s cubic-bezier(0,0,0.2,1)',
transform: flowerInState, // 此处为了刚进入页面时飘入的动画,多'套娃'了一层
}}>
{Array.from({ length: noteCount }, (_note, i) => {
// 数组下标随机取音符url
const curUrlIndex = Math.floor(randomNum[i] * noteUrlGroup.length);
return (
<div
key={i}
className={styles.note}
style={{
position: 'absolute',
top: `${offsetY + ringRadius * Math.cos(angle * i)}vw`, // 利用三角函数计算横纵坐标
left: `${offsetX + ringRadius * Math.sin(angle * i)}vw`,
width: '8.08vw',
height: '9.42vw',
transformStyle: 'preserve-3d',
transform: `translateX(${flowerOutDistance}vw) translateZ(${
flowerOutDistance / 2 - 20 * randomNum[i]
}vw) scale(${0.5 + 0.5 * randomNum[i]})`, // 缩放和位移做一些随机处理,看起来更自然
}}>
<div
className={styles.noteOutAni} // 音符飘走/出现动画
style={{
position: 'absolute',
width: '8.08vw',
height: '9.42vw',
transformStyle: 'preserve-3d',
animationDelay: `${-2 - i * 0.05 * randomNum[i]}s`, // 随机delay
}}>
<img
className={styles.noteAni} // 单个音符绕圈动画
src={noteUrlGroup[curUrlIndex]}
style={{
width: '8.08vw',
height: '9.42vw',
objectFit: 'cover',
animationDelay: `${-i * 0.7 * randomNum[i]}s`,
}} />
</div>
</div>
);
})}
</div>
</div>
「年度歌曲/歌手/歌单」
这算是整个听歌报告最高潮的部分,我们希望给用户一种「你的年度XX隆重登场」的感觉,因此动效的数量和变化幅度也是最大的,尤其是翻页效果。我们先来拆解一下,这一部分的动效包括:
3D 翻页动效; 背景元素动效; 各种动图元素;
先看看翻页部分,我们将所有页面沿 z 方向排列,通过页面索引号确定位置。翻页时改变 translateZ 的值,通过 transition 属性定义翻页动画效果。
React 代码参考:
// 省略部分代码
style = {{
opacity: match? 1 : 0,
transition: 'all 1.5s cubic-bezier (0.3,0,0.3,1) 0.3s',
transform: `translateZ(${-index * distance}vw)`
}}
此时最简单的 3D 翻页效果就出来了,是不是看起来有点像之前的《十周年听歌报告》项目[15]。没错最初的灵感是脱胎于它,但也有不同:10周年项目中歌曲数目较多,用户需要更快速的划过每个页面(歌曲封面),因此它的交互是基于「scroll」,直接而迅速;而「年度之最」系列页面数量更少但页面内容更丰富,是需要用户停驻阅读的,因而它的交互还是基于页面切换,我们会更多的设计页面转场时的花样,营造「你的年度xx隆重揭晓」的氛围感。
然后我们在translateZ基础上加入3d旋转属性变化。翻页前非当前页 X、Y、Z 方向都有个旋转角度(透明度也是 0),当用户操作结束,当前页从已旋转状态变成面向用户。
上一段代码优化为:
// 省略部分代码
style = {{
opacity: match? 1 : 0,
transition: 'all 1.5s cubic-bezier (0.3,0,0.3,1) 0.3s',
transform: `translateZ(${-index * distance}vw)
rotateX(${match ? 0 : 90}deg)
rotateY(${match ? 0 : 90}deg)
rotateZ(${match ? 0 : 90}deg)`
}}
而「年度歌单」、「歌手对比」页面内元素较多,统一的旋转会比较死板:
因此对他们多加一层 transform 动画,并通过我们的老朋友 - delay 来细化动画层次。
React 代码参考(年度歌单为例):
// 五个封面位置/缩放信息,和视觉老师调细节比较方便
const discProperties = [
{
left: 37.3,
top: -22.2,
scale: 0.9,
},
{
left: 43 + 1, // +1修正是为了规避iOS16的一些奇怪bug,下同
top: 32,
scale: 0.5 * 1.01,
},
{
left: 24 + 5,
top: 63 + 3,
scale: 0.29 * 1.05,
},
{
left: 36 + 10,
top: 86 + 3,
scale: 0.27 * 1.07,
},
{
left: 15 + 13,
top: 92 + 15,
scale: 0.18 * 1.1,
},
];
// 五个封面未出现时默认位置
const [coverPos, setCoverPos] = useState([
'translate3d(0vw,0vw,0vw) scale(1)',
'translate3d(0vw,0vw,-10vw) scale(0.8)',
'translate3d(0vw,0vw,-20vw) scale(0.6)',
'translate3d(0vw,0vw,-30vw) scale(0.4)',
'translate3d(0vw,0vw,-40vw) scale(0.2)',
]);
// 翻到本页时触发动画
useEffect(() => {
if (match) {
setCoverPos([
`translate3d(${discProperties[0].left}vw,${discProperties[0].top}vw,0vw) scale(${discProperties[0].scale})`,
`translate3d(${discProperties[1].left}vw,${discProperties[1].top}vw,-10vw) scale(${discProperties[1].scale})`,
`translate3d(${discProperties[2].left}vw,${discProperties[2].top}vw,-20vw) scale(${discProperties[2].scale})`,
`translate3d(${discProperties[3].left}vw,${discProperties[3].top}vw,-30vw) scale(${discProperties[3].scale})`,
`translate3d(${discProperties[4].left}vw,${discProperties[4].top}vw,-40vw) scale(${discProperties[4].scale})`,
]);
} else {
setCoverPos([
'translate3d(0vw,0vw,0vw) scale(1)',
'translate3d(0vw,0vw,-10vw) scale(0.8)',
'translate3d(0vw,0vw,-20vw) scale(0.6)',
'translate3d(0vw,0vw,-30vw) scale(0.4)',
'translate3d(0vw,0vw,-40vw) scale(0.2)',
]);
}
}, [match]);
// 省略部分代码
{discProperties.map((i, index) => (
// {/* 封面圆盘位置 */}
<div
className={styles.centerFrame}
key={`cover-${index}`}
style={{
zIndex: -index,
transition: `all 1.1s cubic-bezier(0.4,0,0.3,1) ${0.7 + 0.15 * index}s`,
transform: coverPos[index],
}}>
{/* 封面组件略 */}
</div>
))}
在年度歌单页面,我们还有个「纯净歌单」的小设计,切换纯净歌单也会有个小动效。对年度歌单的专辑封面来说,其实是固定5个容器,将两张歌曲封面作为子元素背靠背放置,切换时旋转其父容器即可。
慢镜头拆解如下:
React 代码参考:
<div
className={styles.coverPlace}
style={{
transition: `all 1s cubic-bezier(0.4,0,0.3,1) ${0.1 * index}s`,
transform: `rotateY(${toPure ? 180 : 0}deg)`,
}}>
{/* 背面放纯净歌单封面 */}
{!!purePlayList[index] && (
<div
className={styles.ringGroup}
style={{
border: `${index === 0 ? 5 : 8}px rgba(255,255,255,0.4) solid`,
backfaceVisibility: 'hidden',
transform: 'translateZ(-1px) rotateY(180deg)', // 默认是反过来的
}}>
{/* 纯净歌单封面组件 */}
</div>
)}
{/* 普通歌单封面 */}
{!!playList[index] && (
// 普通歌单封面组件
)}
</div>
再来看看背景的各种元素动效:
漂浮的小小星球,就是简单的位移 + delay 。CSS 动画代码参考:
.planetAni{
animation: planetAniKey 8s cubic-bezier(0.3, 0, 0.7, 1) -42s infinite normal both;
}
@keyframes planetAniKey {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-5vw);
}
100% {
transform: translateY(0);
}
}
向外扩散的冲击波,就是个简单的缩放动画:
.waveRingAni {
border-radius: 50%;
background: radial-gradient(50% 50% at 50% 50%, rgba(255, 255, 255, 0) 85%, rgb(220, 255, 255) 100%);
animation: waveRingAniKey 3s cubic-bezier(0.5, 0, 1, 1) -2s infinite normal both;
}
@keyframes waveRingAniKey {
0% {
transform: scale(0.5);
opacity: 0;
}
70% {
opacity: 0.5;
}
100% {
opacity: 0;
transform: scale(15);
}
}
向外发射的射线是缩放 + Y 方向的位移,在构造时旋转一周+轻微随机:React 部分代码参考:
{Array.from({ length: 15 }, (item, i) => (
<div
key={i}
style={{
top: 0,
left: 0,
width: '100vw',
height: '100vh',
position: 'absolute',
transform: `rotate(${30 * (i * (0.8 + 0.1 * Math.random()))}deg)`,
}}>
<div
className={styles.lineOutAni}
style={{
position: 'absolute',
top: '326px',
left: '187px',
width: '1px',
height: '30px',
borderRadius: '0.5px',
animationDelay: `${-i * (0.8 + 0.1 * Math.random())}s`,
background:
'linear-gradient(rgba(255,255,255,0.8), rgba(255,255,255,0))',
}} />
</div>
))}
CSS 部分:
.lineOutAni {
animation: lineOutAniKey 3s linear 0s infinite normal both;
}
@keyframes lineOutAniKey {
0% {
transform: scale(0) translateY(0);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: scale(10) translateY(-400%);
}
}
最后是一些动图:
困难与挑战
做项目从来不是一帆风顺的,它是一段痛并快乐着的旅程,用户看到的是最终的成品,背后其实是很多的调整、修改甚至推倒重来。比如机型适配,这些测试机型号只是冰山一角:
为了尽可能的保证不同设备下看到的效果的一致性,动效从设计之初就得考虑不同的屏幕下的效果,也会因为低端机型的表现做一些妥协。
各种奇奇怪怪的 BUG 也是让人操碎了心,有时不得不狠心把效果砍了。
写在最后
整个项目的动效虽然看起来有些复杂,但多数情况下他们都是通过常见的小技巧组合搭配实现的,例如:
「延迟」错开不同的动画图层,可以使整个动画更有层次感; 「随机」分布动画元素及延迟时间,可以使动画千人千面,不死板; 调整「缓动曲线」使动画节奏更舒服优雅; SVG 和 CSS 是兄弟,很多属性可以互相引用、组合来制作动画; 3D 效果适当引入,增强动画的表现力; 过于复杂的效果就考虑用动图,不死磕代码;
限于篇幅,本文或还有疏漏之处,望各位看官海涵,也希望本文能给大家带来一些启发和帮助,不胜荣幸!
参考资料:
[1] CSSTransition 组件: https://reactcommunity.org/react-transition-group/css-transition
[2]CSS Animation: https://developer.mozilla.org/zh-CN/docs/Web/CSS/animation
[3]「animation-timing-function」: https://developer.mozilla.org/zh-CN/docs/Web/CSS/animation-timing-function
[4]缓动函数: https://developer.mozilla.org/zh-CN/docs/Web/CSS/easing-function
[5]Cubic-Bezier: https://cubic-bezier.com/
[6]CSS 滤镜: https://developer.mozilla.org/zh-CN/docs/Web/CSS/filter
[7]CSS 混合模式: https://developer.mozilla.org/zh-CN/docs/Web/CSS/mix-blend-mode
[8]CSS 3D 演示: https://codepen.io/bigxixi/pen/KKYdxmp
[9]CSS Mask 演示: https://codepen.io/bigxixi/pen/bGJdbxv
[10]Masking vs Clipping: https://css-tricks.com/masking-vs-clipping-use/
[11]CSS SVG 滤镜: https://www.sarasoueidan.com/blog/css-svg-clipping/
[12]animateMotion: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/animateMotion
[13]探秘神奇的运动路径动画 Motion Path: https://www.cnblogs.com/coco1s/p/14713110.html
[14]AE2CSS: https://github.com/bigxixi/ae2css
[15]《十周年听歌报告》项目: https://zhuanlan.zhihu.com/p/630299551
点赞
和在看
是最大的支持 ⬇️❤️⬇️