初识 View Transition API

初识 View Transition API

前阵子在写 hana-img-viewer 的时候偶然了解到了 View Transition API,感觉挺有意思的,稍微氵一篇文章吧。

1076字

简单来说,这就是一个 提供在两个 DOM 之前平滑过渡动画的 API 。一个 DOM 和另一个 DOM 之间,通过 view-transition-name 这个 CSS 属性建立联系,它们之间存在 的关系。

比如,我用 Vue 写一个 SPA,用了 vue-router 这个路由库。有两个页面:

  1. ListPage.vue,文章列表页。
  2. DetailPage.vue,文章详情页。

然后,ListPage 里面有很多的文章 Item,用 Grid 的形式,v-for 循环出来。然后,每个 Item 有 文章封面元素文章标题元素。这两个元素是我们想要去过渡到 DetailPage 的元素。我们需要给这两个元素分别加上 view-transition-name

先看看完整的代码吧:

vue
<!-- src/pages/ListPage.vue -->
<script setup lang="ts">
import { useRouter } from 'vue-router'

// 模拟数据
const articles = [
  { id: 1, title: '探索宇宙的奥秘', imgSrc: 'https://static-r2.caelum.moe/greyflowers-ogimg.webp' },
  { id: 2, title: '深入海底两万里', imgSrc: 'https://static-r2.caelum.moe/greyflowers-ogimg.webp' },
  { id: 3, title: '编程语言的演变', imgSrc: 'https://static-r2.caelum.moe/greyflowers-ogimg.webp' },
]

const router = useRouter()

// 自定义导航函数,包裹在 startViewTransition 中
function navigate(to: string) {
  // 特性检测
  if (!document.startViewTransition) {
    router.push(to)
    return
  }

  // 使用 View Transition API
  document.startViewTransition(() => {
    // 返回的 Promise 会在 DOM 更新后 resolve
    return router.push(to)
  })
}
</script>

<template>
  <div class="list-page">
    <h1>文章列表</h1>
    <div class="article-list">
      <div v-for="article in articles" :key="article.id" class="article-card" @click="navigate(`/item/${article.id}`)">
        <img
          :src="article.imgSrc"
          :alt="article.title"
          :style="{ viewTransitionName: `article-image-${article.id}` }"
        >
        <h2
          class="article-title"
          :style="{ viewTransitionName: `article-title-${article.id}` }"
        >
          {{ article.title }}
        </h2>
      </div>
    </div>
  </div>
</template>

<style scoped>
.list-page {
  padding: 2rem;
  max-width: 900px;
  margin: auto;
}
.article-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 1.5rem;
}
.article-card {
  border: 1px solid #ccc;
  border-radius: 8px;
  overflow: hidden;
  cursor: pointer;
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.article-card:hover {
  transform: translateY(-5px);
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.article-card img {
  width: 100%;
  height: 150px;
  object-fit: cover;
  display: block;
}
.article-title {
  padding: 1rem;
  font-size: 1.2rem;
  margin: 0;
}
</style>

然后我们在 DetailPage 里面,给 想要进行过渡的元素 加上 相同的 view-transition-name ,即使 old 元素和 new 元素两者之间完全不同(指的是内容不同、标签不同)。不过最好还是除了样式以外完全相同,这样子在视觉层面上会更舒适。

vue
<!-- src/pages/DetailPage.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'

const props = defineProps<{
  id: string // 从路由接收 id
}>()

const router = useRouter()

// 模拟数据源,实际项目中会从 store 或 API 获取
const articles = [
  { id: 1, title: '探索宇宙的奥秘', imgSrc: 'https://static-r2.caelum.moe/greyflowers-ogimg.webp', content: '宇宙是广阔无垠的,充满了恒星、星系和未知的现象...' },
  { id: 2, title: '深入海底两万里', imgSrc: 'https://static-r2.caelum.moe/greyflowers-ogimg.webp', content: '海洋覆盖了地球表面的70%以上,其深处隐藏着许多秘密...' },
  { id: 3, title: '编程语言的演变', imgSrc: 'https://static-r2.caelum.moe/greyflowers-ogimg.webp', content: '从机器码到高级语言,编程语言的发展极大地推动了技术革命...' },
]

const article = computed(() => articles.find(a => a.id === Number.parseInt(props.id)))

// 自定义导航函数,包裹在 startViewTransition 中
function navigate(to: string) {
  // 特性检测
  if (!document.startViewTransition) {
    router.push(to)
    return
  }

  // 使用 View Transition API
  document.startViewTransition(() => {
    // 返回的 Promise 会在 DOM 更新后 resolve
    return router.push(to)
  })
}
</script>

<template>
  <div class="detail-page">
    <div v-if="article" class="article-content">
      <img
        :src="article.imgSrc"
        :alt="article.title"
        class="detail-image"
        :style="{ viewTransitionName: `article-image-${article.id}` }"
      >
      <h1
        class="detail-title"
        :style="{ viewTransitionName: `article-title-${article.id}` }"
      >
        {{ article.title }}
      </h1>
      <p class="detail-text">
        {{ article.content }}
      </p>
      <span class="back-link" @click="navigate('/')">
        返回列表
      </span>
    </div>
    <div v-else>
      <p>文章未找到!</p>
    </div>
  </div>
</template>

<style scoped>
.detail-page {
  max-width: 800px;
  margin: 2rem auto;
  padding: 0 2rem;
}
.detail-image {
  width: 100%;
  height: 400px;
  object-fit: cover;
  border-radius: 12px;
}
.detail-title {
  font-size: 2.5rem;
  margin: 1.5rem 0;
}
.detail-text {
  font-size: 1.1rem;
  line-height: 1.8;
  color: #333;
}
.back-link {
  display: inline-block;
  margin-top: 2rem;
  color: #007bff;
  text-decoration: none;
  cursor: pointer;
}
</style>

别忘记了一些 infra 的配置:

typescript
// src/router/index.ts
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
import DetailPage from '../pages/DetailPage.vue'
import ListPage from '../pages/ListPage.vue'

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'List',
    component: ListPage,
  },
  {
    path: '/item/:id',
    name: 'Detail',
    component: DetailPage,
    props: true,
  },
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})

