【译】视图过渡 API 和令人着迷的 UI 动画(第 1 部分)

avatar

原文地址,www.smashingmagazine.com/2023/12/vie…

动画是网站的重要组成部分,通过吸引注意力,引导用户完成用户旅程,提供令人满意和有意义的交互反馈,增加个性化,使得网站出类拔萃。

从 2009年开始,CSS提供过渡和基于关键帧的动画,不仅如此,Web动画和JS动画库,例如GSAP,被广泛使用在构建复杂和精细的动画。

有些这些之后,你可能会思考哪里才是视图过渡API的用武之地。考虑以下包含三列的任务列表例子。

screencapture-player-vimeo-video-896836367-2024-06-04-14_23_00.png

视频

这是一个复杂的基于状态的动画,正是视图过渡API被设计用来解决的问题。如果没有这个API,我们需要同时保存新旧的状态对象来展示DOM,这正是复杂性所在。在DOM中处理和保存状态可能十分困难,而且使用其他API或者JS动画库也一定更加容易。

如果这还不够令人生畏,请记住JavaScript在Web上是最昂贵的资源。过渡动画取决于所选择的动画库,这些动画库在执行前都必须完成加载和解析。换句话说,这样的过渡动画在构建、可访问性、可维护性和性能上都非常昂贵。你不会因为质疑动画成本能否值得而被责备。

但是,如果我们能把额外依赖都留在门口,只依赖JS和CSS?我们可以让浏览器来完成繁重的工作,同时完全控制状态之间的转换。这就是视图过渡API的价值,也是我们需要它的原因,减少满足流行效果的额外开销。

这听起来可能简单,但我们不必担心很多繁重的工作,所有我们需要做的,就是关注DOM的更新和动画样式。API还允许我们利用单独的状态,让我们使用CSS动画速记属性以及其他各个属性来控制动画。

浏览器支持和标准状态

在本文中,我们将深入探讨视图过渡API,通过构建3个有趣的例子来发掘其潜力。

但在这之前,值得重申的是,视图过渡API标准处于推荐候选快照状态。这意味着CSS工作组已经发布了草案,W3C已经对其广泛审查,并打算成为正式的W3C标准。在此之前,该标准仍将是候选建议,并处于反馈期。

所以,视图过渡API还没有准备迎接黄金阶段,最新版本的Chrome、 Edge 、 Opera 和 Android 浏览器均支持,Safari 对此持积极态度,并且 Firefox 有一个开放的投票。我们必须等待最后两个浏览器正式支持该 API,才能在生产环境下使用。

当我们讨论视图过渡 API 时,我们会发现起初被称作“共享元素过渡 API”,所以仍能看到一些21 年或 22 年的旧的文章,使用这些名称。

示例 1 交叉淡入淡出 UI 状态更新

让我们先从一个相对简单而有趣的例子开始,涉及卡片组件网格,单击卡片的图像以一种灯箱或模态的方式扩展图像,无需离开当前页面

<aside id="js-overlay" class="overlay">
  <div id="js-overlay-target" class="overlay__inner"></div>
</aside>
<main>
  <figure style="--color: #dfe7fd" onclick="toggleImageView(1)">
    <div>
      <img id="js-gallery-image-1" class="gallery__image" src="https://images.unsplash.com/photo-1581260466152-d2c0303e54f5?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1336&q=70" />
    </div>
    <figcaption>Peyto Lake, Canada</figcaption>
  </figure>

  <figure style="--color: #f8ad9d" onclick="toggleImageView(2)">
    <div>
      <img id="js-gallery-image-2" class="gallery__image" src="https://images.unsplash.com/photo-1573057454928-f6eda06245ed?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1336&q=70" />
    </div>
    <figcaption>Two Jack Lake, Canada</figcaption>
  </figure>

  <figure style="--color: #d8e2dc" onclick="toggleImageView(3)">
    <div>
      <img id="js-gallery-image-3" class="gallery__image" src="https://images.unsplash.com/photo-1571369985645-7859660074fa?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1336&q=70" />
    </div>
    <figcaption>Crail cottage in Mooroolbark, Australia</figcaption>
  </figure>

  <figure style="--color: #c1d3fe" onclick="toggleImageView(4)">
    <div>
      <img id="js-gallery-image-4" class="gallery__image" src="https://images.unsplash.com/photo-1611029238634-0a9f540a94fd?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1336&q=70" />
    </div>
    <figcaption>Icefields Parkway, Canada</figcaption>
  </figure>

  <figure style="--color: #d0f4de" onclick="toggleImageView(5)">
    <div>
      <img id="js-gallery-image-5" class="gallery__image" src="https://images.unsplash.com/photo-1518495973542-4542c06a5843?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1336&q=70" />
    </div>
    <figcaption>Sunlight passing through the tree</figcaption>
  </figure>

  <figure style="--color: #7bf1a8" onclick="toggleImageView(6)">
    <div>
      <img id="js-gallery-image-6" class="gallery__image" src="https://images.unsplash.com/photo-1433086966358-54859d0ed716?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1336&q=70" />
    </div>
    <figcaption>Bridge over a green waterfall</figcaption>
  </figure>
