最近我为设计垂点的链接文字加入了一个交互动画。在鼠标 hover 时,链接的顶部会出现一个提供预览的气泡卡片。你可以在下面的示例中体验一下。如果你是在移动端阅读就没有办法看到了,我目前只打算为 mouse
事件做相关的逻辑。
我认为这个动画的交互反馈是有趣的。有趣的核心在于:
这个动画的实现来源于我在 2023 朋友圈广告年度评选 中设计的爱心动画。你可以在这个链接中一边操作转盘,一边感受一下爱心的位置变化。也可以在下面简化过的示例中直接使用手指或鼠标拖拽体验一下:
两个动画都是使用了 react-spring
的 useTrail
钩子实现。我们具体来看一下细节。
useTrail
的原理是通过插值器来计算每个元素在过程中的值。插值器根据动画的进度(0 到 1)和元素的索引,计算出不同时间点的值。
useTrail
has an identical API signature to useSprings the difference is the hook automatically orchestrates the springs to stagger one after the other.
上面这段话摘自官方文档。不论是官方文档还是社区内不多的文章里,useTrail
往往被用来实现多个元素的顺序或延时动画。
然后,useTrail
的应用就会和 多个元素 挂钩——只有多个元素的动画,才会用它;如果不存在多个元素,就不用它。可是,如果只是为了实现多个元素的顺序或延时动画,完全可以用其他更简单、直观的方案,可能简单的 timeout 或仅靠 CSS 就能满足需求。
结果就是,当我想要设计一个带有惯性的动画时,我第一时间没有想到最好的实现方法。如何实现一个惯性效果?是依靠两端的延迟?还是调运动时间?我忽略了:惯性才是 useTrail 的核心特性。
要实现惯性效果,我们确实用 useTrail
创建了 2 个元素。
tension
设置得较大,这是为了让它的运动尽可能跟手;tension
设置得较小(或者 friction
较大),这是为了让它的运动变得迟钝。const fast = { tension: 1500, friction: 40 }
const slow = { mass: 2.6, tension: 400, friction: 50 }
const [, api] = useTrail(2, (i) => ({
x: 0,
config: i === 0 ? fast : slow,
}))
const fast = { tension: 1500, friction: 40 }
const slow = { mass: 2.6, tension: 400, friction: 50 }
const [, api] = useTrail(2, (i) => ({
x: 0,
config: i === 0 ? fast : slow,
}))
我们将得到下面这样的效果:
接着,我们将这 2 个元素作为同个元素的 2 个端点,第 1 个元素代表爱心的底端,第 2 个元素代表爱心的顶端。通过 Math.atan2
计算出两点间的夹角,作为爱心旋转的角度即可。
const [, api] = useTrail(2, (i) => ({
x: 0,
config: i === 0 ? fast : slow,
onChange(result) {
if (i === 0) {
bottomX.current = result.value.x
} else {
topX.current = result.value.x
const y = 72
let rotate =
90 - (Math.atan2(y, topX.current - bottomX.current) * 180) / Math.PI
}
},
}))
const [, api] = useTrail(2, (i) => ({
x: 0,
config: i === 0 ? fast : slow,
onChange(result) {
if (i === 0) {
bottomX.current = result.value.x
} else {
topX.current = result.value.x
const y = 72
let rotate =
90 - (Math.atan2(y, topX.current - bottomX.current) * 180) / Math.PI
}
},
}))
控制动画的关键参数,主要是第 2 个点的 mass
和 tension
。
理解了 useTrail
和惯性的关系,接着我们来看一下文字链接的交互实现。也非常简单,主要利用 createPortal
在 body
上创建元素。如果你动手写过一个 Popover
组件就很简单。
然后,我们通过 onMouseEnter
、onMouseMove
、onMouseLeave
三个事件的配合:
onMouseEnter
:立即更新 portal 的位置,通过链接的 getBoundingClientRect()
和 window.scrollY
的关系,同时设置 opacity
和 scale
属性;onMouseMove
:利用 useTrail
更新 2 个端点的位置;onMouseLeave
:重置 opacity
和 scale
属性。其实都是一些简单的计算。只要细心地处理就好。代码就不贴了。
首先说一下我所使用的 JS 库。
第一,是 JS 运动库。其实我一般不使用 react-spring
。我最喜欢的动画库是 popmotion
,它是 motion/react
的底层库。但我不喜欢直接使用后者。正是因为 popmotion
的简单纯粹,我可以以任何想要的方式使用它。
第二,是手势库。我推荐 @use-gesture
。它不一定需要配合 react-spring
使用,用 popmotion
去自己实现所有运动效果是非常有趣的。
之前有同事说,他准备做一个动画,问我使用什么库做的,而实际上他对想要做出的效果并没有任何想法。这让我觉得奇怪。因为工具库只能充当帮手的角色,主要还是要依靠自己的想法。将 React 生态和动画库结合,以自己的喜好选择实现的方法。这本身非常有趣。继而不断地打磨,不断地优化写法。这一整个过程,就是寻找 最佳实践 的过程。
从前我以为,一些技术问题都会有一个所谓的最佳实践,它们存在于 Stack Overflow 上、存在于网上、存在于开源库中 —— 一定有个地方存在能够指导自己的标准答案。这时的我就像那位同事一样,其实还没有开始思考,或是已经提前地放弃了思考。
最佳实践源于对自己实现方式的追求。 它在对自我的不断试错和优化中形成。
然后,我就有了喜欢的库,和不喜欢的库。我开始建立擅长的方式。
我今天看到了一个很喜欢的词来形容 design engineer 的职责,not a designer or a developer, but a builder。所谓 build,就是实现。我们需要花时间去思考细节,然后实现。如果我是一个 builder,我就不可能是一个只做 coding 的 developer 或一个只做 prototyping 的 designer。因此,说起来 design enigneer 并不是一个所谓的职称,它首先是对所有和实现相关的工作者所提出的自我要求。
并不存在一个最佳实践作为标准答案能帮助到我。不要相信所谓的最佳实践。只有自己的经验才是最佳实践。对最佳实践没有追求的人,所完成的任何事都只能是将就与妥协。