最近把 blog 右上角的黑暗模式切换按钮重写了一下,为此还学了学 Figma,虽然技术不难,但是收获颇多,至少自己可以照猫画虎设计一些简单的 SVG 图形了,
废话不多说,下边我介绍一下我是怎样使用 react-spring
来实现如此丝滑的切换动画的,你可以点击下方按钮进行体验,
为了蹭一波 iPhone 灵动岛
热度,我标题还特意用了 灵动
这俩字 🤣。
准备工作
写之前先拆解一下该组件的元素构成,大概分为以下四部分:
- 整个 switch 容器,主题切换时动态修改背景色
- 中间的小球,黑暗模式是月球,浅色模式是太阳
- 左侧的星星,黑暗模式下显示
- 右侧的云(姑且称它为云,过于抽象了点),浅色模式下显示
其中 1 和 2 这里直接用 div 来写,3 和 4 我们可以用 SVG 来弄。
黑暗模式情况下,中间的小球会变成月球,它里边有三颗月球坑我们也直接用 div 来模拟
SVG 我是用 Figma 来画的,我这里直接提供一下代码,有能力的也可以自己画一些不一样的图形。
云朵
<svg width="104" height="54" viewBox="0 0 104 54" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18.0258 11.2704C18.0258 5.34458 22.8296 0.540771 28.7554 0.540771H93.1331C99.0589 0.540771 103.863 5.34458 103.863 11.2704C103.863 17.1962 99.0589 22 93.1331 22H66.2146C63.3038 22 60.9442 24.3596 60.9442 27.2704V27.2704C60.9442 30.1811 63.3038 32.5408 66.2146 32.5408H75.1073C81.0331 32.5408 85.8369 37.3446 85.8369 43.2704C85.8369 49.1962 81.0331 54 75.1073 54H10.7296C4.80381 54 0 49.1962 0 43.2704C0 37.3446 4.80381 32.5408 10.7296 32.5408H44.7296C47.6404 32.5408 50 30.1811 50 27.2704V27.2704C50 24.3596 47.6404 22 44.7296 22H28.7554C22.8296 22 18.0258 17.1962 18.0258 11.2704Z" fill="white"/></svg>
星星 ✨
<svg width="89" height="77" viewBox="0 0 89 77" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M25 10L31.7523 28.2477L50 35L31.7523 41.7523L25 60L18.2477 41.7523L0 35L18.2477 28.2477L25 10Z" fill="#C6D0D1"/><path d="M71.5 42L76.2266 54.7734L89 59.5L76.2266 64.2266L71.5 77L66.7734 64.2266L54 59.5L66.7734 54.7734L71.5 42Z" fill="#C6D0D1"/><path d="M61 0L63.7009 7.29909L71 10L63.7009 12.7009L61 20L58.2991 12.7009L51 10L58.2991 7.29909L61 0Z" fill="#C6D0D1"/></svg>
组件搭建
资源准备好后,让我们先把整个组件的样板代码写出来
import { useState } from 'react'function DarkModeToggle() {const [isDarkMode, setIsDarkMode] = useState(false)// 我们将 width 和 height 设置为了一个合适大小const starts = (<svg className="absolute left-[8px] top-[7px]" width="16" height="14" viewBox="0 0 89 77" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M25 10L31.7523 28.2477L50 35L31.7523 41.7523L25 60L18.2477 41.7523L0 35L18.2477 28.2477L25 10Z" fill="#C6D0D1"/><path d="M71.5 42L76.2266 54.7734L89 59.5L76.2266 64.2266L71.5 77L66.7734 64.2266L54 59.5L66.7734 54.7734L71.5 42Z" fill="#C6D0D1"/><path d="M61 0L63.7009 7.29909L71 10L63.7009 12.7009L61 20L58.2991 12.7009L51 10L58.2991 7.29909L61 0Z" fill="#C6D0D1"/></svg>)// 我们将 width 和 height 设置为了一个合适大小const clouds = (<svg className="absolute right-[10px] top-[10px]" width="15" height="8" viewBox="0 0 104 54" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18.0258 11.2704C18.0258 5.34458 22.8296 0.540771 28.7554 0.540771H93.1331C99.0589 0.540771 103.863 5.34458 103.863 11.2704C103.863 17.1962 99.0589 22 93.1331 22H66.2146C63.3038 22 60.9442 24.3596 60.9442 27.2704V27.2704C60.9442 30.1811 63.3038 32.5408 66.2146 32.5408H75.1073C81.0331 32.5408 85.8369 37.3446 85.8369 43.2704C85.8369 49.1962 81.0331 54 75.1073 54H10.7296C4.80381 54 0 49.1962 0 43.2704C0 37.3446 4.80381 32.5408 10.7296 32.5408H44.7296C47.6404 32.5408 50 30.1811 50 27.2704V27.2704C50 24.3596 47.6404 22 44.7296 22H28.7554C22.8296 22 18.0258 17.1962 18.0258 11.2704Z" fill="white"/></svg>)return (<divclassName="relative w-[56px] h-[28px] rounded-full p-[5px] cursor-pointer"onClick={() => setIsDarkMode(isDarkMode ? 'light' : 'dark')}>{/* 星星 */}{starts}{/* 云朵 */}{clouds}{/* 中间的圆形星球 */}<div className="relative w-[18px] h-[18px] rounded-full z-10">{/* 月球陨石坑 */}<div className="relative w-full h-full"><div className="absolute top-[6px] left-[4px] w-[4px] h-[4px] rounded-full bg-slate-400/50 shadow-inner" /><div className="absolute top-[8px] left-[11px] w-[1px] h-[1px] rounded-full bg-slate-400/50 shadow-inner" /><div className="absolute top-[11px] left-[9px] w-[2px] h-[2px] rounded-full bg-slate-400/50 shadow-inner" /></div></div></div>)}
上述代码中我使用了 TailwindCSS
,如果你不喜欢,请把他替换成普通的样式写法。
写到这里,一个基础框架就算完成了,不过现在它是一点动效都没有,显得很生硬,接下来让它动起来。
动效
动画库我使用的是 react-spring
,它是一个模仿弹簧效果的动画库,可以使你的组件更生动,具体的文档大家可以去官网看。
然后有时间可以看看这个大佬的演讲视频,介绍了类似的动效理念,英语渣的我全程开着汉语机翻看完的。
小球
让我们先弄中间小球的左右切换动效,毕竟它是最显眼的一个。
// 整个小球const nodeStyles = useSpring({x: isDarkMode ? 28 : 0, // 黑暗模式下,球体位于右侧rotate: isDarkMode ? 0 : 180, // 黑暗模式时,给他加一个旋转动画backgroundColor: isDarkMode ? '#c6d0d1' : '#fde047', // 通过颜色来区分是月球还是太阳})// 月球坑,仅在黑暗模式下显示,只是个透明度变化const craterStyles = useSpring({opacity: isDarkMode ? 1 : 0,})
星星和云朵
接下来再弄星星和云朵的动画
星星动效
// 我们把 SVG 中三颗星星的 path 抽离到一个数组中,给 useTranslation 用const starPaths = ['M25 10L31.7523 28.2477L50 35L31.7523 41.7523L25 60L18.2477 41.7523L0 35L18.2477 28.2477L25 10Z','M71.5 42L76.2266 54.7734L89 59.5L76.2266 64.2266L71.5 77L66.7734 64.2266L54 59.5L66.7734 54.7734L71.5 42Z','M61 0L63.7009 7.29909L71 10L63.7009 12.7009L61 20L58.2991 12.7009L51 10L58.2991 7.29909L61 0Z',]// 由于有三颗星星,这里我们用 useTranslation 这个 hook,他可以接受一个数组,并在数组内容变动时候,执行对应动画const starPathTransitions = useTranslation(isDarkMode ? starPaths : [], {from: { scale: 0, rotate: -30, opacity: 0 }, // 初始状态enter: { scale: 1, rotate: 0, opacity: 1 }, // 终点状态leave: { scale: 0, rotate: -30, opacity: 0 }, // 离开状态trail: 150, // 我们让三颗星星错开执行动画})
星星动效同时使用缩放、不透明度与旋转,并错开运行,看着有种 bulingbuling
的感觉。
云朵动效
// 云朵只有一个元素,仅仅是一个左右平移 + 透明度变化,我们还是用 useSpring 这个 hookconst cloudStyles = useSpring({opacity: isDarkMode ? 0 : 1,x: isDarkMode ? -5 : 0,})
容器
容器动效很简单,只有一个背景色
const containerStyles = useSpring({backgroundColor: isDarkMode ? '#475569' : '#7dd3fc',})
最终代码
动效配置编写完毕后,把它们绑定到元素上,最终的代码如下
import React, { useState } from 'react'import { animated, useSpring, useTranslation } from '@react-spring/web'const config = { mass: 3, tension: 200, friction: 30 }const starPaths = ['M25 10L31.7523 28.2477L50 35L31.7523 41.7523L25 60L18.2477 41.7523L0 35L18.2477 28.2477L25 10Z','M71.5 42L76.2266 54.7734L89 59.5L76.2266 64.2266L71.5 77L66.7734 64.2266L54 59.5L66.7734 54.7734L71.5 42Z','M61 0L63.7009 7.29909L71 10L63.7009 12.7009L61 20L58.2991 12.7009L51 10L58.2991 7.29909L61 0Z',]function DarkModeToggle() {const [isDarkMode, setIsDarkMode] = useState(false)const starPathTransitions = useTranslation(isDarkMode ? starPaths : [], {from: { scale: 0, rotate: -30, opacity: 0 },enter: { scale: 1, rotate: 0, opacity: 1 },leave: { scale: 0, rotate: -30, opacity: 0 },trail: 150,})const cloudStyles = useSpring({opacity: isDarkMode ? 0 : 1,x: isDarkMode ? -5 : 0,config,})const nodeStyles = useSpring({x: isDarkMode ? 28 : 0,rotate: isDarkMode ? 0 : 180,backgroundColor: isDarkMode ? '#c6d0d1' : '#fde047',config,})const containerStyles = useSpring({backgroundColor: isDarkMode ? '#475569' : '#7dd3fc',config,})const craterStyles = useSpring({opacity: isDarkMode ? 1 : 0,config,})const starts = (<svg className="absolute left-[8px] top-[7px]" width="16" height="14" viewBox="0 0 89 77" fill="none" xmlns="http://www.w3.org/2000/svg">{starPathTransitions((styles, path) => (// 注意 style 这里 transformBox 不要省略,否则即使你设置了 transformOrigin 为 center,这个 path 也不能按照中心点运动<animated.path key={path} style={{ ...styles, transformBox: 'fill-box', transformOrigin: 'center' }} d={path} fill="#C6D0D1"/>))}</svg>)const clouds = (<animated.svg style={cloudStyles} className="absolute right-[10px] top-[10px]" width="15" height="8" viewBox="0 0 104 54" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18.0258 11.2704C18.0258 5.34458 22.8296 0.540771 28.7554 0.540771H93.1331C99.0589 0.540771 103.863 5.34458 103.863 11.2704C103.863 17.1962 99.0589 22 93.1331 22H66.2146C63.3038 22 60.9442 24.3596 60.9442 27.2704V27.2704C60.9442 30.1811 63.3038 32.5408 66.2146 32.5408H75.1073C81.0331 32.5408 85.8369 37.3446 85.8369 43.2704C85.8369 49.1962 81.0331 54 75.1073 54H10.7296C4.80381 54 0 49.1962 0 43.2704C0 37.3446 4.80381 32.5408 10.7296 32.5408H44.7296C47.6404 32.5408 50 30.1811 50 27.2704V27.2704C50 24.3596 47.6404 22 44.7296 22H28.7554C22.8296 22 18.0258 17.1962 18.0258 11.2704Z" fill="white"/></animated.svg>)return (<animated.divstyle={containerStyles}className="relative w-[56px] h-[28px] rounded-full p-[5px] cursor-pointer"onClick={() => setIsDarkMode(isDarkMode ? 'light' : 'dark')}>{starts}{clouds}<animated.div style={nodeStyles} className="relative w-[18px] h-[18px] rounded-full z-10"><animated.div style={craterStyles} className="relative w-full h-full"><div className="absolute top-[6px] left-[4px] w-[4px] h-[4px] rounded-full bg-slate-400/50 shadow-inner" /><div className="absolute top-[8px] left-[11px] w-[1px] h-[1px] rounded-full bg-slate-400/50 shadow-inner" /><div className="absolute top-[11px] left-[9px] w-[2px] h-[2px] rounded-full bg-slate-400/50 shadow-inner" /></animated.div></animated.div></animated.div>)}
注意一点,把动效绑定到元素上时,该元素必须是 animated.xxx,原生元素必须被 react-spring
包裹后动效才可用。
另外可以看到我在使用 useSpring
与 useTranslation
时传入了一个 config
参数,这个参数可以让你控制动效的展现方式,
可以发现切换时候那个小球有个弹簧反弹效果。 你可以自己尝试修改一下参数,查看他们有什么不同,
我在这里推荐一个可视化配置工具。