太絲滑了!了解一下原生的視圖轉(zhuǎn)換動畫 View Transitions API

在原生 APP 中,我們經(jīng)常會看到那種絲滑又舒適的頁面切換動畫,比如這樣的

Android 里一般稱之為共享元素(shareElement)動畫,也就是動畫前后有一個(或多個)相同的元素,起到前后過渡的效果,可以很清楚的看到元素的變化過程,而并不是簡單的消失和出現(xiàn)。
現(xiàn)在,web 中(Chrome 111+)也迎來了這樣一個特性,叫做視圖轉(zhuǎn)換動畫 View Transitions[1],又稱“轉(zhuǎn)場動畫”,也能很輕松的實現(xiàn)這類效果,一起了解一下吧!
一、快速認識 View Transition
先從一個簡單的例子來認識一下。
比如,下面有一個網(wǎng)格列表:
<div class="list" id="list">
  <div class="item">1</div>
  <div class="item">2</div>
  <div class="item">3</div>
  <div class="item">4</div>
  <div class="item">5</div>
  <div class="item">6</div>
  <div class="item">7</div>
  <div class="item">8</div>
  <div class="item">9</div>
  <div class="item">10</div>
</div>簡單修飾后如下:

然后我們實現(xiàn)一個簡單交互,點擊每個元素,元素就會被刪除。
list.addEventListener('click', function(ev){
  if (ev.target.className === 'item') {
    ev.target.remove()
  }
})可以得到這樣的效果。

功能正常,就是有點太過生硬了。
現(xiàn)在輪到 View Transitions 出場了!我們只需要在改變狀態(tài)的地方添加document.startViewTransition,如下:
list.addEventListener('click', function(ev){
  if (ev.target.className === 'item') {
    document.startViewTransition(() => { // 開始視圖變換
      ev.target.remove()
    });
  }
})當然為了兼容不支持的瀏覽器,可以做一下判斷。
list.addEventListener('click', function(ev){
  if (document.startViewTransition) { // 如果支持就視圖變換
    document.startViewTransition(() => { // 開始視圖變換
      ev.target.remove()
    });
  } else { // 不支持就執(zhí)行原來的邏輯
    ev.target.remove()
  }
})現(xiàn)在效果如下:

刪除前后現(xiàn)在有一個淡入淡出的效果了,也就是默認的動畫效果,我們可以把這個動畫時長設置大一點,如下:
::view-transition-old(root), /* 舊視圖*/
::view-transition-new(root) { /* 新視圖*/
  animation-duration: 2s;
}這兩個偽元素我們后面再做介紹,先看效果:

是不是明顯感覺過渡變慢了許多?
但是這種動畫還是不夠舒服,是一種整體的變化,看不出刪除前后元素的位置變化。
接下來我們給每個元素指定一個標識,用來標記變化前后的狀態(tài),為了方便控制,可以借助 CSS 變量。
<div class="list" id="list">
  <div class="item" style="--i: a1">1</div>
  <div class="item" style="--i: a2">2</div>
  <div class="item" style="--i: a3">3</div>
  <div class="item" style="--i: a4">4</div>
  <div class="item" style="--i: a5">5</div>
  <div class="item" style="--i: a6">6</div>
  <div class="item" style="--i: a7">7</div>
  <div class="item" style="--i: a8">8</div>
  <div class="item" style="--i: a9">9</div>
  <div class="item" style="--i: a10">10</div>
</div>這里通過view-transition-name來設置名稱。
.item{
  view-transition-name: var(--i);
}然后可以得到這樣的效果,每個元素在變化前后會自動找到之前的位置,并且平滑的移動過去,如下:

完整代碼可以查看
- view-transition sort (juejin.cn)[2]
 - view-transition sort (codepen.io)[3]
 
是不是非常絲滑?這就是 View Transitions 的魅力!
二、View Transition 的核心概念
為啥僅僅加了一點點代碼就是實現(xiàn)了如此順暢的動畫呢?為啥瀏覽器可以知道前后的元素位置關系呢?這里簡單介紹一下變化原理。
整個 JS 部分只有一行核心代碼,也就是document.startViewTransition,表示開始視圖變換。
document.startViewTransition(() => {
  // 變化操作
});整個過程包括 3 部分
- 調(diào)用document.startViewTransition,瀏覽器會捕捉當前頁面的狀態(tài),類似于實時截圖,或者“快照”。
 - 執(zhí)行實際的 dom 變化,再次記錄變化后的頁面狀態(tài)(截圖)。
 - 觸發(fā)兩者的過渡動畫,包括透明度、位移等變化,也可以自定義 CSS 動畫。
 
下面是一個示意圖:

在動畫執(zhí)行的過程中,還會在頁面根節(jié)點自動創(chuàng)建以下偽元素。
::view-transition
└─ ::view-transition-group(root)
   └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)
      └─ ::view-transition-new(root)下面是控制臺的截圖。

