打造高性能剪切動畫
前言
本文根據原文Building performant expand & collapse animations翻譯整理而來。為了便於描述和理解,整片譯文並沒有嚴格遵循原文結構。
原文作者:
- Paul Lewis
- Stephen McGruer
PS:知乎不支持 GIF 圖,建議自己修改下面圖片鏈接後綴為 .gif 查看或者移步這裡查看。
在谷歌的產品中我們經常會看到各種各樣的交互動畫,對於前端開發來說實現動畫效果也是經常遇到且頭疼的事情,原作者 Paul Lewis 是谷歌的一名工程師,在這篇文章中我們將探討如何實現如下的交互設計
上面的交互效果是將一個菜單分為收起狀態和展開狀態,在收起狀態下只展現收起狀態下的一部分,所以我們可以看成收起狀態是把展開態的一部分給剪切掉了,這也是為什麼原文中作者使用 animating clips 來描述這種交互形式。
方案1:通過動畫修改容器的寬高
我們定義好菜單收起和展開兩種狀態下的 width 和 height 屬性,並且給元素加上 transition 過渡,通過切換元素的 class 實現動畫效果。具體的實現代碼如下:
.menu { overflow: hidden; width: 350px; height: 600px; transition: width 600ms ease-out, height 600ms ease-out;}.menu--collapsed { width: 200px; height: 60px;}
這個方法簡單粗暴見效快,但對於老司機來說這並不是一個滿意的方案,因為在修改元素的樣式屬性時瀏覽器會重新渲染頁面,對 width 和 height 屬性而言,每一次修改之後頁面都需要進行重排,這個渲染過程開銷相對而言比較大,所以動畫很難達到60fps。關於渲染性能這塊可以參考這篇文章和 CSS Triggers;
方案2:使用 CSS clip 或 clip-path 屬性
除了上面說的修改寬和高之外我們還可以使用 clip 屬性(已經廢棄)來實現。當然,因為 clip 已經棄用了,我們應該使用 clip-path 屬性搞定,但 clip-path 目前的瀏覽器兼容性並不好,所以目前使用clip 或 clip-path 也不是一個完美的解決方案。
.menu { position: absolute; clip: rect(0px 112px 175px 0px); transition: clip 600ms ease-out;}.menu--collapsed { clip: rect(0px 70px 34px 0px);}
這個方案性能上比方案1要好,但仍舊會在每一幀觸發重繪。並且 clip 屬性需要元素是 absolute 或 fixed 定位,這樣可能會讓 CSS 代碼略麻煩。
方案3:CSS Animating 和 scale
方案1修改的是元素的大小,方案2是裁剪元素的可視區域,現在我們換一種思路。
首先從效果上看我們可以認為菜單的收起態是展開態縮放而來的,也就是說我們將展開態高度所放到原來的 1/5 就是收起態的高度。我們對元素做 transform: scale(0.2, x)(這裡沒考慮寬度的縮放比例) 操作,效果如下
現在菜單的高度縮小為原來的 1/5,但是問題就是菜單的內容也被相應的縮小了,要解決這個問題很簡單,我們直接把菜單的內容放大 5 倍即可。
所以方案三其實是在容器上設置一個縮放,然後在內容上設置一個反向縮放(counter-scale),這樣就可以保證在容器收起或展開的時候菜單里的內容不會被壓縮或者放大。這個方法邏輯上稍顯麻煩,但是修改 transfrom 屬性不會觸發重排或者重繪,並且可以利用 GPU 進行運算,因此可以達到更高的性能從而達到更高的幀率。
下面詳細說一下實現步驟:
第一步:計算開始和結束狀態
首先要得到菜單收起和展開狀態下的大小,由此來計算出我們的縮放比例。當然,我們並不能一次取到菜單收起和展開狀態下的大小,因此需要先切換 class 來改變元素的狀態,然後獲取到對應狀態下的大小(或者將菜單第一個子元素的大小看做收起態的大小,下面代碼就是這樣做的)。
需要注意的是當我們執行 getBoundingClientRect()(或者 offsetWidth 和 offsetHeight)時,如果頁面當前樣式有變化,瀏覽器會強制重排。
function calculateCollapsedScale () { // The menu title can act as the marker for the collapsed state. const collapsed = menuTitle.getBoundingClientRect(); // Whereas the menu as a whole (title plus items) can act as // a proxy for the expanded state. const expanded = menu.getBoundingClientRect(); return { x: collapsed.width / expanded.width, y: collapsed.height / expanded.height }}
菜單默認狀態下是展開的,通過上面的代碼可以得到菜單收起態和展開態的縮放比例,然後使用 JavaScript 修改元素的 tansform 屬性,這樣就可以實現展開和收起動畫。
.menu { will-change: transform; transition: 200ms linear;}
var { x, y } = calculateCollapsedScale();var invX = 1 / x;var invY = 1 / y;function toggle() { if (menu.classList.contains(expanded)) { collapse(); return; } expand();}function collapse() { menu.classList.remove(expanded); menu.style.transform = `scale(${x}, ${y})`; menuContents.style.transform = `scale(${invX}, ${invY})`;}function expand() { menu.classList.add(expanded); menu.style.transform = `scale(1, 1)`; menuContents.style.transform = `scale(1, 1)`;}menuBtn.addEventListener(click, toggle);
效果如下(自己錄的Gif,效果略卡)
從效果看可以發現雖然菜單的收起態和展開態沒有問題,但是在變換過程中內容看起來變形了。因為我們是通過容器的縮放和內容的反向縮放(counter-scale)來實現動畫的,要保證內容在動畫過程中不變形,就要保證容器的縮放比例和內容的縮放比例在動畫過程中始終保持相乘等於1。我們用高度舉例,收起態的高度是展開態的 1/5(0.2),所以收起態下內容的高度是放大了5倍,那麼展開動畫就是容器高度縮放比例從 0.2 -> 1,內容高度縮放比例從 5 -> 1。假設緩動函數是線性的,我們把過程分成五個階段,用表格表示:
容器高度縮放比例內容高度縮放比例0.250.440.630.8211所以我們可以看到,在整個動畫過程中,容器高度縮放比例 * 內容高度縮放比例 > 1,這也就是解釋了為什麼我們看到的動畫中內容變形了。
第二步:構造CSS動畫
針對上面的問題,我們可以使用 CSS 動畫解決,因為 CSS 動畫是基於關鍵幀,因此我們可以將動畫過程分成 100 個關鍵幀,計算出每一個關鍵幀下容器和內容的縮放比例,並保證在這 100 個關鍵幀中這兩個比例相乘始終等於 1,接著將這 100 個關鍵幀拼接成 CSS 動畫並插入到頁面中給元素調用。這樣我們就可以保證在動畫過程中不會出現變形,並且一開始就完成所有的計算量,避免了在 JavaScript 中動態計算,從而避免了由於 JavaScript 阻塞導致的動畫卡頓。
將 CSS 動畫插入到頁面中會導致瀏覽器重新渲染頁面樣式,但這個過程只會在最開始執行一次,所以影響並不大。下面是通過 JavaScript 生成 CSS 動畫的代碼
function createKeyframeAnimation () { // Figure out the size of the element when collapsed. let {x, y} = calculateCollapsedScale(); let animation = ; let inverseAnimation = ; for (let step = 0; step <= 100; step++) { // Remap the step value to an eased one. let easedStep = ease(step / 100); // Calculate the scale of the element. const xScale = x + (1 - x) * easedStep; const yScale = y + (1 - y) * easedStep; animation += `${step}% { transform: scale(${xScale}, ${yScale}); }`; // And now the inverse for the contents. const invXScale = 1 / xScale; const invYScale = 1 / yScale; inverseAnimation += `${step}% { transform: scale(${invXScale}, ${invYScale}); }`; } return ` @keyframes menuAnimation { ${animation} } @keyframes menuContentsAnimation { ${inverseAnimation} }`;}
代碼中的 ease() 表示緩動函數,在實際應用中,我們可以如下一樣自己定義一個,或者使用 Tween.js 這樣的項目。
function ease (v, pow=4) { return 1 - Math.pow(1 - v, pow);}
震驚!谷歌竟然能看函數曲線!So Google It!
第三步:執行 CSS 動畫
通過前兩步我們已經生成了 CSS 動畫並且插入到了頁面中,接下來我們通過切換元素的 class 來觸發動畫。
.menu--expanded { animation-name: menuAnimation; animation-duration: 0.2s; animation-timing-function: linear;}.menu__contents--expanded { animation-name: menuContentsAnimation; animation-duration: 0.2s; animation-timing-function: linear;}
結合上面的 CSS 代碼,使用 JavaScript 切換元素的 class 就可以觸發元素執行動畫。這裡要注意在 JavaScript 中我們已經引入了緩動函數,所以在 CSS 代碼中需要將 animation-timing-function 屬性設置為 linear,如果設置成其他值的就相當於在我們原先的緩動基礎上再疊加一次緩動效果。
現在我們搞定了展開動畫,對於收起動畫可以將展開動畫逆向執行,這個方法看起來是沒有問題的,但是因為是完全逆向執行的,所以假設我們展開動畫緩動函數是 ease-out ,那麼收起動畫看起來就是 ease-in,所以視覺效果上會有一些區別。所以更好的方案是在 JavaScript 中生成兩套 CSS 動畫,分別是展開動畫和收起動畫,這樣就可以保證兩個動畫的關鍵幀是遵循同樣的緩動函數。
const xScale = 1 + (x - 1) * easedStep;const yScale = 1 + (y - 1) * easedStep;
再進一步:圓形菜單
現在可以用同樣的原理實現一個圓形的菜單動畫,其實代碼層面基本是一致的。只是圓形這種情況下我們需要設置一個 border-raidu: 50% 來實現圓形,然後用一個設置了 overflow: hidden 的元素將菜單包裹起來,這樣就可以在菜單展開後看起來是一個矩形。
當然,這裡還涉及一些其他樣式上的修改,因為在圓形彩蛋動畫中變換中心並不是左上角或者中心,而且點擊後加號按鈕會消失,所以還需要注意這些細節。在低 DPI 的屏幕上 Chrome 瀏覽器會出現文字模糊的 bug,具體的 bug 說明在這裡。圓形菜單動畫代碼戳這裡;
總結
現在我們已經實現了一種高性能動畫方案,原理是將容器和內容同時縮放並且保證兩個縮放比例相乘等於 1。具體的過程是先通過 JavaScript 計算出 100 個關鍵幀並拼接成 CSS 動畫,然後觸發元素執行 CSS 動畫即可。
如果瀏覽器支持 Web Animation 的話可以直接調用 Web Animation API 執行動畫,但問題是 Web Animation 的兼容性不太好,所以使用的話需要做如下兼容處理。
if (animate in HTMLElement.prototype) { // Animate with Web Animations.} else { // Fall back to generated CSS Animations or JS.}
想要看具體的實現代碼,移步 UI Element Samples Github 倉庫。
相關文章
- infinite scroll
- performant parallaxing
推薦閱讀:
※屏蔽知乎的熱門推送——知乎-我不感興趣 V2.0強勢發布
※面向前端的 Lottie & AE 動畫手把手入門教學
※小爝的知乎Live-如何監控性能 & 分析數據
※echarts實現非同步載入數據,點擊更新圖表功能。
※介紹一個導出CSS精靈圖動畫的AE腳本
TAG:前端開發 |