跳到主要内容

View Transitions API 使用记录

· 阅读需 12 分钟
Skyone
科技爱好者

最近在写一个碧蓝档案学生 Pixiv 收藏数统计的网站,想要实现两个炫酷的功能:模仿碧蓝档案游戏内什亭之匣的页面切换动画和圆形扩散的明暗主题切换动画(就像 Android 版 Telegram 那样)。

本来已经把 SVG 和动画的关键帧画好了,忽然发现一个问题,我的网站是基于 Next.js App Router 的,但是 App Router 不支持监听 Router 事件,也就是说,我并不知道下一个页面什么时候完成加载,也就没办法选择合适的时机播放页面进入的动画。

一搜 Google,千篇一律全是使用 Framer Motion 或者 React Transition Group,但是我 CSS 的 @keyframes 动画已经写好了,不想再重写一遍,难道只用纯 CSS 不能实现?

正好昨天在 MDN 上看到了 View Transitions API 的介绍,这个 API 拿来做动画是真的方便,于是去 Can I use 查了一下,发现现在只有 Chrome 支持,不过也没关系,先实现再说。

经过一番尝试,终于实现了两个动画效果,本文就来介绍一下 View Transitions API 的基本用法。

明暗切换

可用性

示例网站

View Transitions API 快速入门

是什么?

简单来说,View Transitions API 是用来简化创建页面切换动画的 API,它目前只能用于 SPA (Single Page Application) 中,也就是说,只能用于页面内的切换动画。根据设计者的说法,这个 API 的目标是让开发者能够更容易地实现页面切换动画,而不需要关心页面切换的时机,同时针对 MPA (Multi Page Application) 的支持也在计划中。

View Transitions API 提供了一个新的 JavaScript API: document.startViewTransition(),以及几个新的 CSS 属性:

  • view-transition-name
  • ::view-transition
  • ::view-transition-group()
  • ::view-transition-image-pair()
  • ::view-transition-old()
  • ::view-transition-new()

其中 document.startViewTransition() 在最新的 TypeScript 中任然没有定义,我们可以使用下面的代码来定义它:

type StartViewTransitionFunc = () => void | Promise<void>;
type StartViewTransitionReturn = {
updateCallbackDone: Promise<void>;
ready: Promise<void>;
finished: Promise<void>;
}
type StartViewTransition = (func: StartViewTransitionFunc) => StartViewTransitionReturn;

declare global {
interface Document {
startViewTransition?: StartViewTransition;
}
}

可以看到,document.startViewTransition() 接受一个函数作为参数,这个函数会在页面切换的时候被调用,返回一个对象,包含了三个 Promise 对象,分别表示页面切换动画的三个阶段:updateCallbackDonereadyfinished

在调用 document.startViewTransition() 后,浏览器会立即对当前页面进行快照,并且停止全部渲染流程,这一段时间,用户看到的是当前页面的快照,而不是真实的页面。这时的页面不能响应用户的交互。startViewTransition() 接受的参数函数会在这个时候被调用,执行页面的更新操作(比如页面路由,但具体的操作是什么,完全由开发者自己决定)。

startViewTransition() 的参数函数执行完成后(如果是异步函数,会等待兑现),浏览器在后台进行一次渲染,并对新的页面进行快照,这个操作对用户是不可见的。

当旧的页面和新的页面的快照都准备好后,updateCallbackDone 的 Promise 对象会被 resolve。这时,浏览器会开始播放页面切换动画,这个动画是由开发者自己定义的 CSS 动画(也可以是 document.documentElement.animate 产生的动画)。

当动画播放完成后,finished 的 Promise 对象会被 resolve,这时,浏览器会停止播放动画,移除两个页面的快照,恢复页面的交互。

至于页面快照的位置,浏览器会在顶层( <body> 标签之上,<html> 标签之下)创建一个新的层级,这个层级的 z-index 是最高的,所以页面快照会覆盖在所有的元素之上。包含以下伪元素:

View Transition 层级

html
| ::view-transition
| | ::view-transition-group(root)
| | | ::view-transition-image-pair(root)
| | | | ::view-transition-old
| | | | | old page snapshot
| | | | ::view-transition-new
| | | | | new page snapshot
| | ::view-transition-group(custom)
| | | ::view-transition-image-pair(custom)
| | | | ::view-transition-old
| | | | | old page snapshot
| | | | ::view-transition-new
| | | | | new page snapshot
| <body>
......

可以像对普通 HTML 元素一样对这些伪元素进行样式的设置。

如何使用?

以页面切换为例,先看看下面的例子:

什亭之匣动画演示

这是一个使用 SVG 和 @keyframes 实现的页面切换动画,注意到,动画中一共有 7 个三角形的方块和一个背景图,由于 View Transitions API 会对设置了 view-transition-name 的元素进行快照,显然 <path> 元素不能离开 <svg> 元素,所以我们需要把这个 SVG 拆成 8 份分别设置动画。这样也方便我们定义每一个三角形的运动轨迹。CSS 类似下面这么写:

::view-transition-old(D1TranslateAnimation),
::view-transition-old(D2TranslateAnimation),
::view-transition-old(D3TranslateAnimation),
::view-transition-old(D4TranslateAnimation),
::view-transition-old(D5TranslateAnimation),
::view-transition-old(R1TranslateAnimation),
::view-transition-old(R2TranslateAnimation),
::view-transition-old(R3TranslateAnimation) {
--svg-width: max(100vw, 100vh / 9 * 16);
--svg-px: calc(var(--svg-width) / 1920);
transform-origin: center;
transform-box: fill-box;
animation-duration: 610ms;
animation-timing-function: linear;
}

::view-transition-old(D1TranslateAnimation) {
animation-name: d1-translate-animation;
}

::view-transition-old(D2TranslateAnimation) {
animation-name: d2-translate-animation;
}

::view-transition-old(D3TranslateAnimation) {
animation-name: d3-translate-animation;
}

::view-transition-old(D4TranslateAnimation) {
animation-name: d4-translate-animation;
}

::view-transition-old(D5TranslateAnimation) {
animation-name: d5-translate-animation;
}

::view-transition-old(R1TranslateAnimation) {
animation-name: r1-translate-animation;
}

::view-transition-old(R2TranslateAnimation) {
animation-name: r2-translate-animation;
}

::view-transition-old(R3TranslateAnimation) {
animation-name: r3-translate-animation;
}
.d1TranslateAnimation {
transform: translate(-50%, -50%) translate(calc(var(--svg-px) * -675), calc(var(--svg-px) * -246)) scale(0.4) rotate(0deg);
view-transition-name: D1TranslateAnimation;
}

.d2TranslateAnimation {
transform: translate(-50%, -50%) translate(calc(var(--svg-px) * -391), calc(var(--svg-px) * -440)) scale(0.28) rotate(-67deg);
view-transition-name: D2TranslateAnimation;
}

.d3TranslateAnimation {
transform: translate(-50%, -50%) translate(calc(var(--svg-px) * 56), calc(var(--svg-px) * -515)) scale(0.34) rotate(-52.5deg);
view-transition-name: D3TranslateAnimation;
}

.d4TranslateAnimation {
transform: translate(-50%, -50%) translate(calc(var(--svg-px) * 512), calc(var(--svg-px) * -362)) scale(0.42) rotate(-76.4deg);
view-transition-name: D4TranslateAnimation;
}

.d5TranslateAnimation {
transform: translate(-50%, -50%) translate(calc(var(--svg-px) * 153), calc(var(--svg-px) * -19)) scale(0.22) rotate(174deg);
view-transition-name: D5TranslateAnimation;
}

.r1TranslateAnimation {
transform: translate(-50%, -50%) translate(calc(var(--svg-px) * -725), calc(var(--svg-px) * 412)) scale(0.395) rotate(173.3deg);
view-transition-name: R1TranslateAnimation;
}

.r2TranslateAnimation {
transform: translate(-50%, -50%) translate(calc(var(--svg-px) * -180), calc(var(--svg-px) * 370)) scale(0.40) rotate(37deg);
view-transition-name: R2TranslateAnimation;
}

.r3TranslateAnimation {
transform: translate(-50%, -50%) translate(calc(var(--svg-px) * 415), calc(var(--svg-px) * 450)) scale(0.41) rotate(114deg);
view-transition-name: R3TranslateAnimation;
}

然后在 JavaScript 中使用 document.startViewTransition() 来实现页面切换:

const router = useRouter();
const [show, setShow] = useState(false);

const handleRoute = (target: string) => {
flushSync(() => {
setShow(true);
});
const start = performance.now();
const vt = document.startViewTransition(() => {
flushSync(() => {
router.push(target);
});
});
vt.finished.then(() => {
// 这里会有问题,后面会讲到
setShow(false);
});

return show && (<>...</>)
};

在本地调试一点问题都没有,但是线上运行,发现如果网络条件不好,会导致新的页面还没加载完毕就开始播放动画,放完页面还没变的尴尬情况。可见 Next.js 并不保证 flushSync 里的路由以及渲染完成。我们只能自己撸一套轮子。。。详见:Next.js: Add support for View Transition API #46300

最终我的解决方案和 @AaronLayton 的评论类似,由于太长了就不贴出来了,有兴趣的可以看看那位老哥的代码,已经很清晰了。

(最后我还是放弃了 View Transitions API,因为需要兼容 FireFox,我自己搞了一套,效果基本一模一样)

简单但完整的例子

一个简单的明暗模式切换,相对于使用 CSS 滤镜,这种方案不需要对页面中的图片等彩色元素进行特殊处理。