export default router

main.ts 里面注册一下:

typescript
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(router)
app.mount('#app')

挂载一下 <RouterView />

vue
<!-- src/App.vue -->
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>

<template>
  <main>
    <RouterView />
  </main>
</template>

好,这样子 两个页面之间的目标过渡元素之间的关系就建立好了。我们可以看到,即使两个关联元素之间的标签、内容都不一样也是可以建立联系的。

那么,接下来就是去 触发

view-transition 的触发是由专门的浏览器 API 提供的:document.startViewTransition。这个 API 是一个函数,接收一个回调函数。这个回调函数不能乱传,它必须是 会导致当前视图状态发生改变 的函数。换个简单点的说法,就是 这个回调函数,可以把页面从 old 的 DOM 变到 new 的 DOM

当然,从 old 变到 new 的方式根据不同的页面情境有很多种情况,一般有这么几种:

  1. 路由变化:SPA 的页面切换。
  2. 数据驱动的列表重排:排序、筛选。
  3. 内容替换:分页、加载不同标签页的内容。
  4. UI 状态的重大改变:切换视图布局(如列表视图/网格视图)、主题模式切换。
  5. 元素的增删:在列表、购物车或任何集合中添加或移除元素。

我们现在在写的就是第一种。我们点 ListPage 中的 Item 之后就会触发 vue-router 的编程式导航:

typescript
function navigate(to: string) {
  if (!document.startViewTransition) {
    router.push(to)
    return
  }

  // 使用 View Transition API
  document.startViewTransition(() => {
    return router.push(to)
  })
}

当然,如果想要返回的时候也同样丝滑地过渡,那么返回的时候也得开一个 ViewTransition。

我们都知道,router.push 的返回是 Promise,我们把它从这个回调中返回出去,它会在 DOM 更新之后被 resolve。

然后,我们就能看到这样的效果:

是不是挺丝滑的?

以上是 ViewTransition API 的默认过渡效果,我们可以通过其提供的伪类来详细地控制这段过渡的效果:

  • ::view-transition: 整个过渡的根容器。
  • ::view-transition-group(name): 每个被命名的共享元素的容器。
  • ::view-transition-image-pair(name): 包含共享元素的旧图和新图。
  • ::view-transition-old(name): 旧视图或旧共享元素的“截图”。
  • ::view-transition-new(name): 新视图或新共享元素的“截图”。

我们可以改改 App.vue 里的动画效果:

vue
<style>
/* 全局样式,用于自定义过渡动画 */
/* 当我们从详情页返回列表页时,标题会有一个轻微的延迟和滑动效果 */
@keyframes slide-from-right {
  from {
    transform: translateX(30px);
    opacity: 0;
  }
}

@keyframes slide-to-left {
  to {
    transform: translateX(-30px);
    opacity: 0;
  }
}

/* ::view-transition-old/new(root) 代表整个页面的旧/新视图 */
::view-transition-old(root) {
  animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(root) {
  animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* 针对共享元素的标题进行更精细的控制 */
/* * 选择器可以匹配所有以 'article-title-' 开头的 name */
::view-transition-old(article-title-*) {
  animation: none;
  mix-blend-mode: normal;
}
::view-transition-new(article-title-*) {
  animation: none;
  mix-blend-mode: normal;
}

/* 针对共享元素的图片进行控制,可以给它添加一个不一样的缓动函数 */
::view-transition-group(article-image-*) {
  animation-timing-function: cubic-bezier(0.68, -0.55, 0.27, 1.55);
}
</style>

这样子我们就可以控制整个 view-transition 动效的全流程了。

评论0