一个文笔一般,想到哪是哪的唯心论前端小白。
🧠 - 简介
主题切换的方案,网上有好多种,而我要实现这一种:
其实是模仿了 element-plus 官网的主题切换效果,它那里只是实现了亮色和暗色的切换,我则是在 element-plus 明亮切换的基础上,增加了 主色
的切换。
👁️ - 分析
主题切换站在产品的角度其实分为几个档:
- 明暗切换:我的世界里只有 0 和 1,非黑即白,其实主要是修改的反而是中性色的色值,例如:背景色,文本颜色,边框颜色等。
- 主色切换:主色切换和明暗切换,则是刚好相反,是在切换主色,也是我这在这个模板项目中使用的方案。
- 全站定制主题色:把所有的颜色提取出来进行归类,都使用变量控制,然后将这些变量进行设计定制。工作量很大。
另外总结一下一个网站上常用的几类颜色:
- 主色:网站的主要颜色,例如:京东的红色,支付宝的蓝色,美团的黄色等等。
- 辅助色:辅助色主要是和主色交相辉映的,为了衬托主色。
- 点缀色(功能色):在element-plus 中的具体体现就是:警告、错误、成功这些提示颜色。
- 中性色:背景、边框、文本颜色。
🫀 - 拆解
通过效果图可以看出来,这个主题切换分为如下几个部分:
- 明亮和暗黑切换的时候会出现一个动画效果,暗黑向亮白转换时,圆形由小到大,亮白向暗黑转换则相反。
- 都是明亮或者都是暗黑时,则不出现动画。
- 亮白和暗黑后面的颜色一样时,主色没有变化。
- 当下次进入系统时,要记录上次的主色,并进行主色切换。
所以开发起来就很顺利了,主要是实现这个动画效果,然后实现主色的切换。
💪 - 落实
动画效果讲解
动画切换是不是一下子想不起来怎么做?我也是查了好多资料才查到的。
它主要是用到了一个不怎么常用的api:document.startViewTransition
,看过兼容性才知道,兼容性并没有想象中的那么优秀,所以还要做一下兼容,如果浏览器不支持,就不进行动画了。
所以就出现了下面这段逻辑:
ts
// 主题切换动画
const animationTheme = (x: Ref<number>, y: Ref<number>, isToggle: boolean, color: string) => {
// 开始一次视图过渡:
const transition = document.startViewTransition(() => {
changePrimaryColor(colorMap[color])
isToggle && toggleDark()
});
transition.ready.then(() => {
//计算按钮到最远点的距离用作裁剪圆形的半径
const endRadius = Math.hypot(
Math.max(x.value, innerWidth - x.value),
Math.max(y.value, innerHeight - y.value)
);
const clipPath = [
`circle(0px at ${x.value}px ${y.value}px)`,
`circle(${endRadius}px at ${x.value}px ${y.value}px)`,
];
//开始动画
document.documentElement.animate(
{
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
},
{
duration: 400,
easing: "ease-in",
pseudoElement: isDark.value
? "::view-transition-old(root)"
: "::view-transition-new(root)",
}
);
});
}
// 操作主题切换
const handleChangeTheme = useThrottleFn((v: string) => {
if (v === localStorage.getItem('theme')) {
return
}
const [mode, color] = v.split('-')
const isToggle = (!isDark.value && mode === 'dark') || (isDark.value && mode === 'light')
theme.value = v
localStorage.setItem('theme', v)
//在不支持的浏览器里不做动画
if (document.startViewTransition) {
if (isToggle) {
animationTheme(x, y, isToggle, color)
} else {
changePrimaryColor(colorMap[color])
}
} else {
changePrimaryColor(colorMap[color])
isToggle && toggleDark()
}
}, 400)
上面还是用了节流函数进行包裹操作方法,避免动画进行到一半就被打断的尴尬局面 ~ ~ ~
startViewTransition[MDN] 地址,可以自己学习一下,我也正在学习这些不怎么常用的API。
主色生成器
通过看 element-plus 主题源码不难看出,主色和功能色其实都是一组由浅到深的渐变色,所以切换主色并不只是切换一个色值,而是切换一组色值。
所以就实现了一个色阶生成器:
ts
/**
* 创建两种颜色之间的混合色。
* @param color1 第一种颜色值。
* @param color2 第二种颜色值。
* @param ratio 混合比例(0 到 1 之间)。
* @returns 返回混合后的颜色值。
*/
function mix(color1: string, color2: string, ratio: number): string {
// 解析第一种颜色的 RGB 值
const r1 = parseInt(color1.substring(1, 3), 16);
const g1 = parseInt(color1.substring(3, 5), 16);
const b1 = parseInt(color1.substring(5, 7), 16);
// 解析第二种颜色的 RGB 值
const r2 = parseInt(color2.substring(1, 3), 16);
const g2 = parseInt(color2.substring(3, 5), 16);
const b2 = parseInt(color2.substring(5, 7), 16);
// 计算混合后的 RGB 值
const mixedR = Math.round(r1 * (1 - ratio) + r2 * ratio);
const mixedG = Math.round(g1 * (1 - ratio) + g2 * ratio);
const mixedB = Math.round(b1 * (1 - ratio) + b2 * ratio);
// 构建混合后的颜色值
const mixedColor = `#${mixedR.toString(16)}${mixedG.toString(16)}${mixedB.toString(16)}`;
return mixedColor;
}
然后进行变量替换
ts
/**
* 改变网页中特定元素的主题颜色。
* @param e 选定的颜色值。
*/
export function changePrimaryColor(e: string): void {
const pre = "--el-color-primary";
const mixWhite = "#ffffff";
const mixBlack = "#000000";
const el = document.documentElement;
// 设置主要颜色
el.style.setProperty(pre, e);
for (let i = 1; i < 10; i += 1) {
// 设置不同明度的颜色值
el.style.setProperty(`${pre}-light-${i}`, mix(e, mixWhite, i * 0.1));
}
// 设置较深的颜色值
el.style.setProperty("--el-color-primary-dark", mix(e, mixBlack, 0.1));
}
这样就实现了主色的切换。
🛀 - 总结
上面四个核心方法就是这个主题切换的核心功能了,最后为了凑字数,粘上这个抽屉的源码:
vue
<template>
<Drawer
ref="themeDrawer"
:show-cancel="false"
:confirm-text="'关闭'"
@confirm="handleConfirm"
>
<div>
<ul>
<li
v-for="item of themeList "
:key="item.value"
:class="{ 'theme-current': theme == item.value }"
class="theme-item"
@click="handleChangeTheme(item.value)"
>
<p>
<span
v-for="i in 4 "
:key="i"
:style="`background: ${['red', 'yellow', 'blue', 'green'][i - 1]}; height: 24px; width: 36px; display: inline-block;`"
/>
</p>
<p> {{ item.label }}</p>
<span
v-if="theme === item.value"
class="current-jiaobiao"
><i class="iconfont icon-jiaobiao" /></span>
</li>
</ul>
</div>
</Drawer>
</template>
<script setup lang="ts">
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* @Name: CommonTheme
* @Author: Zhang Ziyi
* @Email: 15227974559@163.com
* @Date: 2024-03-19 14:28
* @Introduce: --
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
import { onMounted, ref, Ref } from 'vue'
import {
useLocalStorage, useDark, useMouseInElement, useThrottleFn
} from '@vueuse/core'
import { useToggle } from '@vueuse/shared'
import { changePrimaryColor } from '@/utils/themeColorGenerate'
// Refs
const themeDrawer = ref()
// 数据
const theme = ref<string>('')
theme.value = useLocalStorage('theme', 'light-blue').value
const themeList = ref([
{ label: '暗黑-红', value: 'dark-red' },
{ label: '亮白-红', value: 'light-red' },
{ label: '暗黑-金', value: 'dark-yellow' },
{ label: '亮白-金', value: 'light-yellow' },
{ label: '暗黑-蓝', value: 'dark-blue' },
{ label: '亮白-蓝', value: 'light-blue' },
{ label: '暗黑-绿', value: 'dark-green' },
{ label: '亮白-绿', value: 'light-green' },
])
const colorMap: { [k: string]: string } = {
red: '#FF0000',
yellow: '#FFD700',
blue: '#0000FF',
green: '#00FF00'
}
onMounted(() => {
console.log('🎡 > 主题色初始化完成,当前主题色为:', theme.value, '。');
initTheme()
})
const initTheme = () => {
const [mode, color] = theme.value.split('-');
(!isDark.value && mode === 'dark') && toggleDark()
changePrimaryColor(colorMap[color])
}
// 切换主题相关方法
const { x, y } = useMouseInElement()
const isDark = useDark()
const toggleDark = useToggle(isDark)
// 主题切换动画
const animationTheme = (x: Ref<number>, y: Ref<number>, isToggle: boolean, color: string) => {
// 开始一次视图过渡:
const transition = document.startViewTransition(() => {
changePrimaryColor(colorMap[color])
isToggle && toggleDark()
});
transition.ready.then(() => {
//计算按钮到最远点的距离用作裁剪圆形的半径
const endRadius = Math.hypot(
Math.max(x.value, innerWidth - x.value),
Math.max(y.value, innerHeight - y.value)
);
const clipPath = [
`circle(0px at ${x.value}px ${y.value}px)`,
`circle(${endRadius}px at ${x.value}px ${y.value}px)`,
];
//开始动画
document.documentElement.animate(
{
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
},
{
duration: 400,
easing: "ease-in",
pseudoElement: isDark.value
? "::view-transition-old(root)"
: "::view-transition-new(root)",
}
);
});
}
const handleChangeTheme = useThrottleFn((v: string) => {
if (v === localStorage.getItem('theme')) {
return
}
const [mode, color] = v.split('-')
const isToggle = (!isDark.value && mode === 'dark') || (isDark.value && mode === 'light')
theme.value = v
localStorage.setItem('theme', v)
//在不支持的浏览器里不做动画
if (document.startViewTransition) {
if (isToggle) {
animationTheme(x, y, isToggle, color)
} else {
changePrimaryColor(colorMap[color])
}
} else {
changePrimaryColor(colorMap[color])
isToggle && toggleDark()
}
}, 400)
const handleCancel = (cb: () => void) => cb()
const handleConfirm = (cb: () => void) => cb()
// 对外暴露方法
const open = () => {
themeDrawer.value.open('主题切换')
}
defineExpose({
open
})
</script>
<style lang="scss" scoped>
.theme-item {
cursor: pointer;
position: relative;
margin: 8px 0;
border: 1px solid var(--el-border-color-light);
.current-jiaobiao {
position: absolute;
bottom: 0;
right: 0;
display: block;
color: var(--el-color-primary);
}
&.theme-current {
background: var(--el-color-primary-light-5);
border-color: var(--el-color-primary-light-5);
}
padding: 10px;
&:hover {
background: var(--el-color-primary-light-8);
}
}
</style>
后续会把一些方法提出去,减轻这个组件的体积,方便方便维护。
系列文章:
- 脚手架开发
- 模板项目初始化
- 模板项目开发规范与设计思路
- layout设计与开发
- login 设计与开发
- CURD页面的设计与开发
- 监控页面的设计与开发
- 富文本编辑器的使用与页面设开发设计
- 主题切换的设计与开发并页面
- 水印切换的设计与开发
- 全屏与取消全屏
- 开发提效之一键生成模块(页面)