Published on

View Transitions - 原生支持的视图过渡动效

Authors
  • avatar
    Name
    Deng Hua
    Twitter

View Transitions API 提供了一种将任何可视 DOM 更改从一个状态转换到下一个状态的简单方法。这可能包括小的更改(例如切换某些内容)或广泛的更改(例如从一个页面导航到下一页)。

View Transitions API 在 Chrome 111+ 中可用

目录

View Transtions API

如今,Web 应用程序变得越来越复杂,因此,将视图状态之间的平滑过渡作为功能动画的一种形式,具有减少认知负荷、防止变化盲目性以及在空间关系中建立更好的记忆的优点。

过去尝试在不同的 DOM 状态之间制作动画需要用到大量的 Javascript 和 CSS。除了动画本身之外,我们还必须担心处理不同状态下内容的加载和渲染以及潜在的可访问性问题。

View Transitions API 是 CSS 工作组于 2021 年提出的,允许在不同应用程序视图之间导航时使用过渡动画。为此已经进行了大量工作,该规范目前处于候选推荐状态。

How does it work

基本使用:

document.startViewTransition(() => updateTheDOMSomehow(data));
  1. 当调用 document.startViewTransition() 时,API 会截取当前页面的屏幕截图。

  2. 接下来,调用传递给 startViewTransition() 的回调函数,这会导致 DOM 发生更改。 当回调函数成功运行时,ViewTransition.updateCallbackDone Promise 兑现,允许你响应 DOM 更新。

  3. API 会捕获页面的新状态并实时展示。

  4. API 构造了一个具有以下结构的伪元素树:

::view-transition
└─ ::view-transition-group(root)
  └─ ::view-transition-image-pair(root)
    ├─ ::view-transition-old(root)
    └─ ::view-transition-new(root)
  • ::view-transition 位于页面上所有其他内容之上的覆盖层中。可以为过渡设置背景颜色。

  • ::view-transition-old(root) 是旧视图的屏幕截图,是新视图的实时表示。

  • 默认的,旧视图从 opacity: 1 动画到 opacity: 0 ,而新视图从 opacity: 0 动画到 opacity: 1 ,创建交叉淡入淡出。

  • 所有动画都是使用 CSS 动画执行的,因此可以使用 CSS 进行自定义。


下面是一个视图转换 API 的示例(来自Animating View Transitions的文章):


以切换 <details> 原生元素作为一个简单的例子:

if (document.startViewTransition) {
  document.addEventListener("click", function (event) {
    if (event.target.matches("summary")) {
      event.preventDefault();
      const details = event.target.closest("details");
      document.startViewTransition(() => details.toggleAttribute("open"));
    }
  });
}

在这里,回调切换了 open 属性。浏览器就可以在旧版本的屏幕截图和新版本之间进行过渡。

这些旧版本视图和新版本都以伪元素的形式呈现,在 CSS 中分别用 ::view-transition-old(root)::view-transition-new(root) 引用。

例如,为了延长过渡效果,我们可以 :

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 2s;
}

视图转换还能够使用比默认的交叉淡入淡出的更高级动画。通过为特定元素提供 view-transition-name 以及 layoutpaintcontainment ,该 API 使开发人员能够精细地控制元素的使用方式过渡,包括它们的宽度、高度和位置。

以这个照片库为例:

最明显的过渡效果是照片的大小和位置,每个页面上的 <img> 元素被赋予相同的唯一 view-transition-namecontainment 值。

并且,view-transition-name也可以被动态添加(例如在 onclick 处理程序中),只要它们是唯一的,并在转换开始之前添加。


一个简单的示例

export default function App() {
  let location = useLocation();

  React.useEffect(() => {
    document.startViewTransition(() => { });
  }, [location]);

  return (
    <div>
      <ul>
        <li><Link to="/">Home</Link></li>
        <li><Link to="/about">About</Link></li>
        <li><Link to="/dashboard">Dashboard</Link></li>
      </ul>

      <Routes>
        <Route path="/" element={<Home />}></Route>
        <Route path="/about" element={<About />} />
        <Route path="/dashboard" element={<Dashboard />} />
      </Routes>
    </div>
  );
}

const Home = () => (
  <div>
    <h2>Home</h2>
    <div>Lorem, ipsum dolor sit amet consectetur adipisicing elit. Officia laboriosam ipsum nihil delectus impedit consequatur corporis odio. Omnis perspiciatis eaque architecto eligendi adipisci nostrum dolor eveniet beatae animi iusto accusamus ab ipsa, expedita consequatur, ex aut necessitatibus excepturi perferendis debitis modi a distinctio? Obcaecati eum repellat eius cum mollitia accusamus.</div>
  </div>
);
const About = () => (
  <div>
    <h2>About</h2>
    <div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Rem autem sapiente, cumque dolores expedita voluptas eaque molestiae sequi tenetur, saepe atque distinctio nemo libero facilis ad laborum dignissimos doloribus repudiandae. Iure obcaecati atque, voluptatum ipsum laborum officiis quae similique odit, nemo repellat impedit dolorem consectetur itaque explicabo fugit ea quaerat?</div>
  </div>
);
const Dashboard = () => (
  <div>
    <h2>Dashboard</h2>
    <div>Lorem ipsum dolor sit amet consectetur, adipisicing elit. Error amet maxime sunt eos vitae necessitatibus nemo minima sequi laudantium nihil debitis, enim impedit officia accusamus nam reiciendis, quibusdam culpa iste exercitationem id. Reprehenderit voluptatum maiores asperiores velit quod? Exercitationem dicta animi nulla nihil nostrum minus iusto veniam vero beatae esse.</div>
  </div>
);