</main>

<aside style="display: none" id="js-banner" class="banner">
  <div class="banner__inner">
    This browser currently doesn't support View Transitions API. Use Chrome 111 or newer version to view the
    example.
  </div>
</aside>

main元素作为网格容器,其中包含一系列样式为卡片组件,CodePen

const overlayWrapper = document.getElementById("js-overlay");
const overlayContent = document.getElementById("js-overlay-target");

function toggleImageView(index) {
  // Get the image element by ID.
  const image = document.getElementById(`js-gallery-image-${index}`);

  // Store image parent element.
  const imageParentElement = image.parentElement;

  // Move image node from grid to modal.
  moveImageToModal(image);

  // Create a click listener on the overlay for the active image element.
  overlayWrapper.onclick = function () {
    // Return the image to its parent element
    moveImageToGrid(imageParentElement);
  };
}

// Helper functions for moving the image around and toggling the overlay.
function moveImageToModal(image) {
  // Show the overlay
  overlayWrapper.classList.add("overlay--active");
  overlayContent.append(image);
}

function moveImageToGrid(imageParentElement) {
  imageParentElement.append(overlayContent.querySelector("img"));
  // Hide the overlay.
  overlayWrapper.classList.remove("overlay--active");
}

单击卡片时,我们将图像元素从网格标记移动到叠加层,使得容器节点留空。我们还设置 onclick 事件,移动图像元素返回原来的容器,同时修改叠加层的可见 CSS。

需要注意的是,我们移动图像元素从main 容器中包含figure到具有表示灯箱叠加效果的 aside 元素中,所以我们在main 和 aside 中有相同的DOM 元素。

我们已经完成标记、基础样式和JS 函数功能,我们将使用视图过渡 API 创建第一个状态转换。我们调用document.startViewTransition并传递 callback,该函数通过将图像从 main 传递到 aside 。

// Fallback
if (!document.startViewTransition) {
  doSomething(/*...*/);
  return;
}

// Use View Transitions API
document.startViewTransition(() => doSomething( /*...*/ ));

让我们看一下 toggleImageView 函数并实现视图过渡 API 。 moveImageToModal 和 moveImageToGrid 是更新 DOM 的函数。我们所要做的就是将它们作为 startViewTransition 函数的回调。


function toggleImageView(index) {
  const image = document.getElementById(`js-gallery-image-${index}`);

  const imageParentElement = image.parentElement;

  if (!document.startViewTransition) {
    // Fallback if View Transitions API is not supported.
    moveImageToModal(image);
  } else {
    // Start transition with the View Transitions API.
    document.startViewTransition(() => moveImageToModal(image));
  }

  // Overlay click event handler setup.
  overlayWrapper.onclick = function () {
    // Fallback if View Transitions API is not supported.
    if (!document.startViewTransition) {
      moveImageToGrid(imageParentElement);
      return;
    }
 
    // Start transition with the View Transitions API.
    document.startViewTransition(() => moveImageToGrid(imageParentElement));
  };
}

我们得到一个简洁的淡入淡出动画。只需要将 DOM 更新函数作为回调传递给startViewTransition

在 CSS 中命名过渡元素

