使用View Transitions实现Element Plus的炫酷主题切换


实验性:这是一项实验性技术

View Transitions API 提供了一种机制,可以在更新 DOM 内容的同时,轻松地创建不同 DOM 状态之间的动画过渡。同时还可以在单个步骤中更新 DOM 内容,官方描述

原理

当调用document.startViewTransition()时,API 会根据当前页面的屏幕截图,创建一个动画效果,将当前页面过渡到新的 DOM 状态。

这个startViewTransition函数会返回一个回调函数,我们需要在回调函数中更新我们的 Dom,从而产生一个动画。

使用

下方创建好了一个模板,其中包含一个按钮,点击按钮可以切换背景颜色,我们使用需要使用View Transitions API实现一个简单的圆形扩散动画效果。

<!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);
            }
            :root.dark {
                --bg-color: #000;
            }
        </style>
    </head>
    <body>
        <button id="btn">切换</button>
    </body>
    <script>
        btn.addEventListener("click", (e) => {
            document.documentElement.classList.toggle("dark");
        });
    </script>
</html>

创建一个过渡

首先,我们需要创建一个过渡,使用document.startViewTransition()函数,这个函数接收一个回调函数,我们在回调函数中更新 DOM 状态,会产生一个动画效果。

btn.addEventListener("click", (e) => {
    document.startViewTransition(() => {
        document.documentElement.classList.toggle("dark");
    });
});

这个时候会发现,页面动画产生了一个渐隐的效果,这是因为document.startViewTransition()函数默认会创建的一个效果,如果我们想要变更的话就需要对他进行修改。

修改过渡状态

首先需要拿到document.startViewTransition()产生的实例,这里使用transition来接收,过渡状态可以通过transition.ready属性来获取,它是一个Promise对象。

const transition = document.startViewTransition(() => {
    document.documentElement.classList.toggle("dark");
});

transition.ready.then(() => {
    // 实现过渡的过程
});

我们目标是要实现一个圆心扩散动画,这里可以用到clipPath来裁剪屏幕截图,设定中心点为鼠标位置,从 0 扩散到 100%,从而实现中心扩散的效果。

transition.ready.then(() => {
    // 实现过渡的过程 circle
    document.documentElement.animate(
        {
            clipPath: ["circle(0 at 50% 50%)", "circle(100% at 50% 50%)"],
        },
        {
            duration: 500,
            pseudoElement: "::view-transition-new(root)",
        }
    );
});

这时候会发现我们的动画效果并没有发生,原因是::view-transition-xxx的伪类自带的默认动画覆盖了自定义的动画,我们需要在::view-transition-new(root)伪类中,添加animation: none;来取消默认动画。

::view-transition-old(root),
::view-transition-new(root) {
    animation: none;
}

动画已经有了,这个时候来修改动画的圆心为鼠标点击位置,可以通过事件对象获取到。

transition.ready.then(() => {
    const x = e.clientX;
    const y = e.clientY;

    // 实现过渡的过程 circle
    document.documentElement.animate(
        {
            clipPath: [`circle(0 at ${x}px ${y}px)`, `circle(100% at ${x}px ${y}px)`],
        },
        {
            duration: 500,
            pseudoElement: "::view-transition-new(root)",
        }
    );
});

然而圆形的半径是固定的,我们想要实现一个圆形扩散的效果,就需要修改圆形的半径,这里可以根据勾股定理来计算。

transition.ready.then(() => {
    const x = e.clientX;
    const y = e.clientY;
    // 从点击点到窗口最远边缘的距离,这个距离即为圆的半径,用于确定一个圆形裁剪路径 (clip path) 的最大尺寸,以便覆盖整个视窗。
    // 勾股定理:a² + b² = c²
    const radius = Math.sqrt(Math.max(x, window.innerWidth - x) ** 2 + Math.max(y, window.innerHeight - y) ** 2);

    // 或是使用Math.hypot函数,所有参数的平方和的平方根,例:const radius =Math.hypot(3,4)
    // const radius = Math.hypot(Math.max(x, window.innerWidth - x) ** 2 , Math.max(y, window.innerHeight - y) ** 2);

    // 实现过渡的过程 circle
    document.documentElement.animate(
        {
            clipPath: [`circle(0 at ${x}px ${y}px)`, `circle(${radius} at ${x}px ${y}px)`],
        },
        {
            duration: 500,
            pseudoElement: "::view-transition-new(root)",
        }
    );
});

