Ethan

记录在博客中使用 view-transition-api 实现黑暗模式切换时的实现细节和遇到的问题,分享相关经验和解决方案。

博客使用 view-transition-api 添加黑暗模式
5 mins
1080 words
Loading views

最近进行博客重构,因此参照了 antfu 大神的博客的样式。

基本样式参照了其中,可是对于其中黑暗模式切换,被深深的吸引了,如下:

CleanShot 2025-01-08 at 23.32.33.gif

当时在想,这个是怎么做到的,为何次动画如此丝滑,我得研究下; 1736608357113.png

放弃了~~~

直接吧啦源码,看到如下关键信息:

::view-transition-old(view-transition-name)
::view-transition-new(view-transition-name)

印象中,这段使用的是 View Transition API 中的内容;于是乎去翻阅各种文档;发现大佬这边文章 页面级可视动画 View Transitions API 初体验,大佬深入浅出详细说明了此 API 的作用以及使用;这里不做过多的说明;

mark:View Transitions API 简化了复杂动画的实现,无需手动处理位置计算或动画控制,尤其适合页面级的场景切换和动画增强。

1736689455217.png

流程图说明h3

  1. 触发动画:用户通过调用 document.startViewTransition() 开始动画。
  2. 捕获状态快照:浏览器在 DOM 更新前后分别捕获旧状态和新状态。
  3. 生成动画:浏览器对比新旧快照的差异,生成过渡动画。
  4. 执行动画:根据 CSS 控制动画的伪元素定义的规则执行动画。
  5. 动画完成:动画结束后更新页面状态。
  6. 移除伪元素:动画伪元素被移除,最终状态呈现。

如何扩散h2

从上图显示效果而言,扩散是从一个点扩散到浏览器整体视窗;而视窗的最大半径我们可以通过鼠标点击或者 touch 事件来触发获取元素的 Element.getBoundingClientRect()

const rect = this.themeLabel?.getBoundingClientRect()

boundingClientRect 包含两个 x 和 y,代码当前相对于视窗相对位置,借用 MDN 图:

Pasted image 20250111235731.png

而此时我们需要计算由我们点击位置向外扩散圆的半径;如下图:

1736611793245.png
const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))
  • Math.hypot 计算直角三角形的斜边长度,确保动画覆盖整个视窗。
  • Math.max 确定从触发点到视窗边缘的最远距离,确保动画从中心点覆盖整个页面。

关于 innerWidthh3

innerWidth 是一个只读属性,返回窗口的文档显示区的宽度(以像素为单位)。它包括滚动条的宽度(如果有)。在计算动画扩散半径时,我们使用 innerWidth 来确定从触发点到视窗边缘的最远距离。

接下来我们定义动画绘画路径:

const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`]
  • clipPath 是一个数组,表示动画的起始和结束状态:
    • 起始状态:半径为 0 的圆(即无显示)。
    • 结束状态:覆盖整个页面的圆(最大半径)。

创建动画h3

await document.documentElement.animate(
{ clipPath: currentTheme === 'dark' ? clipPath.reverse() : clipPath },
{
duration: 350,
easing: 'ease-out',
pseudoElement: currentTheme === 'dark' ? '::view-transition-old(root)' : '::view-transition-new(root)',
}
).finished

动画执行效果

  1. 切换到深色模式
    • 动画从大圆过渡到小圆(反转 clipPath)。
    • 动画目标是 ::view-transition-new(root)
  2. 切换到浅色模式
    • 动画从小圆过渡到大圆。
    • 动画目标是 ::view-transition-new(root)

结合上面的流程图,在 view-transition 之后,使用 pseudoElement 精细控制新旧主题之间的过渡。

最终代码如下:【本次使用的是 WebComponent 进行组件的抽取】

<script>
import { themeAtom } from "~/store";
class ThemeSwitcher extends HTMLElement {
private themeLabel: HTMLLabelElement | null = null;
private themeInput: HTMLInputElement | null = null;
constructor() {
super();
this.initTheme();
}
private getSystemTheme(): "light" | "dark" {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
private initTheme(): "light" | "dark" {
const localTheme = window.localStorage.getItem("theme");
return localTheme === "auto"
? this.getSystemTheme()
: (localTheme as "light" | "dark") || "light";
}
private updateTheme(theme: "light" | "dark"): void {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
document.documentElement.setAttribute("data-theme", theme);
this.themeLabel?.classList.toggle("swap-active", theme === "light");
themeAtom.set(theme);
}
private async animateThemeTransition(x: number, y: number): Promise<void> {
const currentTheme = this.initTheme();
const radius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
);
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${radius}px at ${x}px ${y}px)`,
];
try {
await document.documentElement.animate(
{ clipPath: currentTheme === "dark" ? clipPath.reverse() : clipPath },
{
duration: 350,
easing: "ease-out",
pseudoElement:
currentTheme === "dark"
? "::view-transition-old(root)"
: "::view-transition-new(root)",
}
).finished;
} catch (error) {
console.error("Animation failed:", error);
}
}
connectedCallback(): void {
this.themeLabel = this.querySelector("label");
this.themeInput = this.querySelector("input");
if (!this.themeLabel || !this.themeInput) {
console.error("Required elements not found");
return;
}
const currentTheme = this.initTheme();
this.updateTheme(currentTheme);
this.themeInput.addEventListener("click", async (event) => {
const currentTheme = this.initTheme();
const newTheme = currentTheme === "dark" ? "light" : "dark";
try {
const transition = document.startViewTransition(async () => {
this.updateTheme(newTheme);
window.localStorage.setItem("theme", newTheme);
});
await transition.ready;
const rect = this.themeLabel?.getBoundingClientRect();
if (rect) {
await this.animateThemeTransition(rect.x, rect.y);
}
} catch (error) {
console.error("Theme switch failed:", error);
// 回退方案
this.updateTheme(newTheme);
window.localStorage.setItem("theme", newTheme);
}
});
}
disconnectedCallback(): void {
this.themeInput?.removeEventListener("click", () => {});
}
}
customElements.define("switch-theme", ThemeSwitcher);
</script>

Comments