当我们调用startViewTransition函数时,API对旧的页面状态截取快照并执行 DOM 更新。当更新完成时,将截取新的页面状态。需要指出的是,转换过程是通过 CSS生成的,而不是实际的 DOM 元素。这可以避免潜在的可访问性和可用性问题。

默认情况,视图过渡API将在旧(淡出)和新(淡入)状态之间执行交叉淡入淡出动画。

1-view-transitions-api-cross-fade-animation.jpg

我们只是在两种状态之间交叉淡入淡出,其中包括所有元素,但是API并不知道从容器移动到叠加的是同一个元素。

我们需要告诉浏览器在状态切换时特别关注图像元素,这时我们可以创建一个仅用于该元素的过渡动画。CSS 属性 view-transition-name 将应用于过渡元素的视图过渡的名称,并指示浏览器在应用过渡时追踪元素的大小和位置。

我们可以随心所欲地命名过渡动画,让我们来试一试active-image,通过.gallery__image--active这个装饰器类。

.gallery__image--active {
  view-transition-name: active-image;
}

需要注意的是,view-transition-name必须是唯一标识符,在动画期间仅应用在单个渲染元素,这就是我们将该属性应用于活动图像元素的原因。我们可以在图像叠加关闭时移除该装饰器类,将图像返回其原始位置,并准备好将视图过渡应用到另一个图像,不必担心视图过渡是否已应用于页面上的另一个元素。

因此,我们有一个类.gallery__image--active,用来接受视图转换的图像,需要一种方法,当用户点击图像时应用该类,还可以通过将存储过渡对象等待动画完成时,调用 await finished 该属性关闭类和一些清理工作

// Start the transition and save its instance in a variable
const transition = document.startViewTransition(() =>l /* ... */);

// Wait for the transition to finish.
await transition.finished;

/* Cleanup after transition has completed */

应用到例子如下所示

function toggleImageView(index) {
  const image = document.getElementById(`js-gallery-image-${index}`);
  
  // Apply a CSS class that contains the view-transition-name before the animation starts.
  image.classList.add("gallery__image--active");

  const imageParentElement = image.parentElement;
  
  if (!document.startViewTransition) {
    // Fallback if View Transitions API is not supported.
    moveImageToModal(image);
  } else {
    // Start transition with the View Transitions API.
    document.startViewTransition(() => moveImageToModal(image));
  }

  // This click handler function is now async.
  overlayWrapper.onclick = async function () {
    // Fallback if View Transitions API is not supported.
    if (!document.startViewTransition) {
      moveImageToGrid(imageParentElement);
      return;
    }

    // Start transition with the View Transitions API.
    const transition = document.startViewTransition(() => moveImageToGrid(imageParentElement));
    
    // Wait for the animation to complete.
    await transition.finished;
    
    // Remove the class that contains the page-transition-tag after the animation ends.
    image.classList.remove("gallery__image--active");
  };
}

也可以通过 JS 在内联 HTML 中切换 CSS 的view-transition-name属性,但是建议把所有内容都保留在 CSS 中,因为你可能想使用媒体查询或特性查询来创建回退并在一个地方管理所有内容。

// Applies view-transition-name to the image
image.style.viewTransitionName = "active-image";

// Removes view-transition-name from the image
image.style.viewTransitionName = "none";

差不多就是这个样子了,看下实际的效果,codePen

看起来好多了,不是么?通过几行额外的 CSS 和 JS,我们设法在两种状态间创建了复杂的过渡,否则可能花费数个小时。

自定义 CSS 动画中的持续时间和缓动

我们刚才看到的是默认是体验,事实上能做的远不止在两种状态之间交叉淡入淡出的过渡。例如,正如对类似 CSS 动画内容一样,可以配置持续时间和缓动函数。

利用 CSS 的 animation 属性,可以用它们完全定制过渡行为。区别在于声明的不同,记住的是,视图过渡不是 DOM 的一部分,如果不存在的话,我们如何在 CSS 选择呢?

当执行 startViewTransition 时,API 会暂停渲染,捕获新的页面状态,并创建伪类,如下所示

::view-transition
└─ ::view-transition-group(root)
   └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)
      └─ ::view-transition-new(root)