【图中奇怪的格子是录屏转 GIF 格式的问题】

example 演示

CSS 里禁用动画,使用 JavaScript 根据点击位置动态计算圆形扩散的半径,然后使用 document.documentElement.animate 播放动画。

src/app/global.css
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}

首先实现一个明暗模式切换的 Context,没什么好说的,就是一个简单的 Context。我使用 <body> 标签的 class 来切换明暗模式,所以需要在 ColorModeProvider 中设置 class,但后面的颜色就都自动适配了。

ColorModeProvider.tsx
"use client";

type ColorMode = "light" | "dark" | "system";
type CurrentColorMode = "light" | "dark";

type ColorModeContext = {
colorMode: ColorMode;
currentColorMode: CurrentColorMode;
setColorMode: (colorMode: ColorMode) => void;
toggleColorMode: () => void;
}

const ColorModeContext = createContext<ColorModeContext>(null!);

interface ColorModeProviderProps {
children: ReactNode;
}

function ColorModeProvider({children}: ColorModeProviderProps) {
const [colorMode, _setColorMode] = useState<ColorMode>("light");
const [currentColorMode, setCurrentColorMode] = useState<CurrentColorMode>("light");

const setColorMode = (colorMode: ColorMode) => {
localStorage.setItem("pattern.mode", colorMode);
if (colorMode === "light") {
document.documentElement.classList.add("light");
document.documentElement.classList.remove("dark");
} else if (colorMode === "dark") {
document.documentElement.classList.add("dark");
document.documentElement.classList.remove("light");
} else {
document.documentElement.classList.remove("light");
document.documentElement.classList.remove("dark");
}
_setColorMode(colorMode);
};
useEffect(() => {
const colorMode = localStorage.getItem("pattern.mode");
if (colorMode === "system" || colorMode === "dark") {
setColorMode(colorMode);
} else {
setColorMode("light");
}
}, []);
useEffect(() => {
if (colorMode === "system") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
if (mediaQuery.matches) {
setCurrentColorMode("dark");
} else {
setCurrentColorMode("light");
}
const listener = (e: MediaQueryListEvent) => {
if (e.matches) {
setCurrentColorMode("dark");
} else {
setCurrentColorMode("light");
}
};
mediaQuery.addEventListener("change", listener);
return () => {
mediaQuery.removeEventListener("change", listener);
};
} else {
setCurrentColorMode(colorMode);
}
}, [colorMode]);

const toggleColorMode = () => {
if (colorMode === "light") {
setColorMode("dark");
} else if (colorMode === "dark") {
setColorMode("system");
} else {
setColorMode("light");
}
};

return (
<ColorModeContext.Provider value={{colorMode, currentColorMode, setColorMode, toggleColorMode}}>
{children}
</ColorModeContext.Provider>
);
}

export default ColorModeProvider;

export function useColorMode() {
return useContext(ColorModeContext);
}

然后实现一个按钮,点击按钮时,根据点击位置计算圆形扩散的半径,然后播放动画。

很简单,就是使用 flushSync 强制 React 完成同步渲染,等待新旧页面快照就绪,然后使用 document.documentElement.animate 播放动画。

HeaderColorToggle.tsx
type StartViewTransitionFunc = () => void;
type StartViewTransitionReturn = {
updateCallbackDone: Promise<void>;
ready: Promise<void>;
finished: Promise<void>;
}
type StartViewTransition = (func: StartViewTransitionFunc) => StartViewTransitionReturn;

declare global {
interface Document {
startViewTransition: StartViewTransition;
}
}

type ColorMode = "light" | "dark" | "system";

function getColorModeIron(mode: ColorMode) {
if (mode === "light") {
return <LightModeIcon/>;
} else if (mode === "dark") {
return <DarkModeIcon/>;
} else {
return <SystemModeIcon/>;
}
}

function HeaderColorToggle() {
const {toggleColorMode, colorMode} = useColorMode();

const listener: MouseEventHandler = async (e) => {
if (!document.startViewTransition) {
toggleColorMode();
return;
}
const x = e.clientX;
const y = e.clientY;
const radius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));

const vt = document.startViewTransition(() => {
flushSync(() => {
toggleColorMode();
});
});
await vt.ready;
const frameConfig = {
clipPath: [
`circle(0 at ${x}px ${y}px)`,
`circle(${radius}px at ${x}px ${y}px)`,
],
};
const timingConfig = {
duration: 200,
pseudoElement: "::view-transition-new(root)",
};
document.documentElement.animate(frameConfig, timingConfig);
};

return (
<button onClick={listener} aria-label="切换颜色模式" title="切换颜色模式" type="button"
className={clsx("px-3 py-2 shrink-0 flex rounded cursor-pointer items-center hover:bg-bg-hover hover:text-link-hover fill-current")}>
{getColorModeIron(colorMode)}
</button>
);
}

【完】