同文档过渡

Web 功能: 视图过渡

问题

网站通常会提供对象的多个视图,例如商品列表,以及每个商品的详情页面。在两个视图之间切换时,往往会感觉不连贯。当用户点击商品缩略图查看详情时,缩略图会消失,而一张新的、更大的图片会立即出现在屏幕的其他位置。这种不连贯性使用户更难跟踪元素之间的关系。

解决方案

借助 View Transitions API,您可以指定在过渡前后以不同状态存在的元素对。在单页导航 (SPA) 中使用 document.startViewTransition() 触发过渡时,浏览器会通过共享的唯一 view-transition-name 识别这些共享元素。然后,它会自动计算它们在位置、大小和样式上的差异,并以动画形式让它们从旧状态平滑过渡到新状态。此过渡发生在顶层,甚至高于 z-index 值较高的元素。

实现指南

第 1 步:在 startViewTransition 中封装状态更改

对于单页应用 (SPA) 或简单的状态更改,请将更新 DOM 的逻辑封装在 document.startViewTransition 中。浏览器会捕获当前状态的快照,运行更新,然后捕获新状态。

function navigate(view) {
  // MANDATORY: Wrap the update in startViewTransition
  document.startViewTransition(() => updateDOM(view));
}

第 2 步:分配共享过渡名称

使用 view-transition-name CSS 属性告知浏览器应变形哪些元素。名称可以是任何内容(none 除外)。强制性要求:在具有给定 view-transition-name 的元素之前和之后,最多只能有 1 个元素。如果有 2 个或更多元素具有给定的 view-transition-name,DOM 将立即更新为新状态,而不会发生过渡。

您可以使用多个 view-transition-name 来变形多对元素。例如,您可能希望使用单独的过渡效果来过渡商品图片和商品名。

由于列表视图中有多个项,因此您无法为所有项指定相同的 view-transition-name。在 SPA 中,可以通过两种方式解决此问题。

  1. 动态详情页面:为列表页面上的每个项分配一个唯一的 view-transition-name,然后在选择列表项时,将该名称动态应用于详情页面上的匹配元素,如下所示。

    /* In the list view, give each */
    #product-1 { view-transition-name: p1 }
    #product-2 { view-transition-name: p2 }
    #product-3 { view-transition-name: p3 }
    
    function updateDOM(clickedTransitionName){
      const hero = document.getElementById("hero");
      hero.style.viewTransitionName = clickedTransitionName;
    }
    
  2. 动态列表项:为详情页面上的元素分配一个 view-transition-name,并在列表页面上的相应项被选中时,将该名称应用于该项。返回列表页面时,从列表页面上的内容中移除 view-transition-name。

详情页面上的 #hero 元素和列表页面上所选的 .thumbnail 元素共享一个 view-transition-name。

#hero{
  view-transition-name: hero;
}
.thumbnail.selected {
  view-transition-name: hero;
}

点击缩略图后,我们需要使用 .selected 类选择器分配 view-transition-name,并在开始过渡之前对 DOM 进行任何更改,从而准备好列表视图。

然后,您可以调用 document.startViewTransition(),并应用更改,将页面从详情视图转换为列表视图。

返回列表视图后,您必须清理视图过渡类,以防止下次导航出错。您可以在过渡的 finished promise 解析后执行此清理。

// Function called when a thumbnail is clicked
function goFromListToDetail(e){
  e.currentTarget.classList.add("selected");
  const hero = document.getElementById("hero");
  const bgColor = getComputedStyle(e.currentTarget).backgroundColor;
  hero.style.background = bgColor;

  // Trigger the transition, checking for support
  if (!document.startViewTransition) {
    document.body.classList.add("detail");
    // MANDATORY Accessibility Routing: Route focus to the newly revealed heading to announce context and preserve logical tab flow
    document.getElementById("detail-heading")?.focus();
    return; // MANDATORY: End function execution if view transitions are not supported.  
  }
  const transition = document.startViewTransition(() => {
    document.body.classList.add("detail");
  });
  // MANDATORY Accessibility Routing: Route focus after the view transition resolves
  transition.finished.finally(() => {
    document.getElementById("detail-heading")?.focus();
  });
}

// Function called when navigating from detail back to list view
function goFromDetailToList() {
  if (!document.startViewTransition) {
    document.body.classList.remove("detail");
    document.getElementById("list-heading")?.focus();
    return;
  }
  const transition = document.startViewTransition(() => {
    document.body.classList.remove("detail");
  });
  // Clean up the list view and route focus
  transition.finished.finally(() => {
    // Route focus back to list view
    document.getElementById("list-heading")?.focus();
    // Remove selected classList to remove view-transition-names
    document.querySelectorAll(".selected").forEach(
      (element) => {
        element.classList.remove("selected");
      },
    );
  });
}

您选择的方法取决于用例。动态列表项需要较少的重复 CSS,但需要更多手动 JavaScript 清理。

第 3 步:修正宽高比“拉伸”问题

默认情况下,浏览器会在一个拉伸以适应两者的组中交叉淡入淡出新旧快照。如果您要对文本进行过渡,请在旧视图和新视图中将文本元素的宽度设置为 fit-content,以便过渡元素的宽高比保持稳定。

#list-page .title {
  width: fit-content;
}

#detail-page #title {
  width: fit-content;
}

如果要过渡宽高比发生变化的元素,您可能需要将新旧伪元素的高度设置为 ::view-transition-pair() 伪元素的 100%。

::view-transition-old(hero),
::view-transition-new(hero){
  height: 100%;
}

伪元素是实时元素的快照,因此您还可以使用 object-fit 和 object-position 声明来更好地控制过渡效果。

最佳做法

  • 请勿指定过多过渡事件。仅对用户正在主动跟踪的主要内容(例如主打图片、标题)使用共享元素。
  • 务必在过渡完成后移除临时 view-transition-name 值,以免对未来的过渡产生副作用。
  • 请勿对具有活跃动画的元素进行过渡。视图过渡会对快照进行操作,因此在视图过渡期间,所有动画都会暂停。
  • 务必使用 prefers-reduced-motion 媒体查询,尊重用户对减少动画效果的偏好。
  • 强制性无障碍路由:视图过渡会动态改变页面布局,但不会以编程方式管理焦点。如果焦点仍停留在过渡期间隐藏或移除的元素上,焦点就会丢失,导致键盘和辅助技术用户无法了解上下文。在 DOM 更新后或视图转换的 finished promise 解析后,立即以编程方式将焦点转移到更新后的页面标题或视图容器(使用 tabindex="-1")。
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

后备策略

View Transitions API 专为渐进增强而设计。不支持该 API 的浏览器会立即执行 DOM 更新,而不会显示动画。

function navigate(){
  if (!document.startViewTransition) {
    // Fallback: Just update the DOM
    updateDOM();
  } else {
    document.startViewTransition(() => updateDOM());
  }
}