每个部分都用来定义过渡动画的不同部分

  • ::view-transition,是根元素,可以被认为是过渡动画的 body 元素。不同的是,这个伪元素包含在一个覆盖层,位于其他内容之上。
  • ::view-transition-group,反应新旧状态之间的大小和位置
  • ::view-transition-image-pair,::view-transition-group的唯一子项,提供一个容器,用来隔离新旧过渡状态的快照之间的混合,这些转换状态是直接子项
  • ::view-transition-old旧过渡状态的快照
  • ::view-transition-new新过渡状态的快照

有很多活动部件,其目的是在选择过渡的特定方面提供尽可能的灵活性

还记得我们在上文的CSS类中使用view-transition-name,在后台生成了伪元素树,我们可是使用伪元素来定位 active-image 过渡元素或者页面上具有该值的 root 元素。

::view-transition
├─ ::view-transition-group(root)
│  └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ ::view-transition-group(active-image)
   └─ ::view-transition-image-pair(active-image)
      ├─ ::view-transition-old(active-image)
      └─ ::view-transition-new(active-image)

在这个例子中,我们想要修改交叉淡入淡出和过渡元素,可以使用通配符*选择器和伪元素一起使用,以更改所有可用过渡元素的动画属性,并使用 page-trasition-tag 值更改特定动画的目标伪元素。

/* Apply these styles only if API is supported */
@supports (view-transition-name: none) {
  /* Cross-fade animation */
  ::view-transition-image-pair(root) {
    animation-duration: 400ms;
    animation-timing-function: ease-in-out;
  }

  /* Image size and position animation */
  ::view-transition-group(active-image) {
    animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
  }
}

应对不支持的浏览器

到目前为止,我们已经在代码中检查是否支持,如下所示

// etc.

// Move the image from the grid container to the overlay.
if (!document.startViewTransition) {
  // Fallback if View Transitions API is not supported.
  moveImageToModal(image);
} else {
  // Start transition with the View Transitions API.
  document.startViewTransition(() => moveImageToModal(image));
}

// Move the image back to the grid container.
overlayWrapper.onclick = async function () {
  // Fallback if View Transitions API is not supported.
  if (!document.startViewTransition) {
    moveImageToGrid(imageParentElement);
    return;
  }
}

// etc.

让我们来分解一下,掌握它是怎么工作的。首先,我们检测 JS 中的特性检测,去检查startViewTransition是否在 document 对象中

// Fallback
if (!document.startViewTransition) {
  doSomething(/*...*/);
  return;
}

// Use View Transitions API (Arrow functions).
document.startViewTransition(() => doSomething(/*...*/));

然后在 CSS 中,可是使用@support s 根据浏览器支持某个功能做有条件应用样式,也就是说当功能支持时可以应用,当不支持时,也可以应用对应的样式

@supports (view-transition-name: none) {
  /* View Transitions API is supported */
  /* Use the View Transitions API styles */
}

@supports not (view-transition-name: none) {
  /* View Transitions API is not supported */
  /* Use a simple CSS animation if possible */
}

@supports 也是较新的功能,在旧版本的浏览器中进行检测有可能不支持,这时可以用 JS 应用类

if("startViewTransition" in document) {
  document.documentElement.classList.add("view-transitions-api");
}

现在我们可以在添加到 html 元素的条件下,view-transitions-api设置 CSS 中的过渡样式

/* View Transitions API is supported */
html.view-transitions-api {}

/* View Transitions API is not supported */
html:not(.view-transitions-api) {}

如果不支持该 API,则会渲染为如下内容

可访问性下的动画

当然,每当我们在 web 上讨论运动,也应该注意对运动敏感的用户,并确保我们考虑减少运动的体验。

这就是 CSS 的prefers-reduced-motion查询的意义,有了它,我们可以嗅探那些在系统级别启动了辅助功能设置的用户,然后在我们的工作结束时减少运动。下面的例子是一个严格的例子,在这些情况下会破坏动画,但值得指出的是减少运动并不总意味着没有运动,因此,虽然代码可以工作,但可能不是最佳的选择。

@media (prefers-reduced-motion) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

最终演示

