我们组在 7 月 3 日上线了微信广告 11 周年的 H5 活动。和 去年的活动 类似:输入自己的话、浏览大家的话。由于我在上线当天休假,加之开发时间有限,没有太好地打磨和完成。整个活动很简单,服务的人也很少,但机会难得,我还是希望抽出一些时间再来玩一玩。
我 很多次地思考 过:
最佳实践源于对自己实现方式的追求。 它在对自我的不断试错和优化中形成。
一个设计实现的背后,可能对应的是一类交互形式。对我来说,还原设计稿、完成设计团队的任务是基础要求,我自己不满足于此,意义也不来源于此 —— 我花时间精进自己。至于能服务多少人,我决定不了。而且就是因为服务的人少,我才更希望玩一玩。
“转盘”这个东西,从 23 年 到 24 年,再到这一次的活动,真是一个我不断思考最佳实践的例子了。这次的文字转盘很简单,我想在其基础上玩一个类似 elevenlabs 的效果 —— 越是远离圆心的文字,延迟越大。整体文字像个八爪鱼一样。
虽然这个效果并不适合活动,因为它会让浏览的体验变得打扰、低效,甚至本身都显得多余。但 —— 正如我所说 —— 我只是想玩一下,以增加一些经验。
之前用 react-spring
的 useTrail
实现过类似的效果,但考虑到开源维护力度已越来越弱,这次单纯用 motion/react
实现。往后应该会逐渐弃用。
最终效果:
首先我们将整个转盘看作一个整体,它由一个 MotionValue
驱动。一个基础的框架:
const rotate = useMotionValue(0)
<motion.div
drag
{/* 拖动开始时,记录当前的旋转角度,并停止动画 */}
onDragStart={...}
{/* 拖动过程中,实时更新旋转角度 */}
onDrag={...}
{/* 拖动结束时,惯性滚动,最终结果为 power * velocity,并定位到最接近的旋转整数角度 */}
onDragEnd={...}
{/* 以下参数禁用自身的移动 */}
dragMomentum={false}
dragElastic={0}
dragConstraints={{ top: 0, left: 0, right: 0, bottom: 0 }}
/>
当 rotate
靠近最接近的整数角度时,播放 audio
。此时记录当前的旋转角度,以防止重复播放。
const texts = ["Huge epic braam", ...]
const rotate = useMotionValue(0)
const lastAudioPlayedDeg = useRef(0)
rotate.on("change", (value) => {
const nearest = Math.round(value / (360 / texts.length)) * (360 / texts.length)
if (Math.abs(nearest - value) < 3 && nearest !== lastAudioPlayedDeg.current) {
if (audioRef.current) {
audioRef.current.currentTime = 0
audioRef.current.play()
}
lastAudioPlayedDeg.current = nearest
}
})
<audio ref={audioRef} />
elevenlabs 是通过每一个字符分别运动实现的,但这可以优化一下。
既然字符是 mono
等宽的,我们可以把每一行中相同 index 的字符看作一个圆。这样一层一层往外,形成多个同心圆。对于上面的文字我们只需要 23 个转盘,因为最长的文字 "Small automobile weapon"
的长度为 23,页面上只会有 23 个元素运动。而 elevenlabs 的元素数量则需要 23 * 14 = 322
个。
function splitByCharacter(arr: string[]) {
// 找到最大字符串长度
const maxLength = Math.max(...arr.map((text) => text.length))
const result = []
for (let i = 0; i < maxLength; i++) {
// 获取每个字符串第 i 个字符,若超出则用空字符串代替
const tempArr = arr.map((text) => text[i] || "")
result.push(tempArr)
}
return result
}
每一个转盘都接收外层的 rotate
作为参数,在组件内部通过 motion/react
提供的非常方便的 useSpring
将其转换为不同 visualDuration
的 MotionValue
。
{
splitTexts.map((text, index) => (
<CircularText key={index} rotate={rotate} texts={text} charIndex={index} />
))
}
const CircularText = ({ rotate }) => {
const rotate = useSpring(rotateProp, {
visualDuration: step * charIndex,
bounce: 0,
restDelta: 0.01,
})
}
我们的文字允许多行,且不等宽:
因此要加上类似 elevenlabs 的效果就会变得复杂,必须让所有字符单独运动,关键是最终效果确实不一定好。
简单起见,一开始我通过 useVelocity
让字符整体做一个 skew
变换,但文字被扭曲的效果并不好,或者说是另一种感觉,我们就此决定放弃:
// 如果要用 useVelocity,就要把 useMotionValue 替换成 useSpring,否则 velocity 容易出现跳变
// const rotateValue = useMotionValue(0)
const rotateValue = useSpring(0, {
visualDuration: 0.2,
bounce: 0,
restDelta: 0.001,
})
const rotateValueVelocity = useVelocity(rotateValue)
const skewYValue = useTransform(rotateValueVelocity, (v) => -v / 20)
const rotateFinal = useTransform(rotateValueVelocity, (v) => -v / 25)
const velocityTransform = useTransform(
[rotateFinal, skewYValue],
([rotate, skewY]) =>
`translate3d(0, 0, 0) rotate(${rotate}deg) skewY(${skewY}deg)`
)
那我现在就尝试一下让每个字符单独运动。首先每一个字符的容器需要分开,除了字符还需要考虑左右两个棒棒,它们也需要给到不同的 visualDuration
。
实时拿到每个字符距离容器右侧的值,越是靠右的元素,visualDuration
越小;越是靠左的元素,visualDuration
越大。也就是说左边的棒棒是最慢的,右边的棒棒是最快的:
useEffect(() => {
if (charRef.current) {
// 拿到 charRef.current 距离容器右边多少
const rect = charRef.current.getBoundingClientRect()
const wrapperRect = wrapperRef.current.getBoundingClientRect()
const duration = 0.01 * (wrapperRect.right - rect.right) * 0.07
setVisualDuration(duration)
}
}, [charRef.current])
这样,原先一整个转盘的动画,一下就变成了几百个元素的动画,性能一定存在问题了。Anyway, it's fun。效果如下:
点赞如果只是数字的增加就太没有氛围了,我们可以把两根应援棒变成烟花棒玩玩。你应该也在首页见到了:
还有一些也值得尝试,但我精力有限。
比如我们在用户输入的时候希望能够让 textarea
的字体大小是动态调节的,随着输入字符的增多,字体大小从 30px
流动变换到 24px
,再比如对 textarea
多行逻辑的判断等。这些都是我没有尝试过的 UI。因为时间有限,我们也没有太深入打磨。
另外入场动画第一次使用 motion/react
实现,API 很方便。但 JS 动画确实存在性能顾虑,总是让我不放心,但小活动嘛就用了。
总之,有机会再玩!