前言
哈喽大家好!我是 嘟老板。
现在越来越多的网站都有切换主题功能,既能凸显网站多样的风格,也能满足不同审美的用户,极大的提升了用户体验,但是大部分都是直接从一种主题切换到另一种主题,切换效果略显生硬,最近看了 ElementPlus 官网切换主题的过渡动画,顿时醍醐灌顶,原来还可以这样做!
阅读本文您将收获:
- 了解并实现切换主题过渡动画全过程。
- 了解相关知识点,如 startViewTransition、animate 等。
效果预览
实现过程
整个实现过程大致可以分为三步:主题切换、动画过渡和圆形扩散动画。
主题切换
切换主题通常是通过修改 html
标签的 class
来实现。当主题为 light
时,html
标签的 class
设为 light
或置空,当主题为 dark
时,html
标签的 class
设为 dark
。
以一个简单的 html 为例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
:root {
--bg-color: #fff;
background-color: var(--bg-color);
}
.dark {
--bg-color: #000;
}
</style>
</head>
<body>
<button id="themeToggler">切换主题</button>
</body>
<script>
const themeToggler = document.getElementById('themeToggler');
themeToggler.addEventListener('click', () => {
document.documentElement.classList.toggle('dark')
})
</script>
</html>
以上实现了一个最简易的切换主题效果,首先通过 css
设置不同 class 对应的背景色变量(--bg-color),若 class 为 dark
时,将 --bg-color 设为黑色(#000
),若 class 为空时,将 --bg-color 设为白色 #fff
。
效果如下:
不过以上方式建议只在demo中使用,在实际项目中,还是借助 UI 库的主题切换功能更方便快捷,极大减轻心智负担。
ZiMuAdmin就是借助 ElementPlus
提供的主题 + VueUse 来实现主题切换。
封装主题切换按钮
先封装一个切换主题的按钮组件 - ThemeToggler
,组件基于 ElementPlus
的开关组件 El-Switch 封装,具体逻辑如下:
-
El-Switch
组件双向绑定(v-model)darkMode
变量,darkMode
为true
时,表示暗黑主题;为false
时表示明亮主题。<el-switch ref="switchRef" v-model="darkMode" v-bind="attrs" :active-action-icon="DarkIcon" :inactive-action-icon="LightIcon" />
active-action-icon
和inactive-action-icon
分别表示暗黑主题和明亮主题对应的图标,可在 ElementPlusIcons 中选择,也可自定义 svg。提供两个 svg 供参考,复制粘贴到
.vue
文件中即可。<template> <svg viewBox="0 0 24 24" class="dark-icon"> <path d="M11.01 3.05C6.51 3.54 3 7.36 3 12a9 9 0 0 0 9 9c4.63 0 8.45-3.5 8.95-8c.09-.79-.78-1.42-1.54-.95A5.403 5.403 0 0 1 11.1 7.5c0-1.06.31-2.06.84-2.89c.45-.67-.04-1.63-.93-1.56z" fill="currentColor" /> </svg> </template>
<template> <svg viewBox="0 0 24 24" class="light-icon"> <path d="M6.05 4.14l-.39-.39a.993.993 0 0 0-1.4 0l-.01.01a.984.984 0 0 0 0 1.4l.39.39c.39.39 1.01.39 1.4 0l.01-.01a.984.984 0 0 0 0-1.4zM3.01 10.5H1.99c-.55 0-.99.44-.99.99v.01c0 .55.44.99.99.99H3c.56.01 1-.43 1-.98v-.01c0-.56-.44-1-.99-1zm9-9.95H12c-.56 0-1 .44-1 .99v.96c0 .55.44.99.99.99H12c.56.01 1-.43 1-.98v-.97c0-.55-.44-.99-.99-.99zm7.74 3.21c-.39-.39-1.02-.39-1.41-.01l-.39.39a.984.984 0 0 0 0 1.4l.01.01c.39.39 1.02.39 1.4 0l.39-.39a.984.984 0 0 0 0-1.4zm-1.81 15.1l.39.39a.996.996 0 1 0 1.41-1.41l-.39-.39a.993.993 0 0 0-1.4 0c-.4.4-.4 1.02-.01 1.41zM20 11.49v.01c0 .55.44.99.99.99H22c.55 0 .99-.44.99-.99v-.01c0-.55-.44-.99-.99-.99h-1.01c-.55 0-.99.44-.99.99zM12 5.5c-3.31 0-6 2.69-6 6s2.69 6 6 6s6-2.69 6-6s-2.69-6-6-6zm-.01 16.95H12c.55 0 .99-.44.99-.99v-.96c0-.55-.44-.99-.99-.99h-.01c-.55 0-.99.44-.99.99v.96c0 .55.44.99.99.99zm-7.74-3.21c.39.39 1.02.39 1.41 0l.39-.39a.993.993 0 0 0 0-1.4l-.01-.01a.996.996 0 0 0-1.41 0l-.39.39c-.38.4-.38 1.02.01 1.41z" fill="currentColor" /> </svg> </template>
-
借助
VueUse
的 useDark 和 useToggle api 实现主题切换。我们将该部分内容单独写入
helper.ts
中维护,后续可持续维护相关逻辑。import { useDark, useToggle } from '@vueuse/core' export const isDark = useDark({ storageKey: 'zm-theme' }) export const toggleDark = useToggle(isDark)
其中
isDark
表示当前是否为暗黑主题,toggleDark
为用于切换isDark
值的函数,即用于切换主题。 -
ThemeToggler
组件引入helper
,定义darkMode
变量,初始值为isDark
的值,并在darkMode
变化时,调用toggleDark
函数切换主题。<script setup lang="ts"> import { isDark, toggleDark } from './helper' import DarkIcon from './icons/dark.vue' import LightIcon from './icons/light.vue' defineOptions({ name: 'ThemeToggler' }) const attrs = useAttrs() const darkMode = ref(isDark.value) watch( () => darkMode.value, () => { toggleDark() } ) </script>
-
如有需要,可通过
svg
的class
(.dark-icon
和.light-icon
)自定义图标的样式,提供style
供参考:<style lang="scss" scoped> :deep(.dark-icon) { border-radius: 50%; color: #cfd3dc; background-color: #141414; } :deep(.light-icon) { color: #606266; } </style>
OK,到这一个初步的切换主题按钮就封装完毕了,有问题滴滴我。
视图过渡
切换主题搞定了,开始着手实现动画过渡。
这里我们用到 Document 接口提供的实例方法:startViewTransition,详见下文附录部分。
El-Switch
提供了 before-change
属性,是状态更改前的钩子,正好可以用来处理动画逻辑。
处理函数代码如下:
const handleBeforeChange = () => {
// 若浏览器不支持 View Transitions
if (!document.startViewTransition) {
return true
}
return new Promise(resolve => {
document.startViewTransition(() => {
resolve(true)
})
})
}
在 before-change
中,我们通过 startViewTransition
开启一个新的视图过渡,并在过渡结束后,通过 resolve(true)
来告诉 El-Switch
组件过渡已经完成,以继续更新状态。
效果如下:
现在已经有了过渡效果,但看起来好像没啥区别,别急,我们加上动画就明显了。
圆形扩散动画
实现动画需要借助 Element 接口提供的 animate 实例方法,详见下文附录部分。
具体实现分为以下步骤:
-
获取动画开始位置,在主题切换按钮的中心点。
代码如下:
const switchEl = switchRef.value.$el as HTMLElement const rect = switchEl.getBoundingClientRect() const x = rect.left + rect.width / 2 const y = rect.top + rect.height / 2
首先通过 Vue 的 ref 语法获取按钮元素,然后通过
getBoundingClientRect
方法获取元素尺寸数据和相对于视窗的位置,其中rect.left
是元素相对于视窗左侧的距离,rect.top
是元素相对于视窗顶部的距离,rect.width
是元素的宽度,rect.height
是元素的高度,通过简单的运算,得出按钮中心点相对于视窗的位置坐标 x、y(单位:像素 px)。 -
计算圆形动画的半径,确定动画需要扩散的最大范围
代码如下:
const radius = Math.hypot( Math.max(x, innerWidth - x), Math.max(y, innerHeight - y) )
我们来看张简图,方便理解:
目前,已知坐标 x 和 y,求 radius,应该很一目了然吧,三角形勾股定理得:x^2 + y^2 = radius^2,radius 等于 根号下(x^2 + y^2),恰好
Math
对象提供了 hypot 方法,可以求所有参数平方和的平方根,所以上面代码中的 radius 就是我们需要的半径。至于为什么要用
Math.max
,是因为切换主题的按钮可能在左边,也可能在右边,按钮离哪边远,动画就向那边扩散的多,取最长的半径,没毛病。 -
设置动画
代码如下:
transition.ready.then(() => { const clipPath = [ `circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)` ] // 新视图的根元素动画 document.documentElement.animate( { clipPath }, { duration: 400, easing: 'ease-in', // 指定要附加动画的伪元素 pseudoElement: '::view-transition-new(root)' } ) })
主要可以分为两部分:
- 应用 transition.ready 属性,等待相关伪元素创建完成后,开始过渡动画。
- 应用
document.documentElement.animate
设置动画,第一个参数为动画关键帧对象,第二个参数是属性配置对象,其中pseudoElement: '::view-transition-new(root)'
用来设置附加动画的伪元素,::view-transition-new(root)
就是调用startViewTransition
开始的视图过渡过程中创建的伪元素。
handleBeforeChange 完整代码
const handleBeforeChange = () => {
// 浏览器不支持 View Transitions 时的回退方案:
if (!document.startViewTransition) {
return true
}
return new Promise(resolve => {
const switchEl = switchRef.value.$el as HTMLElement
const rect = switchEl.getBoundingClientRect()
const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2
const radius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)
const transition = document.startViewTransition(() => {
resolve(true)
})
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${radius}px at ${x}px ${y}px)`
]
document.documentElement.animate(
{
clipPath
},
{
duration: 400,
easing: 'ease-in',
pseudoElement: '::view-transition-new(root)'
}
)
})
})
}
-
关闭视图过渡默认动画,在应用的入口文件(如
main.ts
)中,引入包含以下代码的样式文件(如index.scss
)即可:::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; } ::view-transition-image-pair(root) { isolation: auto; }
OK,到这从主题切换到过渡动画就算完成了,看下最终效果:
你问我为啥和 ElementPlus
官网的效果不完全一致?因为我不想要收回的效果,哈哈!如果你想,切换到暗黑主题时,更改 clipPath
为相反的顺序,并且将动画伪元素配置 pseudoElement
改为 '::view-transition-old(root)'
即可。
附录
startViewTransition
这是一项实验性技术,用于生产前,请仔细检查浏览器兼容性。
startViewTransition
是 View Transitions API 提供的一个函数方法,可以开始一个新的视图过渡,并返回一个 ViewTransition 对象。
语法
startViewTransition(callback)
参数:
callback
:回调函数,通常用于在视图过渡过程中更新 DOM,返回一个Promise
,在 API 截取当前页面的屏幕截图后被调用。若callback
返回Promise resolve
,视图过渡将在下一帧开始;若callback
返回Promise reject
,视图过渡将终止。
视图过渡过程如下:
-
当调用
document.startViewTransition()
时,API 会截取当前页面的屏幕截图。 -
调用传给
startViewTransition()
的回调函数。当回调函数成功时,ViewTransition.updateCallbackDone
Promise 兑现,允许你响应 DOM 更新。例如:
transition.updateCallback.then(() => { // DOM 更新... })
-
API 会捕获页面的新状态并实时展示。
-
API 构造了一个具有以下结构的伪元素树:
::view-transition └─ ::view-transition-group(root) └─ ::view-transition-image-pair(root) ├─ ::view-transition-old(root) └─ ::view-transition-new(root)
::view-transition
是视图过渡叠加层的根元素,它包含所有视图过渡且位于所有其他页面内容的顶部。::view-transition-old
是旧页面视图的屏幕截图;::view-transition-new
是新页面视图的实时展示。
当过渡动画即将运行时,
ViewTransition.ready
Promise 兑现,你可以响应它进行一些操作,如运行自定义的 JavaScript 动画,而不是默认的动画。例如:
transition.ready.then(() => { // 自定义动画 })
-
旧页面视图的
opacity
从 1 过渡到 0,而新视图从 0 过渡到 1,这就是默认的交叉淡入淡出效果。 -
当过渡动画结束时,
ViewTransition.finished
Promise 兑现,你可以响应它进行一些操作。例如:
transition.finished.then(() => { // 过渡动画结束... })
animate
Element 接口的 animate()
方法用于创建一个新的 Animation,并将它应用于某个元素,然后运行动画,返回一个新建的 Animation 对象实例。
一个元素上可以应用多个动画效果。Element 接口提供 getAnimations() 方法来获得动画效果列表。
语法
animate(keyframes, options)
参数
-
keyframes
:关键帧对象数组,或一个关键帧对象(其属性是可迭代值的数组),如:// 数组形式 element.animate( [ { // from opacity: 0, color: "#fff", }, { // to opacity: 1, color: "#000", }, ], 2000, );
// 对象形式 element.animate( { opacity: [0, 1], // [ from, to ] color: ["#fff", "#000"], // [ from, to ] }, 2000, );
-
options
:表示动画持续时间的整数(以毫秒为单位),或包含一个或多个时间属性的对象,对象的属性包含:delay
:表示动画延迟时间(以毫秒为单位),整数,默认为 0。duration
:定义动画持续时间(以毫秒为单位),整数,默认为 0,即不会运行动画。direction
:定义动画方向,可选值为normal
、reverse
、alternate
、alternate-reverse
,默认normal
。easing
:d定动画缓动函数,可选值为 easing-function,如linear
、ease
、ease-in
、ease-out
、ease-in-out
、step-start
、step-end
,默认为linear
。enddelay
:定义动画结束延迟时间的整数(以毫秒为单位),默认为 0。fill
:定义动画填充模式,可选值为none
、forwards
、backwards
、both
,默认为none
。iterationstart
:定义动画开始的时机,若设为 0.5,则动画将在第一次迭代的一半时开始播放,默认值为 0.0,即动画将从第一次迭代的开始处开始播放。iterations
:定义动画迭代次数,默认为 1,也可设为Infinity
,表示动画在元素存在期间一直重复。composite
:定义动画合成模式,可选值为add
、accumulate
、replace
,默认replace
。iterationcomposite
:定义动画迭代合成模式,可选值为accumulate
、replace
,默认replace
。pseudoelement
:包含伪元素选择器的字符串,如::before
、::after
,若存在,则动画效果将应用于目标的选定伪元素,而不是目标本身。
例如:
element.animate( { opacity: [0, 1], // [ from, to ] color: ["#fff", "#000"], // [ from, to ] }, { duration: 2000, easing: 'ease-in-out', pseudoelement: '::before' }, );
结语
本文重点介绍了切换主题过渡动画的完整实现过程,并简单介绍了相关知识点,如 startViewTransition、animate 等,旨在帮助同学们加深对于相关技术的应用理解。希望对您有所帮助!相关代码已上传至 GitHub,欢迎 star。
如您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。
技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。
往期干货
- 🔥Vite6.0 都发布了,还没了解过原理?敲一个本地服务,模拟下 Vite 加载资源的过程
- 🔥Vue3响应式系统玩明白了吗?一文带你从0入门响应式
- 🚀前端懂算法,一个顶俩!超细解读常用的排序算法,Passion!!! (持续更新版~~~)
- 🚀面试离不开的首屏性能优化是什么,到底该怎么做
- 🔜想开发 vscode 插件却不知从何入手?超速入门,助力你弹射起步
- 💯What?维护新老项目频繁切换node版本太恼火?开发一个vscode插件自动切换版本,从此告别烦恼
- (⊙ˍ⊙)哦? ElementPlus 官网导航栏有点意思,来看看咋实现的
- 🧨🧨🧨你想要的 RBAC 权限管理实现全流程来啦!~~ 代码含量过多,请谨慎阅读 ~~
- 👏👏👏厉害了 Vue Vine !Vue 组件还能这样写!!!
- 一文带你了解多数企业系统都在用的 RBAC 权限管理策略