这是完成的 DEMO,其中包含回退和prefers-reduced-motion嗅探的实现,可以试着跟进一步自定义,CodePen

示例 2:使用 CSS 关键帧实现元素过渡动画

第一个例子旨在帮我们理解视图过渡的基础知识,我们所看到的默认的过渡,可以通过@keyframes 和 animation 属性配置自定义的动画。

让我们创建一个包含三列的容器的待办事项。使用与之前类似的方法,并使用自定义动画使运动更自然。具体而言,单击的待办事项在离开父容器时会向上扩展,向下缩减,然后移动到目标容器时略微反弹。其余的待办事项也应平滑地进行动画,以覆盖已完成的待办事项留下的空白区域。

我们将从默认的交叉淡入淡出动画开始,细节查看下文 Codepen

如果我们要应用上一个例子所做的默认交叉淡入淡出过渡,我们将得到以下结果,CodePen

这个效果很好,但不够显著。如果过渡可以更加明显,就更好了,它只是将从一个容器进入另一个容器,这个例子的重点是定制 CSS 过渡动画。

我们可以使用与上一个例子相同的设置,唯一的区别是,这次有两个可能的容器,意味着有一个列作为原容器,另外两个列作为目标容器。

function moveCard(isDone) {
  const card = this.window.event.target.closest("li");

  // Get the target column id (done or wont do).
  const destination = document.getElementById(
    `js-list-${isDone ? "done" : "not-done"}`
  );

  // We'll use this class to hide the item controls.
  card.classList.add("card-moving");

  if (!document.startViewTransition) {
    destination.appendChild(card);
    return;
  }

  const transition = document.startViewTransition(() => {
    // Update DOM (move the clicked card).
    destination.appendChild(card);
  });
}

请注意,视图过渡会冻结渲染,在动画期间不能与元素进行交互,这是一个需要牢记的重要限制,避免创建冗长的动画,这些会破坏可用性,甚至以缓慢的交互到下一个绘制(INP)核心 Web 重要指标的形式影响性能,具体取决于过渡所阻止的内容。

创建过渡元素

让我们首先将view-transition-name 属性添加到待办事项中,并设置更新位置的基本动画,在这个例子中,我们将使用两组不同的值

  • card-active:这是一个待办事项,当前正在移动另一列,我们将在动画运行之前应用设置,在动画结束后删除
  • card-${index + 1}:一旦已完成的项目转换到目标容器,这将应用于剩余的待办事项。每个待办事项都有一个唯一的索引号,以帮助其顺序进行排序并更新位置,以填补已完成的待办事项留下的空白。

目前待办事项不再交叉淡入淡出,但浏览器会追踪位置和大小,并相应地对它们进行动画处理。

// Assign unique `view-transition-name` values to all task cards.
const allCards = document.querySelectorAll(".col:not(.col-complete) li");
allCards.forEach(
  (c, index) => (c.style.viewTransitionName = `card-${index + 1}`)
);

// This function is now async.
async function moveCard(isDone) {
  const card = this.window.event.target.closest("li");

   // Apply card-active to a card that has been clicked on.
   card.style.viewTransitionName = "card-active";

  const destination = document.getElementById(
    `js-list-${isDone ? "done" : "not-done"}`
  );
  
  card.classList.add("card-moving");

  if (!document.startViewTransition) {
    destination.appendChild(card);
    return;
  }

  const transition = document.startViewTransition(() => {
    destination.appendChild(card);
  });

  // Wait for the animation to complete.
  await transition.finished;

  // Cleanup after the animation is done.
  card.style.viewTransitionName = "none";
}

就这样,我们为待办事项设置了动画,在代码中只是做了切换 view-transition-name 值的工作,并告诉浏览器监视哪些元素的位置和大小,这就是我们真正需要的,我们从中得到一个强大的过渡动画。

虽然这个动画看起来不错,但是同时觉得有些僵硬。有时,视图过渡开箱即用让人印象深刻,就像在第一个示例中看到的那样,其他时候啧需要额外的调整。

应用 CSS 关键帧

让我们通过定义自己的@keyframes 来缩放和跳回已完成的待办事项,从而修复之前动画僵硬的问题。我们可以充分利用 css 的 animation 属性并创建关键帧,以便在状态之间获得更具吸引力的过渡。