其中,::view-transition-old表示「舊視圖」的狀態(tài),也就是變化之前的截圖,::view-transition-new表示「新視圖」的狀態(tài),也就是變化之后的截圖。
默認情況下,整個頁面root都會作為一個狀態(tài),也就是上面的::view-transition-group(root),在切換前后會執(zhí)行淡入淡出動畫,如下:
:root::view-transition-new(root) {
  animation-name: -ua-view-transition-fade-in; /*淡入動畫*/
}
:root::view-transition-old(root) {
  animation-name: -ua-view-transition-fade-out; /*淡出動畫*/
}這也是為什么在使用了document.startViewTransition后整個頁面會有淡入淡出的效果了。

為了讓每個元素都有自己的過渡狀態(tài),這里需要給每個元素都指定名稱。
.item{
  view-transition-name: item-1;
}這樣指定名稱后,每個名稱都會創(chuàng)建一個::view-transition-group,表示獨立的分組。

這樣在變化前后view-transition-name相同的部分就會一一自動執(zhí)行過渡動畫了,以第4、5個元素為例(在3刪除以后)。

最后就會得到這樣的效果。

核心概念就這些了,下面再來看幾個例子。
三、不同元素之間的過渡
視圖變化其實和元素是否相同沒有關聯(lián),有關聯(lián)的只有view-transition-name,瀏覽器是根據(jù)view-transition-name尋找的,也就是相同名稱的元素在前后會有一個過渡動畫。
比如下面這樣一個例子。

每個按鈕在打開彈窗時,都可以清楚的看到彈窗是從哪里出現(xiàn)的,如何實現(xiàn)這樣的效果呢?
從本質(zhì)上看,其實就是「按鈕到彈窗的視圖變換」,按照前面講到的,可能會想到給前后加上相同的view-transition-name,下面是HTML結構。
<div class="bnt-group" id="group">
  <button>按鈕1</button>
  <button>按鈕2</button>
  <button>按鈕3</button>
</div>
<dialog id="dialog">
  <form method="dialog">
    我是彈窗
    <button>關閉</button>
  </form>
</dialog>嘗試一下。
button,dialog{
  view-transition-name: dialog;
}然后添加點擊打開彈窗事件。
group.addEventListener('click', function(ev){
  if (ev.target.tagName === 'BUTTON') {
    if (document.startViewTransition) {
      document.startViewTransition(() => {
        dialog.showModal()
      });
    } else {
      dialog.showModal()
    }
  }
})這樣會有什么問題嗎?運行如下:

很明顯報錯了,意思就是一個頁面中不能有相同的view-transition-name。嚴格來講,是「不能同時出現(xiàn)」,如果其他元素都是隱藏的,只有一個是顯示的,也沒有問題。其實仔細想一下,也很好理解,如果同時有兩個相同的名稱,并且都可見,最后變換的時候該以哪一個為準呢?
所以,在這種情況下,正確的做法應該是動態(tài)設置view-transition-name,比如默認不給按鈕添加名稱,只有在點擊的時候才添加,然后在變換結束之后再移除按鈕的view-transition-name,實現(xiàn)如下:
group.addEventListener('click', function(ev){
  if (ev.target.tagName === 'BUTTON') {
    ev.target.style.viewTransitionName = 'dialog' // 動態(tài)添加 viewTransitionName
    if (document.startViewTransition) {
      document.startViewTransition(() => {
        ev.target.style.viewTransitionName = '' // 結束后移除 viewTransitionName
        dialog.showModal()
      });
    } else {
      dialog.showModal()
    }
  }
})這樣就實現(xiàn)了動態(tài)縮放的效果:

大致已經(jīng)實現(xiàn)想要的效果,不過還有一個小問題,我們把速度放慢一點(把動畫時長設置長一點)。

可以清楚的看到,原本的按鈕先放大到了彈窗大小,然后逐漸消失。這個過程是我們不需要的,有沒有辦法去掉呢?
當然也是可以的!原本的按鈕其實就是舊視圖,也就是點擊之前的截圖,我們只需要將這個視圖隱藏起來就行了。
::view-transition-old(dialog) {
  display: none;
}這樣就完美實現(xiàn)了從哪里點擊就從哪里打開的效果:

完整代碼可以查看:
- view-transition-dialog (juejin.cn)[4]
 - view-transition-dialog (codepen.io)[5]
 
四、自定義過渡動畫
通過前面的例子可以看出,默認情況下,視圖轉(zhuǎn)換動畫是一種淡入淡出的動畫,然后還有如果位置、大小不同,也會平滑過渡。
除此之外,我們還可以手動指定過渡動畫。比如下面這個例子。

這是一個黑暗模式的簡易模型,實現(xiàn)也非常簡單,準備兩套主題,這里用color-scheme實現(xiàn)。
.dark{
  color-scheme: dark;
}然后通過點擊動態(tài)給html切換dark類名。
btn.addEventListener('click', function(ev){
  document.documentElement.classList.toggle('dark')
})這樣就得到了主題切換效果:

