随着今年微信广告榜单页面的升级,我和设计师一起完成了基于 conic-gradient
的旋转动画。这篇文章中,我将分享使用 motion/react
逐步优化的过程,其中包含了:
conic-gradient
转起来;motion/react
的数个方法控制动画速度,以在切换时展现光线流转的效果。下面是完整演示,你可以点击上方的 Tab 进行切换:
直接从 Figma 中的 “Angular Fill” 导出 CSS 的 conic-gradient
代码,是有问题的。这不完全算是 Figma 的 bug。比如下面的 fill 导出代码后完全对不上:
问题有 2 个:
具体的代码如下:
/* 导出这么多位的小数点 ?! */
div {
background: conic-gradient(
from 180deg at 50% 49.9%,
#08cc64 8.704102858901024deg,
#17e879 90deg,
#08cc64 160.429208278656deg,
#08cc64 176.39445662498474deg,
#09d669 193.76951694488525deg,
#77ff6e 299.3333888053894deg,
#31df67 317.59665727615356deg,
#08cc64 335.3943371772766deg,
#00c566 350.5913472175598deg
);
}
/* 导出这么多位的小数点 ?! */
div {
background: conic-gradient(
from 180deg at 50% 49.9%,
#08cc64 8.704102858901024deg,
#17e879 90deg,
#08cc64 160.429208278656deg,
#08cc64 176.39445662498474deg,
#09d669 193.76951694488525deg,
#77ff6e 299.3333888053894deg,
#31df67 317.59665727615356deg,
#08cc64 335.3943371772766deg,
#00c566 350.5913472175598deg
);
}
350deg
而没有 360deg
的色值。整个过渡就不可能自然,因为过渡在 350deg
时就结束了;conic-gradient
只能绘制正圆形的渐变,这不是 Figma 的问题,是 CSS 的限制,所以我才说:这不完全算是 Figma 的 bug。解决思路很简单,我们需要提供一个 360deg
的色值,让渐变自然衔接。然而设计师并不是从 0deg
开始绘制渐变的,比如上面 #08cc64 8deg
。只能说因为 Figma 和 Web 的兼容性在这里还是太多问题了。
一种方法是计算出 0deg
/360deg
时的色值,但这太麻烦了,而且按照百分比计算出的色值可能无法对应上 Figma 中的视觉效果。
第二种方法就是,把第一个点强行变为 0deg
,这样就同时确定了首尾的色值,然后改变一下 from 180deg
,使光源的位置相对应地偏移即可。
比如,如果 Figma 中导出的代码是:
div {
background: conic-gradient(
from 180deg at 50% 49.9%,
#08cc64 8deg,
#00c566 350deg
);
}
div {
background: conic-gradient(
from 180deg at 50% 49.9%,
#08cc64 8deg,
#00c566 350deg
);
}
我们就这样转换:
div {
background: conic-gradient(
// 光源位置由 180deg 相应移动到 180 - 8 = 172deg
from 172deg at 50% 49.9%,
// 将第一个点从 8deg 强行变为 0deg
#08cc64 0deg,
// 其他点相对应地偏移 8deg
#00c566 342deg,
// 添加结束点
#08cc64 360deg
);
}
div {
background: conic-gradient(
// 光源位置由 180deg 相应移动到 180 - 8 = 172deg
from 172deg at 50% 49.9%,
// 将第一个点从 8deg 强行变为 0deg
#08cc64 0deg,
// 其他点相对应地偏移 8deg
#00c566 342deg,
// 添加结束点
#08cc64 360deg
);
}
于是,我们就解决了渐变衔接不自然的问题:
不论是 CSS 中的 conic-gradient
还是 canvas 中的 createConicGradient()
方法,尽管能改变中心的位置,但都只能绘制正圆形的渐变。这样的渐变类似于一种俯视的角度:
设计师在 Figma 中可以通过直接拉伸为椭圆形就行。而在 CSS 中其实也很简单,就是通过 scale
来实现拉伸即可。
如果一个 100px × 100px
的元素有一个 主轴:次轴 = 4:1
的椭圆形 conic-gradient
:
div {
width: 50px;
height: 100px;
tranform: scaleX(4);
}
div {
width: 50px;
height: 100px;
tranform: scaleX(4);
}
至此,我们才终于画对了 Figma 中的 “Angular Fill”。
直接粗暴地旋转元素是不行的,让 conic-gradient
旋转,自然不是让整个图像旋转,而是要让光源的角度发生变化。前者是改变 rotate
,后者是改变 conic-gradient(from 180deg ...)
中 180deg
的值:
光源的角度变化,有一点感觉了,然而这样还不足够。每一个过渡色的位置,甚至是色值都需要变化,以达到视觉上较好的效果。最终设计师总共打了 12 个点,然后我再将这 12 个点依次用上面的方法转换为 conic-gradient
的代码,这其中我和设计师的工作量都比较大。
Figma 打点
借助 motion/react
底层的 popmotion,即便是字符串,我也可以很方便实现 12 个点的过渡。由于设计师打的点又不是等距的,因此又花了非常多的时间微调每个点的偏移量:
animate({
to: [
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
],
duration: 16000,
// 设计师打的点又不是等距的,因此又花了非常多的时间微调每个点的偏移量
offset: [0, 0.06, 0.09, 0.13, 0.2, 0.35, 0.45, 0.55, 0.65, 0.84, 1],
ease: linear,
repeat: Infinity,
onUpdate: (v) => {
// 在这里设置 style.background
},
})
animate({
to: [
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
"conic-gradient(...)",
],
duration: 16000,
// 设计师打的点又不是等距的,因此又花了非常多的时间微调每个点的偏移量
offset: [0, 0.06, 0.09, 0.13, 0.2, 0.35, 0.45, 0.55, 0.65, 0.84, 1],
ease: linear,
repeat: Infinity,
onUpdate: (v) => {
// 在这里设置 style.background
},
})
在页面中,用户会通过 Tab 在朋友圈榜单和视频号榜单之间切换。我在这里的设计目标是:当用户点击切换时,将原先匀速的旋转动画加速再恢复到匀速,以让光线加速流转,类似于延时影片中的效果。
由于涉及到颜色的变化,同时我们打的 12 个点本身就是不等距的,这时候再要加速度,我觉得会不太好处理。因此我在今年 3 月第一版上线的切换效果是将两个动画叠在一起,让背后的动画初始旋转 -50deg
。在用户点击时,两个动画的容器同时旋转 50deg
,从而看起来像是流转了:
这样的效果其实目的已经达到了,但是毕竟不够优雅。在前后两层不透明度的变化时,画面还是比较乱的。
回过头来看,我们要做的,就好像是在一个匀速摇摆的秋千背后,稍微推一把。基础思路是 hack 进 popmotion 的 animate
的每一帧,然后对每一帧的值进行微调。而 motion/react
的 useAnimationFrame
就是这样一个 hook。
在用 motion/react
来简单地改写整个动画之前,我们先试试让一个匀速旋转的图标加速:
const rotate = useMotionValue(0)
const velocityValue = useMotionValue(0)
const velocityFactor = useVelocity(velocityValue)
useAnimationFrame((_, delta) => {
let newRotate =
rotate.get() + (delta / 16000) * 360 + velocityFactor.get() / 4
if (newRotate > 360) {
newRotate = newRotate - 360
}
rotate.set(newRotate)
backgroundProgress.set(newRotate / 360)
})
// 点击事件
const handleSwitch = () => {
animate(velocityValue, velocityValue.get() + 4, {
type: "keyframes",
duration: 1.7,
ease: [0.43, 0.35, 0.2, 0.95],
})
}
return <motion.div style={{ rotate }} />
const rotate = useMotionValue(0)
const velocityValue = useMotionValue(0)
const velocityFactor = useVelocity(velocityValue)
useAnimationFrame((_, delta) => {
let newRotate =
rotate.get() + (delta / 16000) * 360 + velocityFactor.get() / 4
if (newRotate > 360) {
newRotate = newRotate - 360
}
rotate.set(newRotate)
backgroundProgress.set(newRotate / 360)
})
// 点击事件
const handleSwitch = () => {
animate(velocityValue, velocityValue.get() + 4, {
type: "keyframes",
duration: 1.7,
ease: [0.43, 0.35, 0.2, 0.95],
})
}
return <motion.div style={{ rotate }} />
主要的代码就是上面这些。驱动动画的值是 velocityValue
。每一次点击,我让这个值增加 4
,由它计算出的 velocityFactor
就是在每一帧中要去增加的值。
原理非常简单。实际上直接用 popmotion 自己写也是可以的。至于为什么是增加 4
,以及时间为什么是 1.7
,曲线为什么是 [0.43, 0.35, 0.2, 0.95]
,这些问题就要依靠每个人的 sense 了。
你可以点击下面的按钮看看效果。
把上面的逻辑应用到背景动画中,我这里使用了 useTransform
做两个 motionValue
的转换:
const bg = useTransform(
// 0 - 1
progress,
[0, 0.06, 0.09, 0.13, 0.2, 0.35, 0.45, 0.55, 0.65, 0.84, 1],
// conic-gradients
["conic-gradient(...)", "conic-gradient(...)"],
{
clamp: false,
}
)
const bg = useTransform(
// 0 - 1
progress,
[0, 0.06, 0.09, 0.13, 0.2, 0.35, 0.45, 0.55, 0.65, 0.84, 1],
// conic-gradients
["conic-gradient(...)", "conic-gradient(...)"],
{
clamp: false,
}
)
就得到了我们最终的效果:
在整个动画的实现中,我们用到了 6 个 motion/react
中的方法或 hook:animate
,motion
,useAnimationFrame
,useMotionValue
,useTransform
,useVelocity
。
即便看起来很多,它们所服务的目的却非常好理解。API 只是实现目的的手段 。最佳实践永远源于个人的追求,而非工具的限制。