让我们分解下动画序列:

  • 单击的待办事项应该按比例放大,增加尺寸,就像从容器中抬出,然后飞向目标容器,在接触到地面时弹跳
  • 同时,位于新完成项目下方的剩余待办事项在等待片刻之后,将其在列表中的位置向上移动,填充已完成项目剩余空间
  • 剩余的待办事项会移动位置,容器应该等待然后再收缩到新高度,这样就不会切断其他待办事项
  • 容器立即调整到新高度,而不会出现交叉淡入淡出过渡

让我们从剩余待办事项的容器的延迟动画开始,同样,待办事项列中的项目被分配了唯一的 view-transition-name值,我们能够在 CSS 使用::view-tranisition-group伪元素上的通用选择器选择这个组,而不是单独写,声明一个 anination-delay

/* Delay remaining card movement */
::view-transition-group(*) {
  animation-timing-function: ease-in-out;
  animation-delay: 0.1s;
  animation-duration: 0.2s;
}

接下来,我们将对原容器和目标容器执行相同的操作,我们希望在完成的待办事项完成过渡时延迟一下子,参考 DOM 树,我们知道::view-tranisition-old和::view-transition-new两个伪元素可用,分别代表原容器和目标容器

我们将在 root 级别定位这些状态

/* Delay container shrinking (shrink after cards have moved) */
::view-transition-old(root),
::view-transition-new(root) {
  animation-delay: 0.2s;
  animation-duration: 0s; /* Skip the cross-fade animation, resize instantly */
}

让我们自定义单击待办事项的动画,首先我们通过选择范围限定为活动项的::view-transition-group伪元素来调整单击项animation-duration,我们命名为 card-active

/* Adjust movement animation duration */
::view-transition-group(card-active) {
  animation-duration: 0.4s;
  animation-delay: 0s;
}

最后我们将在 CSS 中创建自定义 keyframe 动画,并将其应用到新旧状态的::view-transition-image-pair容器

/* Apply custom keyframe animation to old and new state */
::view-transition-image-pair(card-active) {
  /* Bounce effect is achieved with custom cubic-bezier function */
  animation: popIn 0.5s cubic-bezier(0.7, 2.2, 0.5, 2.2);
}

/* Animation keyframes */
@keyframes popIn {
  0% {
    transform: scale(1);
  }
  40% {
    transform: scale(1.2);
  }
  50% {
    transform: scale(1.2);
  }
  100% {
    transform: scale(1);
  }
}

只需要对 CSS 做些调整,我们就创建一个自定义的,令人愉快和异想天开的动画,这使得我们的待办事项真正流行起来。

codePen

示例 3:运行多个过渡

前面 2 个例子演示了在单个元素运行单个动画的视图过渡,让我们增加复杂性,看看视图过渡 API 的强大。在第三个例子中,我们将创建两个动画,并且按照顺序执行。具体说,就是我们将实现在电子购物网站上常见交互,即用户把商品放到购物车。

首先,点击事件将激活一个点,该点移动到页眉的购物车图标,然后购物车中的商品数量更新将用动画提现。

与之前例子相同,我们将从默认交叉淡入淡出的基本设置开始,这一次我们用的是产品卡片网格,每个卡片包含一个按钮,点击按钮时,会显示一个从商品卡片过渡到购物车的按钮形状相同的点。该购物车是页面右上角位置,还包括一个计数器,该计数器在购物车添加(删除)商品时更新。

let counter = 0;
const counterElement = document.getElementById("js-shopping-bag-counter");

async function addToCart(index) {
  const dot = createCartDot();
  const parent = this.window.event.target.closest("button");

  parent.append(dot);

  const moveTransition = document.startViewTransition(() =>
    moveDotToTarget(dot)
  );

  await moveTransition.finished;

  dot.remove();

  if (!document.startViewTransition) {
    incrementCounter();
    return;
  }

  const counterTransition = document.startViewTransition(() =>
    incrementCounter(counterElement)
  );
}

function moveDotToTarget(dot) {
  const target = document.getElementById("js-shopping-bag-target");
  target.append(dot);
}