添加CSS:

@keyframes old {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}
@keyframes new {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

::view-transition-old(root) {
  animation: 200ms ease-out old;
}
::view-transition-new(root) {
  animation: 200ms ease-in new;
}

在开发者工具中的Elements一项,可以看到DOM树中有一个一闪而过的元素,我们可以将动画时间加长,这样就能看清它的结构:

这就是之前提到的伪元素树。

::view-transition-old 表示视图过渡的旧视图状态——即过渡前旧视图的静态屏幕截图。

::view-transition-new 表示视图过渡的新视图状态——即过渡后新视图的实时表示。


由于目前新旧视图的伪元素都加载在root节点上,所以是整个页面有动画效果。如果你想让不同的元素以不同于默认的“root”动画的方式进行过渡,你可以使用 view-transition-name 属性将它们分开。

还是刚才的示例:

首先为<Routes>组件外层包一层div,并使用一个class命名。

// ...
<div className="wrapper" style={{border: '2px solid red'}}>
  <Routes>
    <Route path="/" element={<Home />}></Route>
    <Route path="/about" element={<About />} />
    <Route path="/dashboard" element={<Dashboard />} />
  </Routes>
</div>
//...

在CSS中,为.wrapper增加view-transition-name属性与其值:

.wrapper {
  view-transition-name: wrapper-view-trans-name;
}

然后填入对应新旧伪元素CSS中:

@keyframes old {
  from {
    opacity: 1;
    margin-left: 0;
  }
  to {
    opacity: 0;
    margin-left: 100%;
  }
}
@keyframes new {
  from {
    opacity: 0;
    margin-left: -100%;
  }
  to {
    opacity: 1;
    margin-left: 0;
  }
}
::view-transition-old(wrapper-view-trans-name) {
  animation: 500ms ease-out old;
}

::view-transition-new(wrapper-view-trans-name) {
  animation: 500ms ease-in new;
}

我们还加入了新的动画,模拟一个旧元素树向右滑动渐隐。新元素树由左向右滑动渐显的效果。

效果:

对应的伪元素树:

ViewTransition 与 Promise

startViewTransition() 返回一个 ViewTransition 实例,该实例包含了多个 Promise, 允许你在到达视图过渡过程的不同阶段时运行代码。

例如,ViewTransition.ready 在伪元素树创建并且动画即将开始时Resolved,而 ViewTransition.finished 在动画结束后Resolved,此时新页面视图对用户可见且可交互。

示例1: 为博客添加切换主题的视图转换动效

import { useTheme } from 'next-themes'

export default function useThemeViewTrans() {
  const { theme, setTheme, resolvedTheme } = useTheme()

  const toggleTheme = (event: MouseEvent) => {
    // 为不支持此 API 的浏览器提供回退方案:
    if (!document.startViewTransition) return

    // 获取切换主题的按钮文档内位置
    const x = event.clientX
    const y = event.clientY
    // 获取到最远角的距离
    const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))

    // 开始一次视图过渡:
    const transition = document.startViewTransition(() => {
      setTheme(theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark')
    })

    // 等待伪元素创建完成:
    transition.ready.then(() => {
      const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`]
      // 新视图的根元素动画
      document.documentElement.animate(
        {
          clipPath,
        },
        {
          duration: 500,
          easing: 'ease-in',
          // 指定要附加动画的伪元素
          pseudoElement: '::view-transition-new(root)',
        }
      )
    })
  }

  return {
    theme,
    resolvedTheme,
    toggleTheme,
  }
}

并添加以下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;
}

效果:

示例2: SPA应用的路由更改的视图转换动效

类似的,你也可以将它用于页面路由切换。

下面的代码给Next.js的<Link>组件添加了ViewTransition的效果(注意,必须在客户端组件下使用)。

// ...
    function handleClick(e) {
      // 获取点击位置,或者回退到屏幕中间
      const x = e?.clientX ?? innerWidth / 2
      const y = e?.clientY ?? innerHeight / 2
      // 获取到最远角的距离
      const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))

      if (!document.startViewTransition) return

      const transition = document.startViewTransition(() => {
        router.push(href)
      })

      transition.ready.then(() => {
        // 新视图的根元素动画
        document.documentElement.animate(
          {
            clipPath: [`circle(0 at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`],
          },
          {
            duration: 500,
            easing: 'ease-in',
            // 指定要附加动画的伪元素
            pseudoElement: '::view-transition-new(root)',
          }
        )
      })
    }
// ...

效果:


参考:

Implementing view transitions in a Next JS application using React

Smooth and simple transitions with the View Transitions API

CSS View Transitions Module Level 1

Seamless SPA Transitions Using the New View Transitions API (Part 1)

Animating View Transitions

The View Transitions API And Delightful UI Animations (Part 1)

Cross Document View Transitions

Animating DOM state changes no longer requires a mountain of code thanks to the new View Transitions API.

The View Transition API: A New Way to Animate Page Transitions

next-view-transitions