使用视图过渡 API
本文解释了视图过渡 API 的工作原理、如何创建视图过渡和自定义过渡动画,以及如何操作活动视图过渡的理论。这涵盖了单页应用 (SPA) 中 DOM 状态更新的视图过渡,以及多页应用 (MPA) 中文档之间的导航。
视图过渡过程
让我们逐步了解视图过渡的工作过程
-
视图过渡被触发。其触发方式取决于视图过渡的类型
- 对于同文档过渡 (SPA),通过将触发视图更改 DOM 更新的函数作为回调传递给
document.startViewTransition()
方法来触发视图过渡。 - 对于跨文档过渡 (MPA),通过启动到新文档的导航来触发视图过渡。导航的当前文档和目标文档都需要位于相同的源,并通过在其 CSS 中包含一个
@view-transition
at-rule,其中navigation
描述符设置为auto
来选择加入视图过渡。注意:活动的视图过渡具有关联的
ViewTransition
实例(例如,在同文档 (SPA) 过渡的情况下由startViewTransition()
返回)。ViewTransition
对象包含多个 Promise,允许你在视图过渡过程的不同部分达到时运行代码。有关更多信息,请参阅使用 JavaScript 控制视图过渡。
- 对于同文档过渡 (SPA),通过将触发视图更改 DOM 更新的函数作为回调传递给
-
在当前(旧页面)视图上,API 会捕获已声明
view-transition-name
的元素的静态图像快照。 -
视图更改发生
-
对于同文档过渡 (SPA),调用传递给
startViewTransition()
的回调,这会导致 DOM 更改。当回调成功运行后,
ViewTransition.updateCallbackDone
Promise 会实现,允许你响应 DOM 更新。 -
对于跨文档过渡 (MPA),导航发生在当前文档和目标文档之间。
-
-
API 从新视图中捕获“实时”快照(即交互式 DOM 区域)。
此时,视图过渡即将运行,
ViewTransition.ready
Promise 会实现,允许你通过运行自定义 JavaScript 动画(而不是默认动画)来响应,例如。 -
旧页面快照“淡出”,新视图快照“淡入”。默认情况下,旧视图快照从
opacity
1 动画到 0,新视图快照从opacity
0 动画到 1,从而创建交叉淡入淡出效果。 -
当过渡动画达到其结束状态时,
ViewTransition.finished
Promise 会实现,允许你响应。
注意:如果在 document.startViewTransition()
调用期间,文档的 页面可见性状态 为 hidden
(例如,如果文档被窗口遮挡、浏览器最小化或另一个浏览器选项卡处于活动状态),则视图过渡将完全跳过。
关于快照的题外话
值得注意的是,在谈论视图过渡时,我们通常使用术语快照来指代页面中声明了 view-transition-name
的部分。这些部分将与页面上设置了不同 view-transition-name
值的其他部分分开进行动画处理。虽然通过视图过渡对快照进行动画处理实际上涉及两个单独的快照——一个旧 UI 状态的快照和一个新 UI 状态的快照——但为简单起见,我们使用快照来指代整个页面区域。
旧 UI 状态的快照是静态图像,因此用户无法在它“淡出”时与之交互。
新 UI 状态的快照是交互式 DOM 区域,因此用户可以在新内容“淡入”时开始与之交互。
视图过渡伪元素树
为了处理创建出站和入站过渡动画,API 构建了一个具有以下结构的伪元素树
::view-transition └─ ::view-transition-group(root) └─ ::view-transition-image-pair(root) ├─ ::view-transition-old(root) └─ ::view-transition-new(root)
对于同文档过渡 (SPA),伪元素树在文档中可用。对于跨文档过渡 (MPA),伪元素树仅在目标文档中可用。
树结构中最有趣的部分如下
-
::view-transition
是视图过渡叠加层的根,它包含所有视图过渡组并位于所有其他页面内容之上。 -
::view-transition-group()
作为每个视图过渡快照的容器。root
参数指定默认快照 — 视图过渡动画将应用于view-transition-name
为root
的快照。默认情况下,这是:root
元素的快照,因为默认浏览器样式定义了这一点css:root { view-transition-name: root; }
但请注意,页面作者可以通过取消上述设置并在不同元素上设置
view-transition-name: root
来更改此设置。 -
::view-transition-old()
目标是旧页面元素的静态快照,而::view-transition-new()
目标是新页面元素的实时快照。这两者都作为替换内容呈现,方式与<img>
或<video>
相同,这意味着它们可以使用object-fit
和object-position
等属性进行样式设置。
注意:可以通过在不同的 DOM 元素上设置不同的 view-transition-name
来使用不同的自定义视图过渡动画来定位它们。在这种情况下,会为每个元素创建一个 ::view-transition-group()
。有关示例,请参阅不同元素的动画不同。
注意:正如你稍后将看到的,要自定义出站和入站动画,你需要分别使用动画来定位 ::view-transition-old()
和 ::view-transition-new()
伪元素。
创建基本的视图过渡
本节演示了如何创建基本的视图过渡,包括 SPA 和 MPA 的情况。
基本 SPA 视图过渡
SPA 可能包括响应某种事件(例如导航链接被点击或服务器推送更新)来获取新内容并更新 DOM 的功能。
我们的 视图过渡 SPA 演示 是一个基本的图像库。我们有一系列包含缩略图 <img>
元素的 <a>
元素,使用 JavaScript 动态生成。我们还有一个包含 <figcaption>
和 <img>
的 <figure>
元素,用于显示全尺寸图库图像。
当点击缩略图时,displayNewImage()
函数通过 Document.startViewTransition()
运行,这会导致全尺寸图像及其关联的标题显示在 <figure>
中。我们已将其封装在一个 updateView()
函数中,该函数仅在浏览器支持 View Transition API 时才调用它
function updateView(event) {
// Handle the difference in whether the event is fired on the <a> or the <img>
const targetIdentifier = event.target.firstChild || event.target;
const displayNewImage = () => {
const mainSrc = `${targetIdentifier.src.split("_th.jpg")[0]}.jpg`;
galleryImg.src = mainSrc;
galleryCaption.textContent = targetIdentifier.alt;
};
// Fallback for browsers that don't support View Transitions:
if (!document.startViewTransition) {
displayNewImage();
return;
}
// With View Transitions:
const transition = document.startViewTransition(() => displayNewImage());
}
此代码足以处理显示图像之间的过渡。支持的浏览器会将旧图像和新图像以及标题的更改显示为平滑的交叉淡入淡出(默认视图过渡)。它仍然可以在不支持的浏览器中工作,但没有漂亮的动画。
基本 MPA 视图过渡
在创建跨文档 (MPA) 视图过渡时,过程甚至比 SPA 更简单。不需要 JavaScript,因为视图更新是由跨文档、同源导航触发的,而不是由 JavaScript 启动的 DOM 更改触发的。要启用基本的 MPA 视图过渡,你需要在当前文档和目标文档的 CSS 中指定 @view-transition
at-rule 以选择加入,如下所示
@view-transition {
navigation: auto;
}
我们的 视图过渡 MPA 演示 展示了此 at-rule 的实际应用,并额外演示了如何自定义视图过渡的出站和入站动画。
注意:目前 MPA 视图过渡只能在同源文档之间创建,但此限制可能会在未来的实现中放宽。
自定义动画
视图过渡伪元素应用了默认的 CSS 动画(在其参考页面中有详细说明)。
如上所述,大多数外观过渡都具有默认的平滑交叉淡入淡出动画。有一些例外
height
和width
过渡应用了平滑缩放动画。position
和transform
过渡应用了平滑移动动画。
你可以使用常规 CSS 以任何你想要的方式修改默认动画 — 使用 ::view-transition-old()
针对“from”动画,使用 ::view-transition-new()
针对“to”动画。
例如,要更改两者的速度
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.5s;
}
建议你在想要将样式应用于 ::view-transition-old()
和 ::view-transition-new()
的情况下,使用 ::view-transition-group()
来定位这些样式。由于伪元素层次结构和默认的用户代理样式,这些样式将被两者继承。例如
::view-transition-group(root) {
animation-duration: 0.5s;
}
注意:这也是一种保护代码的好选择——::view-transition-group()
也会动画,你可能会得到 group
/image-pair
伪元素与 old
和 new
伪元素的不同持续时间。
对于跨文档 (MPA) 过渡,伪元素需要仅包含在目标文档中才能使视图过渡工作。如果你想在两个方向上都使用视图过渡,则需要将它包含在两者中。
我们的 视图过渡 MPA 演示 包含了上述 CSS,但将自定义更进一步,定义了自定义动画并将其应用于 ::view-transition-old(root)
和 ::view-transition-new(root)
伪元素。结果是,当导航发生时,默认的交叉淡入淡出过渡被替换为“向上滑动”过渡
/* Create a custom animation */
@keyframes move-out {
from {
transform: translateY(0%);
}
to {
transform: translateY(-100%);
}
}
@keyframes move-in {
from {
transform: translateY(100%);
}
to {
transform: translateY(0%);
}
}
/* Apply the custom animation to the old and new page states */
::view-transition-old(root) {
animation: 0.4s ease-in both move-out;
}
::view-transition-new(root) {
animation: 0.4s ease-in both move-in;
}
不同元素的动画不同
默认情况下,视图更新期间更改的所有不同元素都使用相同的动画进行过渡。如果你希望某些元素与默认的 root
动画不同地进行动画处理,你可以使用 view-transition-name
属性将它们分开。例如,在我们的 视图过渡 SPA 演示 中,<figcaption>
元素被赋予 figure-caption
的 view-transition-name
,以便在视图过渡方面将它们与页面的其余部分分开
figcaption {
view-transition-name: figure-caption;
}
应用此 CSS 后,生成的伪元素树现在将如下所示
::view-transition ├─ ::view-transition-group(root) │ └─ ::view-transition-image-pair(root) │ ├─ ::view-transition-old(root) │ └─ ::view-transition-new(root) └─ ::view-transition-group(figure-caption) └─ ::view-transition-image-pair(figure-caption) ├─ ::view-transition-old(figure-caption) └─ ::view-transition-new(figure-caption)
第二组伪元素的存在允许仅对 <figcaption>
应用单独的视图过渡样式。不同的旧视图和新视图捕获彼此独立处理。
以下代码仅对 <figcaption>
应用自定义动画
@keyframes grow-x {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes shrink-x {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
::view-transition-group(figure-caption) {
height: auto;
right: 0;
left: auto;
transform-origin: right center;
}
::view-transition-old(figure-caption) {
animation: 0.25s linear both shrink-x;
}
::view-transition-new(figure-caption) {
animation: 0.25s 0.25s linear both grow-x;
}
在这里,我们创建了一个自定义 CSS 动画,并将其应用于 ::view-transition-old(figure-caption)
和 ::view-transition-new(figure-caption)
伪元素。我们还为两者添加了许多其他样式,以使它们保持在相同的位置,并阻止默认样式干扰我们的自定义动画。
注意:你可以使用 *
作为伪元素中的标识符来定位所有快照伪元素,无论它们具有什么名称。例如
::view-transition-group(*) {
animation-duration: 2s;
}
有效的 view-transition-name
值
view-transition-name
属性可以采用唯一的 <custom-ident>
值,它可以是任何不会被误解为关键字的标识符。每个渲染元素的 view-transition-name
值必须是唯一的。如果两个渲染元素同时具有相同的 view-transition-name
,ViewTransition.ready
将被拒绝,并且过渡将被跳过。
它还可以采用关键字值
none
:使元素不参与单独的快照,除非它有一个设置了view-transition-name
的父元素,在这种情况下,它将作为该元素的一部分进行快照。match-element
:自动为所有选定元素设置唯一的view-transition-name
值。
利用默认动画样式
请注意,我们还发现了另一种更简单且比上述方法产生更好结果的过渡选项。我们最终的 <figcaption>
视图过渡最终看起来像这样
figcaption {
view-transition-name: figure-caption;
}
::view-transition-group(figure-caption) {
height: 100%;
}
这之所以有效,是因为默认情况下,::view-transition-group()
会以平滑缩放方式在旧视图和新视图之间过渡 width
和 height
。我们只需要为两种状态设置固定的 height
即可使其工作。
注意:使用视图过渡 API 实现平滑过渡 包含其他几个自定义示例。
使用 JavaScript 控制视图过渡
视图过渡具有关联的 ViewTransition
对象实例,其中包含多个 Promise 成员,允许你响应过渡的不同状态到达时运行 JavaScript。例如,一旦创建了伪元素树并且动画即将开始,ViewTransition.ready
就会实现,而一旦动画完成并且新的页面视图对用户可见和可交互,ViewTransition.finished
就会实现。
可以这样访问 ViewTransition
- 对于同文档 (SPA) 过渡,
document.startViewTransition()
方法返回与过渡关联的ViewTransition
。 - 对于跨文档 (MPA) 过渡
- 当文档由于导航而即将卸载时,会触发
pageswap
事件。其事件对象 (PageSwapEvent
) 通过PageSwapEvent.viewTransition
属性提供对ViewTransition
的访问,以及通过PageSwapEvent.activation
提供包含导航类型以及当前和目标文档历史条目的NavigationActivation
。注意:如果导航在重定向链中的任何位置都有跨域 URL,则
activation
属性返回null
。 - 当文档首次渲染时(无论是从网络加载新文档还是激活文档(从 回退/前进缓存 (bfcache) 或 预渲染)),都会触发
pagereveal
事件。其事件对象 (PageRevealEvent
) 通过PageRevealEvent.viewTransition
属性提供对ViewTransition
的访问。
- 当文档由于导航而即将卸载时,会触发
让我们看一些示例代码,以展示如何使用这些功能。
JavaScript 驱动的自定义同文档 (SPA) 过渡
以下 JavaScript 可用于创建从用户点击时鼠标光标位置发出的圆形显示视图过渡,动画由 Web Animations API 提供。
// Store the last click event
let lastClick;
addEventListener("click", (event) => (lastClick = event));
function spaNavigate(data) {
// Fallback for browsers that don't support this API:
if (!document.startViewTransition) {
updateTheDOMSomehow(data);
return;
}
// Get the click position, or fallback to the middle of the screen
const x = lastClick?.clientX ?? innerWidth / 2;
const y = lastClick?.clientY ?? innerHeight / 2;
// Get the distance to the furthest corner
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
);
// Create a transition:
const transition = document.startViewTransition(() => {
updateTheDOMSomehow(data);
});
// Wait for the pseudo-elements to be created:
transition.ready.then(() => {
// Animate the root's new view
document.documentElement.animate(
{
clipPath: [
`circle(0 at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: "ease-in",
// Specify which pseudo-element to animate
pseudoElement: "::view-transition-new(root)",
},
);
});
}
此动画还需要以下 CSS,以关闭默认 CSS 动画并阻止旧视图和新视图状态以任何方式混合(新状态“擦拭”旧状态,而不是过渡进入)
::view-transition-image-pair(root) {
isolation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
display: block;
}
JavaScript 驱动的自定义跨文档 (MPA) 过渡
Chrome DevRel 团队成员列表 演示提供了一组基本的团队资料页面,并演示了如何使用 pageswap
和 pagereveal
事件根据“from”和“to”URL 自定义跨文档视图过渡的出站和入站动画。
pageswap
事件监听器如下所示。这会在链接到资料页面的出站页面上的元素上设置视图过渡名称。当从主页导航到资料页面时,自定义动画仅针对每次点击的链接元素提供。
window.addEventListener("pageswap", async (e) => {
// Only run this if an active view transition exists
if (e.viewTransition) {
const currentUrl = e.activation.from?.url
? new URL(e.activation.from.url)
: null;
const targetUrl = new URL(e.activation.entry.url);
// Going from profile page to homepage
// ~> The big img and title are the ones!
if (isProfilePage(currentUrl) && isHomePage(targetUrl)) {
// Set view-transition-name values on the elements to animate
document.querySelector(`#detail main h1`).style.viewTransitionName =
"name";
document.querySelector(`#detail main img`).style.viewTransitionName =
"avatar";
// Remove view-transition-names after snapshots have been taken
// Stops naming conflicts resulting from the page state persisting in BFCache
await e.viewTransition.finished;
document.querySelector(`#detail main h1`).style.viewTransitionName =
"none";
document.querySelector(`#detail main img`).style.viewTransitionName =
"none";
}
// Going to profile page
// ~> The clicked items are the ones!
if (isProfilePage(targetUrl)) {
const profile = extractProfileNameFromUrl(targetUrl);
// Set view-transition-name values on the elements to animate
document.querySelector(`#${profile} span`).style.viewTransitionName =
"name";
document.querySelector(`#${profile} img`).style.viewTransitionName =
"avatar";
// Remove view-transition-names after snapshots have been taken
// Stops naming conflicts resulting from the page state persisting in BFCache
await e.viewTransition.finished;
document.querySelector(`#${profile} span`).style.viewTransitionName =
"none";
document.querySelector(`#${profile} img`).style.viewTransitionName =
"none";
}
}
});
注意:我们在每次拍摄快照后删除 view-transition-name
值。如果我们保留它们设置,它们将保留在导航时保存到 bfcache 的页面状态中。如果然后按下返回按钮,则导航回的页面的 pagereveal
事件处理程序将尝试在不同的元素上设置相同的 view-transition-name
值。如果多个元素设置了相同的 view-transition-name
,则视图过渡将被跳过。
pagereveal
事件监听器如下所示。这与 pageswap
事件监听器类似,但请记住,这里我们正在为新页面上的页面元素自定义“to”动画。
window.addEventListener("pagereveal", async (e) => {
// If the "from" history entry does not exist, return
if (!navigation.activation.from) return;
// Only run this if an active view transition exists
if (e.viewTransition) {
const fromUrl = new URL(navigation.activation.from.url);
const currentUrl = new URL(navigation.activation.entry.url);
// Went from profile page to homepage
// ~> Set VT names on the relevant list item
if (isProfilePage(fromUrl) && isHomePage(currentUrl)) {
const profile = extractProfileNameFromUrl(fromUrl);
// Set view-transition-name values on the elements to animate
document.querySelector(`#${profile} span`).style.viewTransitionName =
"name";
document.querySelector(`#${profile} img`).style.viewTransitionName =
"avatar";
// Remove names after snapshots have been taken
// so that we're ready for the next navigation
await e.viewTransition.ready;
document.querySelector(`#${profile} span`).style.viewTransitionName =
"none";
document.querySelector(`#${profile} img`).style.viewTransitionName =
"none";
}
// Went to profile page
// ~> Set VT names on the main title and image
if (isProfilePage(currentUrl)) {
// Set view-transition-name values on the elements to animate
document.querySelector(`#detail main h1`).style.viewTransitionName =
"name";
document.querySelector(`#detail main img`).style.viewTransitionName =
"avatar";
// Remove names after snapshots have been taken
// so that we're ready for the next navigation
await e.viewTransition.ready;
document.querySelector(`#detail main h1`).style.viewTransitionName =
"none";
document.querySelector(`#detail main img`).style.viewTransitionName =
"none";
}
}
});
稳定页面状态以使跨文档过渡保持一致
在运行跨文档过渡之前,你理想地希望等待页面状态稳定下来,依靠 渲染阻塞 来确保
- 关键样式已加载并应用。
- 关键脚本已加载并运行。
- 用户初始视图可见的 HTML 已解析,因此它会一致地渲染。
默认情况下,样式是渲染阻塞的,除非它们通过脚本动态添加到文档中。脚本和动态添加的样式都可以使用 blocking="render"
属性进行渲染阻塞。
为了确保你的初始 HTML 已解析并在过渡动画运行之前始终一致地渲染,你可以使用 <link rel="expect">
。在此元素中,你包含以下属性
rel="expect"
表示你想要使用此<link>
元素来渲染阻塞页面上的某些 HTML。href="#element-id"
表示你想要渲染阻塞的元素的 ID。blocking="render"
用于渲染阻塞指定的 HTML。
注意:为了阻塞渲染,具有 blocking="render"
的 script
、link
和 style
元素必须位于文档的 head
中。
让我们通过一个 HTML 文档示例来探讨这是什么样子
<!doctype html>
<html lang="en">
<head>
<!-- This will be render-blocking by default -->
<link rel="stylesheet" href="style.css" />
<!-- Marking critical scripts as render blocking will
ensure they're run before the view transition is activated -->
<script async src="layout.js" blocking="render"></script>
<!-- Use rel="expect" and blocking="render" to ensure the
#lead-content element is visible and fully parsed before
activating the transition -->
<link rel="expect" href="#lead-content" blocking="render" />
</head>
<body>
<h1>Page title</h1>
<nav>...</nav>
<div id="lead-content">
<section id="first-section">The first section</section>
<section>The second section</section>
</div>
</body>
</html>
结果是文档渲染被阻塞,直到解析了主要内容 <div>
,确保了一致的视图过渡。
你还可以在 <link rel="expect">
元素上指定 media
属性。例如,你可能希望在窄屏设备上加载页面时阻塞较少内容的渲染,而不是在宽屏设备上。这是有道理的——在移动设备上,页面首次加载时可见的内容比在桌面设备上少。
这可以通过以下 HTML 实现
<link
rel="expect"
href="#lead-content"
blocking="render"
media="screen and (width > 640px)" />
<link
rel="expect"
href="#first-section"
blocking="render"
media="screen and (width <= 640px)" />