function incrementCounter() {
  counter += 1;
  counterElement.innerText = counter;
}

function createCartDot() {
  const dot = document.createElement("div");
  dot.classList.add("product__dot");

  return dot;
}

创建合成动画

首先,我们需要在各自的过渡动画中切换点元素和购物车元素的view-transition-transition值。使用 await transition.finished来延迟计数器更新动画,直到点完成其到购物车的动画。在此过程中,我们注册两个视图转换名称,分别是 card-dot 和 card-counter

async function addToCart(index) {
  /* ... */

  const moveTransition = document.startViewTransition(() =>
    moveDotToTarget(dot)
  );

  await moveTransition.finished;
  dot.remove();

  dot.style.viewTransitionName = "none";
  counterElement.style.viewTransitionName = "cart-counter";

  if (!document.startViewTransition) {
    incrementCounter();
    return;
  }

  const counterTransition = document.startViewTransition(() =>
    incrementCounter(counterElement)
  );

  await counterTransition.finished;
  counterElement.style.viewTransitionName = "none";
}

/* ... */

function createCartDot() {
  const dot = document.createElement("div");
  dot.classList.add("product__dot");
  dot.style.viewTransitionName = "cart-dot";
  return dot;
}

现在,我们有了跳回 CSS 并且自定义两个动画所需的东西。让我们定义两个@keyframe 动画,toDown 和 fromUP

/* Counter fade out and moving down */
@keyframes toDown {
  from {
    transform: translateY(0);
    opacity: 1;
  }
  to {
    transform: translateY(4px);
    opacity: 0;
  }
}

/* Counter fade in and coming from top */
@keyframes fromUp {
  from {
    transform: translateY(-3px);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

对于点的动画,我们将更改其animation-duration和 animation-timing-function;对于计时器动画,我们在标准交叉淡入淡出的动画中增加轻微的垂直移动

@supports (view-transition-name: none) {
  ::view-transition-group(cart-dot) {
    animation-duration: 0.6s;
    animation-timing-function: ease-in;
  }

  ::view-transition-old(cart-counter) {
    animation: toDown 0.15s cubic-bezier(0.4, 0, 1, 1) both;
  }

  ::view-transition-new(cart-counter) {
    animation: fromUp 0.15s cubic-bezier(0, 0, 0.2, 1) 0.15s both;
  }
}

在此设置中,有几个事情值得注意。首先,我们将动画规则包装在@supports,确保尽在用户浏览器支持时才应用,如果不支持基本的 view-transition-name 属性,则可以放心假设不支持视图过渡。

接下来,请注意,counter-dot 元素上没有动画,也没有应用到其的 CSS属性修改其尺寸。这是因为点的尺寸影响其父容器,换句话说,点的初始位置在产品购物车的按钮容器中,然后移动到较小的购物车容器中。

4-view-transitions-api-tracks-element-position.png

这是一个完美的例子,说明视图过渡 API 如何在动画期间追踪元素的尺寸和位置,以及新旧快照之间的过渡。

完整的例子,见codePen

结论:

每次使用视图过渡API仅用几行代码把看起来成本很高的动画变成一些小任务时,我都感到惊讶。如果操作得当,动画可以给所有项目注入活力,并提供令人愉悦的用户体验。

话虽如此,我们仍然需要小心使用和实现动画,对于初学者来说,我们仍在讨论仅在 Chrome 支持的功能,但是由于 Safari 对此积极,Firefox 也在实现它,因此很有不希望获得广泛的支持,尽管我们不知道是什么时候。

另外,视图过渡 API 也许并不简单,并不能拯救我们自己。想想诸如缓慢或重复的动画,不必要的复杂动画、为那些喜欢减少运动的用户提供工作以及其他不良做法。坚持动画最佳实践从未像现在这样重要。我们的目标是确保我们以增加乐趣和兼容的方式使用视图过渡,而不是为了炫耀。

之后的一篇文章,我们将使用视图过渡 API 在单页和多页应用间切换,类似原生移动应用程序的两个视图之间切换,现在,我们也可以在 web 上使用。

在那之前,去构建一些有趣的应用,并使用它们来试验视图过渡 API 。