使用 startViewTransition 实现 Element Plus 主题切换效果
使用 startViewTransition 实现 Element Plus 主题切换效果
效果分析
- 点击主题按钮,切换到白天(light)模式,主题效果从点击位置由一个小圆点逐渐放大,直到覆盖整个页面。
- 点击主题按钮,切换到黑夜(dark)模式,主题效果从整个页面以点击位置为圆心,由一个大圆逐渐收缩成小圆点,直至消失。
实现原理
比较容易想到的方式,就是将黑夜模式页面和白天模式页面完全叠在一起,在切换主题的时候,对原主题页面进行裁切,直到原主题页面完全消失,新主题页面完全显示。
实现步骤
既然是圆形裁切,那么就需要确定圆心坐标和半径。
主题切换由按钮的点击事件触发,那么圆心坐标就是鼠标点击的坐标
<button onclick="changeTheme">change</button>
// 获取按钮
let button = document.querySelector("button");
/**
* 点击按钮切换主题事件
*/
function changeTheme(event) {
const { clientX: x, clientY: y } = event;
}
知道了圆心的坐标,那么圆形的半径就可以求出,下面是圆形求半径的公式。
因为这个圆形需要覆盖整个页面,为了保险,所以这里取了页面的最大值。
const r = Math.hypot(Math.max(x, innerWidth), Math.max(y, innerHeight));
裁切可以使用 cilp-path
属性实现
clip-path: circle(r at x y)
,其中r
表示圆的半径;x
表示圆心 x 轴坐标;y
表示圆心 y 轴坐标。详细用法请参考 MDN-clip-path。
当由 light 主题切换成 dark 主题时,
clip-path: circle(r at x y) => circle(0 at x y)
当由 dark 主题切换成 light 主题时,
clip-path: circle(0 at x y) => circle(r at x y)
既然有了裁切的方式,那么裁切目标呢?上面我们分析的是裁切主题页面,那么怎么获取到这两个主题页面呢?
这里我们需要引入两个伪元素 ::view-transition-new
和 ::view-transition-old
,分别代表新旧主题视图,这里我们可以理解成我们需要进行裁切的页面。
如果需要详细了解它们原理,请参考 W3C 标准文档 。
- 当由 light 主题切换成 dark 主题时,
::view-transition-old
被裁切,::view-transition-new
不会被裁切。表示旧主题视图由半径为 r 的圆形逐渐变化成半径为 0 的圆形。
- 当由 dark 主题切换成 light 主题时,
::view-transition-new
被裁切,::view-transition-old
不会被裁切。表示新主题视图由半径为 0 的圆形逐渐变化成半径为 r 的圆形。
解决了裁切的功能点,接下来就是如何实现动画效果了。
View Transitions API 可以轻松地创建不同 DOM 状态之间的动画过渡,刚好派上用场。
使用 startViewTransition
很简单
const transition = document.startViewTransition(callback);
startViewTransition
有个实例属性 ready
,过渡动画即将开始时触发并放回 Promise
实例。
我们可以在这里面将圆形裁切的动画效果实现。
最终代码
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<button onclick="toggleTheme(event)">change</button>
</body>
</html>
* {
margin: 0;
padding: 0;
}
html {
background-color: white;
}
html.dark {
background-color: black;
}
::view-transition-new(root),
::view-transition-old(root) {
width: 100vw;
height: auto;
animation: none;
}
html.dark::view-transition-old(root) {
z-index: 9999;
}
html.dark::view-transition-new(root) {
z-index: 1;
}
html::view-transition-old(root) {
z-index: 1;
}
html::view-transition-new(root) {
z-index: 9999;
}
const buttonNode = document.querySelector("button");
let isDark = false;
function changeStyle() {
isDark = !isDark;
document.documentElement.classList.toggle("dark");
}
function toggleTheme(event) {
if (!document.startViewTransition) {
changeStyle();
return;
}
const transition = document.startViewTransition(() => changeStyle());
transition.ready.then(() => {
const { clientX: x, clientY: y } = event;
const r = Math.hypot(Math.max(x, innerWidth), Math.max(y, innerHeight));
const clipPath = [
`circle(${r}px at ${x}px ${y}px)`,
`circle(0px at ${x}px ${y}px)`,
];
document.documentElement.animate(
{
clipPath: isDark ? clipPath : clipPath.reverse(),
},
{
duration: 500,
easing: "ease-out",
pseudoElement: isDark
? "::view-transition-old(root)"
: "::view-transition-new(root)",
}
);
});
}