接著,我們添加視圖轉(zhuǎn)換動畫。
btn.addEventListener('click', function(ev){
  if (document.startViewTransition) {
    document.startViewTransition(() => {
      document.documentElement.classList.toggle('dark')
    });
  } else {
    document.documentElement.classList.toggle('dark')
  }
})這樣就得到了一個默認的淡入淡出的切換效果(為了方便觀察,將動畫時長延長了)。

你可以把前后變化想象成是兩張截圖的變化,如果要實現(xiàn)點擊出現(xiàn)圓形裁剪動畫,其實就是在新視圖上執(zhí)行一個裁剪動畫,由于是完全重疊的,所以看著像是一種穿透擴散的效果:

動畫很簡單,就是一個clip-path動畫。
@keyframes clip {
  from {
    clip-path: circle(0%);
  }
  to{
    clip-path: circle(100%);
  }
}我們把這個動畫放在::view-transition-new中。
::view-transition-new(root) {
  /* mix-blend-mode: normal; */
  animation: clip .5s ease-in;
  /* animation-duration: 2s; */
}效果如下:

是不是還有點奇怪?這是因為默認的一些樣式導致,包括原有的淡出效果,還有混合模式。

所以還需要去除這些影響。
::view-transition-old(root) {
  animation: none;
}
::view-transition-new(root) {
  mix-blend-mode: normal;
  animation: clip .5s ease-in;
}當然你可以把鼠標點擊的位置傳遞到頁面根節(jié)點。
btn.addEventListener('click', function(ev){
  document.documentElement.style.setProperty('--x', ev.clientX + 'px')
  document.documentElement.style.setProperty('--y', ev.clientY + 'px')
  if (document.startViewTransition) {
    document.startViewTransition(() => {
      document.documentElement.classList.toggle('dark')
    });
  } else {
    document.documentElement.classList.toggle('dark')
  }
})動畫里直接通過CSS 變量獲取。
@keyframes clip {
  from {
    clip-path: circle(0% at var(--x) var(--y));
  }
  to{
    clip-path: circle(100% at var(--x) var(--y));
  }
}這樣就實現(xiàn)了完美的擴散切換效果:

完整代碼可以查看:
- view transition theme change - (juejin.cn)[6]
 - view transition theme change (codepen.io)[7]
 
五、其他案例
找了幾個有趣的例子。
只要涉及到前后過渡變化的,都可以考慮用這個特性,例如下面的拖拽排序。

Android https://mp.weixin.qq.com/s/v8XwlqLAtCxxYG2FOvatFw。
再比如這樣一個數(shù)字過渡動畫。

https://mp.weixin.qq.com/s/v8XwlqLAtCxxYG2FOvatFw
還有類似于 APP 的轉(zhuǎn)場動畫(多頁面跳轉(zhuǎn))
五、總結和說明
總的來說,原生的視圖轉(zhuǎn)換動畫可以很輕松的實現(xiàn)兩種狀態(tài)的過渡,讓 web 也能實現(xiàn)媲美原生 APP 的動畫體驗,下面再來回顧一下整個變化過程:
- 調(diào)用document.startViewTransition,瀏覽器會捕捉當前頁面的狀態(tài),類似于實時截圖,或者“快照”。
 - 執(zhí)行實際的 dom 變化,再次記錄變化后的頁面狀態(tài)(截圖)。
 - 觸發(fā)兩者的過渡動畫,包括透明度、位移等變化,也可以自定義 CSS 動畫。
 - 默認情況下是整個頁面的淡入淡出變化。
 - ::view-transition-old表示「舊視圖」的狀態(tài),也就是變化之前的截圖,::view-transition-new表示「新視圖」的狀態(tài),也就是變化之后的截圖。
 - 如果需要指定具體元素的變化,可以給該元素指定view-transition-name。
 - 前后變化不一定要同一元素,瀏覽器是根據(jù)view-transition-name尋找的。
 - 同一時間頁面上不能出現(xiàn)兩個相同view-transition-name的元素,不然視圖變化會失效。
 
另外,視圖轉(zhuǎn)換動畫應該作為一種「體驗增強」的功能,而非必要功能,在使用動畫時其實拖慢了頁面打開或者更新的速度,并且在動畫過程中,頁面是完全“凍結”的,做不了任何事情,因此需要衡量好動畫的時間,如果頁面本身就很慢就更不要使用這些動畫了。
[1]View Transitions: https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
[2]view-transition sort (juejin.cn): https://code.juejin.cn/pen/7268263402853072931
[3]view-transition sort (codepen.io): https://codepen.io/xboxyan/pen/BavBevP
[4]view-transition-dialog (juejin.cn): https://code.juejin.cn/pen/7268262983178911779
[5]view-transition-dialog (codepen.io): https://codepen.io/xboxyan/pen/WNLeBgY
[6]view transition theme change - (juejin.cn): https://code.juejin.cn/pen/7268257573277532219
[7]view transition theme change (codepen.io): https://codepen.io/xboxyan/pen/poqzmLY















 
 
 











 
 
 
 