添加反向样式

此时我们发现,每次点击切换,样式变化都是从点击处往外扩散,而不是再点一次的时候,收回效果(具体查看 element plus 的样式切换)。我们需要修改clipPathpseudoElement

transition.ready.then(() => {
    const isDark = document.documentElement.classList.contains("dark");
    const x = e.clientX;
    const y = e.clientY;
    // 从点击点到窗口最远边缘的距离,这个距离即为圆的半径,用于确定一个圆形裁剪路径 (clip path) 的最大尺寸,以便覆盖整个视窗。
    // 勾股定理:a² + b² = c²
    const radius = Math.sqrt(Math.max(x, window.innerWidth - x) ** 2 + Math.max(y, window.innerHeight - y) ** 2); //一个数的平方根

    // 或是使用Math.hypot函数,所有参数的平方和的平方根,例:const radius =Math.hypot(3,4)
    // const radius = Math.hypot(Math.max(x, window.innerWidth - x) ** 2 , Math.max(y, window.innerHeight - y) ** 2);

    const clipPath = [`circle(0 at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`];
    // 实现过渡的过程 circle
    document.documentElement.animate(
        {
            clipPath: isDark ? clipPath.reverse() : clipPath,
        },
        {
            duration: 500,
            easing: "ease-in",
            pseudoElement: isDark ? "::view-transition-old(root)" : "::view-transition-new(root)",
        }
    );
});

同时给::view-transition-old(root)添加层级z-index

.dark::view-transition-old(root) {
    z-index: 9999;
}

完整代码如下:

<!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);
            }

            :root.dark {
                --bg-color: #000;
            }

            ::view-transition-old(root),
            ::view-transition-new(root) {
                animation: none;
            }

            .dark::view-transition-old(root) {
                z-index: 9999;
            }

            #btn {
                position: absolute;
                top: 10%;
                right: 50px;
                transform: translate(-50%, -50%);
            }
        </style>
    </head>

    <body>
        <button id="btn">切换</button>
    </body>
    <script>
        btn.addEventListener("click", (e) => {
            const transition = document.startViewTransition(() => {
                document.documentElement.classList.toggle("dark");
            });

            transition.ready.then(() => {
                const isDark = document.documentElement.classList.contains("dark");
                const x = e.clientX;
                const y = e.clientY;
                // 从点击点到窗口最远边缘的距离,这个距离即为圆的半径,用于确定一个圆形裁剪路径 (clip path) 的最大尺寸,以便覆盖整个视窗。
                // 勾股定理:a² + b² = c²
                const radius = Math.sqrt(Math.max(x, window.innerWidth - x) ** 2 + Math.max(y, window.innerHeight - y) ** 2); //一个数的平方根

                // 或是使用Math.hypot函数,所有参数的平方和的平方根,例:const radius =Math.hypot(3,4)
                // const radius = Math.hypot(Math.max(x, window.innerWidth - x) ** 2 , Math.max(y, window.innerHeight - y) ** 2);

                const clipPath = [`circle(0 at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`];
                // 实现过渡的过程 circle
                document.documentElement.animate(
                    {
                        clipPath: isDark ? clipPath.reverse() : clipPath,
                    },
                    {
                        duration: 500,
                        easing: "ease-in",
                        pseudoElement: isDark ? "::view-transition-old(root)" : "::view-transition-new(root)",
                    }
                );
            });
        });
    </script>
</html>

文章作者: 弈心
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 弈心 !
评论
  目录