一直觉得 Element-plus 的亮暗切换很漂亮,最近抽时间研究了一下,技术还是比较新的,甚至在打包的时候,对应 api 还报了找不到声明的问题,也算是小坑。
吐槽一下:前端真是天天卷样式,实在搞不动了
实现原理
我们先来看一下 element-plus 的效果:
分析一下可以看出,要想实现这个效果,至少需要四步:
1、找到点击的位置
2、再找到距离点击位置的最远位置(以上图官方为例,点击的是右上角,那么最远的点应该就是左下角)
3、基于上面的点,画一个大圆
4、让这个圆动起来,实现效果切换~
前面三个都不难,重点就是最后一步,这个我一直以为使用的是蒙版~
实现基础切换
我创建了一个管理框架,并使用了
Element-Plus
和vueuse
。
首先,创建这个 switch 按钮,并实现基础逻辑:
<template>
<el-switch
class="XSwitchTheme"
:model-value="isDark"
@change="onChange"
@click.capture="onClick"
>
<template #active-action>🌙</template>
<template #inactive-action>🌞</template>
</el-switch>
</template>
<script setup lang="ts">
import { useDark } from '@/hooks/useDark';
import { ref } from 'vue';
const { isDark, mode } = useDark();
const pos = ref({ x: 0, y: 0 });
function onClick(e: MouseEvent) {
pos.value = { x: e.clientX, y: e.clientY };
}
function onChange(val: boolean) {
mode.value = val ? 'dark' : 'light';
}
</script>
因为使用了 vueuse 的 useColorMode
来管理亮暗模式,所以 swtich 中需要使用只读的方式传值,在 change 事件中去修改它。
useDark 的逻辑也很简单:
import { useColorMode } from '@vueuse/core';
import { computed } from 'vue';
export const useDark = () => {
const mode = useColorMode({
emitAuto: true,
storageKey: "ACTIVE_COLOR_SCHEME",
disableTransition: false,
initialValue: 'light'
});
return {
isDark: computed(
() =>
(mode.store.value === 'auto' ? mode.system.value : mode.store.value) ===
'dark'
),
mode
};
};
这样就可以适配亮暗,并且还可以根据系统自动选择。
现在,我们的页面也可以完成切换效果:
找到远端点,并绘制大圆
在上面的代码中,已经找到了每次点击的位置,现在,我们需要找到对应的远点:
我们要做的是,无论切换按钮在哪里,都可以找到远点,如图示例:
也就是:
1、找到 x 轴的较大值
2、找到 y 轴的较大值
3、基于 x 和 y 值,相当于一个直角三角形,求斜边边长,这个边长就是大圆的半径
这里要用到 js 的原生方法 Math.hypot
,它可以很方便的求出斜边:
const radius = Math.hypot(
Math.max(pos.value.x, window.innerWidth - pos.value.x),
Math.max(pos.value.y, window.innerHeight - pos.value.y)
);
我们有了半径,怎么画圆呢?这里就需要用到 css 提供的一个属性:
clip-path
:使用裁剪方式创建元素的可显示区域。区域内的部分显示,区域外的隐藏。
有兴趣的朋友可以自行查看 MDN文档
clip-path
比如我们现在在页面上这样写:
clip-path: circle(10% at 50% center);
关于 circle 更多内容可以查看 MDN文档
我们将它挂在文本上:
<h1 class="text-100px" style="clip-path: circle(10% at 50% center)">JEREMYJONE</h1>
看到的效果就是:
它将 h1 标签进行了裁切,大小为 10%,位置在 50%、中央的位置,它只展示这个区域的内容。
有了上面关于 circle 的基础,我们可以想象,基于点击位置,画一个上面计算出来的 radius 半径大小的圆。它应该这样写:
`clip-path: circle(${radius}px at ${pos.value.x}px ${pos.value.y}px)`
我们先看一下效果,所以我们直接在切换后,将半径设置为 500px,我们把它画在根节点上:
function onChange(val: boolean) {
mode.value = val ? 'dark' : 'light';
// 半径固定设为 500px
document.documentElement.style.clipPath = `circle(500px at ${pos.value.x}px ${pos.value.y}px)`;
}
可以看到在点击后,圆已经出现了。现在就要实现它的动画。
实现动画
要实现这个动画,就要使用一个比较新的 API 了。
startViewTransition
,它开始一个新的视图过渡,并返回一个 ViewTransition 对象来表示它。
说人话,就是在页面发生变化后启动一个动画,并可以通过伪类实现不同效果。直接上用法:
// 调用 startViewTransition 实现动画
document.startViewTransition(() => { mode.value = val? 'dark' : 'light' }).ready.then(() => {
document.documentElement.animate(
{
// 圆,从半径为0,到半径最大,然后让其实现动画,也就实现了圆自动展开的效果
clipPath: [
`circle(0px at ${pos.value.x}px ${pos.value.y}px)`,
`circle(${radius}px at ${pos.value.x}px ${pos.value.y}px)`
]
},
{
duration: 600,
pseudoElement: '::view-transition-new(root)'
}
)
})
这里的 ::view-transition-new(root)
伪类是应用在 startViewTransition
API 中的,具体可以看 W3C技术规范
现在我们就可以看到动画效果了:
但是,效果并不明显,这是因为这个伪类有默认效果,我们需要重置它:
::view-transition-new(root),
::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
这样就看到效果了。
实现反向效果
反向效果的实现就很简单了,只需要将其动画翻转即可:
function onChange(val: boolean) {
const setTheme = () => {
mode.value = val ? 'dark' : 'light';
};
const doAnimate = () => {
const radius = Math.hypot(
Math.max(pos.value.x, window.innerWidth - pos.value.x),
Math.max(pos.value.y, window.innerHeight - pos.value.y)
);
const clipPath = [
`circle(0px at ${pos.value.x}px ${pos.value.y}px)`,
`circle(${radius}px at ${pos.value.x}px ${pos.value.y}px)`
];
document.documentElement.animate(
// 通过 val 值判断展示方向
{ clipPath: val ? clipPath.reverse() : clipPath },
{
duration: 600,
pseudoElement: val
// 这里一样,通过 val 值判断方向
? '::view-transition-old(root)'
: '::view-transition-new(root)'
}
);
};
document.startViewTransition
? document.startViewTransition(setTheme).ready.then(doAnimate)
: setTheme();
}
这里有一个细节,就是反向的时候,我们仍然需要添加一个 css,否则 old 会因为层级不够,而看不到效果,因为 new 默认永远在 old 上面:
.dark::view-transition-old(root) {
z-index: 9999999999;
}
需要注意,添加的时候要挂载 dark 属性,否则变亮时,也会看不到效果了。
至此,我们就完成了整个效果。
完整代码
<template>
<el-switch
class="XSwitchTheme"
:model-value="isDark"
@change="onChange"
@click.capture="onClick"
>
<template #active-action>🌙</template>
<template #inactive-action>🌞</template>
</el-switch>
</template>
<script setup lang="ts">
import { useDark } from '@/hooks/useDark';
import { ref } from 'vue';
const { isDark, mode } = useDark();
const pos = ref({ x: 0, y: 0 });
function onClick(e: MouseEvent) {
pos.value = { x: e.clientX, y: e.clientY };
}
function onChange(val: boolean) {
const setTheme = () => {
mode.value = val ? 'dark' : 'light';
};
const doAnimate = () => {
const radius = Math.hypot(
Math.max(pos.value.x, window.innerWidth - pos.value.x),
Math.max(pos.value.y, window.innerHeight - pos.value.y)
);
const clipPath = [
`circle(0px at ${pos.value.x}px ${pos.value.y}px)`,
`circle(${radius}px at ${pos.value.x}px ${pos.value.y}px)`
];
document.documentElement.animate(
{ clipPath: val ? clipPath.reverse() : clipPath },
{
duration: 600,
pseudoElement: val
? '::view-transition-old(root)'
: '::view-transition-new(root)'
}
);
};
document.startViewTransition
? document.startViewTransition(setTheme).ready.then(doAnimate)
: setTheme();
}
</script>
<style>
.XSwitchTheme {
.el-switch__action {
background-color: transparent;
}
}
::view-transition-new(root),
::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
.dark::view-transition-old(root) {
z-index: 9999999999;
}
</style>
文章评论