animejs - SVG路徑動畫踩坑實錄與原理解析

語言: CN / TW / HK

前言

animejs(中文官網animejs.cn),是一個老牌的前端動畫庫,它允許我們使用簡明的參數配置來編排、實現一些複雜的動畫效果。這個庫提供的動畫類型有很多,對於我個人來講,使用最多的是它的SVG路徑動畫。在使用這個功能的過程中踩了一些坑,而官方文檔並未對這些潛在的坑進行説明,因此我寫了這篇文章,從原理的角度講述一下一些常見的坑點,希望可以幫助到有需要的同學。

SVG路徑動畫簡介

文檔地址 animejs的官方文檔非常的簡明,左邊是清晰的動畫演示,右邊則是直奔主題的使用介紹與實例代碼,這裏直接貼出內容: 文檔.png 我們可以看到,這個功能的核心內容就是anime.path這個方法,這個方法讀取一個svg圖形(經過閲讀源碼,這個圖形可以是pathcirclerectlinepolylinepolygon),然後返回一個函數(以下稱為path函數)。從文檔上我們可以看出來,這個path函數接受一個屬性名(x/y、角度)作為參數,然後返回某種可以為平移變換提供參考的數據(後續會在源碼解析中進行解釋),根據這些數據對動畫目標進行操作,使其按照預期的路徑運動。

常見的坑

文檔介紹看起來非常簡單,實際使用也是如此,短短几行代碼即可上手。但是它對我們選擇的運動目標和SVG路徑有着比較嚴格的要求,這是文檔中沒有提及的,而這些坑對於初次遇到的同學可能要花費很多精力去排查。 這裏為了方便説明,我使用官網中的示例這個比較簡單的場景來進行展示,實際項目中的情況可能要複雜很多,但是根本原因是一樣的。 normal.gif (在以下演示中,紫色的部分都是正常的情況,而其他顏色的部分則是錯誤示例)

問題1 - 運動中心偏移

bad-1.gif
我們可以看到,藍色方塊雖然在沿着正確的路徑運動,但是它的“運動中心”並不在圖形的中心上,而是看起來像圍繞着某個頂點(不完全是)在運動。而在我遇到的需求場景中,基本都是需要圖形的中心落在運動路徑上的。

問題2 - 運動路徑偏移

bad-2.gif
在這個例子中,黃色的方塊雖然正在按照正確的路徑運動,但是位置與預期有着很大的偏移,彷彿在這個地方有另一條形狀一致的透明路徑在引導它移動。

問題3 - 運動軌跡完全變形

如果説前兩種只是各種位置上的偏移,運動軌跡本身還是正確的話,第三種情況就更容易讓人摸不着頭腦:

bad-3.gif
紅色方塊的運動軌跡上雖然還能看得出預期路徑的影子,但是已經嚴重扭曲變形。

問題解析

源碼部分

animejs的源碼是一個一千多行的單文件,內容量並不大,而且在結構上根據不同的功能做了比較清晰的分塊,閲讀起來難度不大。對於路徑動畫,核心的函數只有四個,所以我在這裏只對這四個函數進行介紹,對於涉及到的其他部分,會做適當的補充説明。
截屏2022-09-30 15.54.32.png
這四個函數的調用關係非常簡單,第四個依賴第三個的結果,第三個依賴第二個,以此類推。但是在調用時機上有比較大的區別。前三個函數getParentSvgElgetParentSvggetPath是在動畫的創建階段進行調用的,並將最終的返回值作為動畫參數傳入,而在動畫進行的過程中,getPathProgress這個函數將會在瀏覽器渲染的每一幀(requestAnimationFrame)被調用,調用過程中所依賴的參數就是前面函數的返回值。 ```javascript function getParentSvgEl(el) { let parentEl = el.parentNode; while (is.svg(parentEl)) { if (!is.svg(parentEl.parentNode)) break; parentEl = parentEl.parentNode; } return parentEl; }

function getParentSvg(pathEl, svgData) { const svg = svgData || {}; const parentSvgEl = svg.el || getParentSvgEl(pathEl); const rect = parentSvgEl.getBoundingClientRect(); const viewBoxAttr = getAttribute(parentSvgEl, 'viewBox'); const width = rect.width; const height = rect.height; const viewBox = svg.viewBox || (viewBoxAttr ? viewBoxAttr.split(' ') : [0, 0, width, height]); return { el: parentSvgEl, viewBox: viewBox, x: viewBox[0] / 1, y: viewBox[1] / 1, w: width, h: height, vW: viewBox[2], vH: viewBox[3] } } 前兩個函數總體做的是一件事,就是找到路徑所處的svg的最外層及其相關的一些尺寸、viewport的信息,因為有很多svg的視口並沒有與其尺寸完全吻合(在本文中,為了簡化説明,我們只考慮viewport與尺寸完全吻合的情況),這一步的目的是確定我們這個動畫的“畫布”位置,確切來説是確定“畫布”的左上角。javascript function getPath(path, percent) { const pathEl = is.str(path) ? selectString(path)[0] : path; const p = percent || 100; return function(property) { return { property, el: pathEl, svg: getParentSvg(pathEl), totalLength: getTotalLength(pathEl) * (p / 100) } } } 第三個函數`getPath`就是暴露給外部使用的`anime.path`這個方法,我們將指定的svg路徑傳入,它就會返回一個函數,緊接着我們像`path('x')` `path('y')` `path('angle')`這樣調用這個函數,並且填入對應的參數位置。其實我們通過閲讀這段源碼可以發現,這個函數直接返回了一個對象,而且我們傳入的`'x'`, `'y'`這些參數的值僅僅只是成為了`property`這個屬性的值,作為一個標識符存在,並不影響其他屬性的計算。 而這一步真正額外做的是什麼呢?是計算了`totalLength`這個屬性,這是路徑的總長度,也就是一個動畫週期中,動畫目標所要運動的路程。在隨後生成每一幀動畫時,就可以根據**動畫週期的時間**,**動畫週期的長度**,**當前播放的總時間**計算出**當前週期內**在路徑上運動過的長度,進而可以使用[`SVGGeometryElement.getPointAtLength()`](https://developer.mozilla.org/en-US/docs/Web/API/SVGGeometryElement/getPointAtLength)這個方法獲取到當前時刻在路徑上的那個點,最後根據這個點計算位置、角度等信息。(在實際情況下,還需要將淡入淡出、變速、反轉、暫停等諸多情況一併計算,感興趣的朋友可以去看源碼,在此不再贅述)javascript function getPathProgress(path, progress, isPathTargetInsideSVG) { function point(offset = 0) { const l = progress + offset >= 1 ? progress + offset : 0; return path.el.getPointAtLength(l); } const svg = getParentSvg(path.el, path.svg) const p = point(); const p0 = point(-1); const p1 = point(+1); const scaleX = isPathTargetInsideSVG ? 1 : svg.w / svg.vW; const scaleY = isPathTargetInsideSVG ? 1 : svg.h / svg.vH; switch (path.property) { case 'x': return (p.x - svg.x) * scaleX; case 'y': return (p.y - svg.y) * scaleY; case 'angle': return Math.atan2(p1.y - p0.y, p1.x - p0.x) * 180 / Math.PI; } } ``` 第三個函數調用發生在每一幀動畫間,簡單描述這個函數的作用就是:根據當前的動畫進度,拿到當前目標位置的點,然後計算這個點到 “畫布”左上角 的偏移量、角度,最後這些偏移量將用來對動畫目標進行同樣的平移、旋轉,使其落在預期的位置上。

偏移問題的解析

現在回到我們最初的問題,我們可以看到第一二種問題場景是非常類似的,因此把他們放在一起來分析。

基礎原因

截屏2022-09-30 17.33.25.png
官方的DEMO使用了一個半透明的“殘影”來標記運動目標的初始位置,在這個DEMO中,我使用了同樣的方法來標記。眼尖的同學可能在之前已經發現了,錯誤偏移的方塊,它們的“殘影”相對於正確的方塊也發生了同樣的偏移。
看到這裏,我們已經可以猜到大概的原因了,錯誤的偏移與運動目標的初始位置有關。由於偏移量是根據路徑上的點到 “畫布”左上角 的距離計算的,所以我們把 “畫布”左上角 看做是平移變換的 “基準點”。 想要讓動畫目標的幾何中心正確地吸附在目標路徑上運動,我們必須保證它的初始位置是與 “基準點” 相吻合的。
在這兩個例子中,藍色的方塊雖然落在了基準點上,但是重合的位置是它的頂點而不是幾何中心,所以最終結果是它的頂點吸附在路徑上運動,顯得十分詭異。 而另一個例子就更容易理解了,黃色方塊的初始位置完全偏離了基準點,因此發生了非常大的偏移。
而這些初始位置是如何確定的呢?在路徑動畫中,動畫目標可以是同一個svg文檔內的圖形,也可以是一個普通的HTML DOM,幾乎所有影響定位的屬性(DOM的絕對定位、svg圖形的bouding box以及兩者祖先元素的translate等等)都會決定初始位置。但是需要注意的是,動畫目標自身的translate不會影響其初始位置,因為這個屬性會被動畫覆蓋。

實際場景更為複雜

看到這裏可能有很多人會質疑,把動畫目標定位到左上角不是很自然的事情嗎?我這是不是為了寫文章而硬去造了一些不太可能出現的錯誤場景?確實在實際開發中,我們可能出現藍色方塊那種中心點定位錯誤的問題,但是很少會出現黃色方塊的那種情況,除非故意。但是前面説過了,DEMO裏面是用最簡單的情況來描述根本原因,在實際的場景中,發生偏移的是路徑,而不是動畫目標。這裏面關鍵的位置問題指的是動畫目標和路徑兩者之間的相對位置,所以我認為這兩種情況的原理是一樣的,下面進行詳細的解釋。
在理想狀態下,我們認為SVG路徑是這樣的: html <svg width="512" height="256" viewBox="0 0 512 256"> <path fill="none" stroke="#8452E3" stroke-width="1" d="..."></path> </svg> 但是實際從設計師那裏拿到的是這樣的: html <svg width="512" height="256" viewBox="0 0 512 256"> ... <g transform="translate(100, 500)"> ... <g transform="translate(24, 8)"> ... <g transform="translate(24, 8)"> ... <g transform="translate(0, 5)"> ... <path fill="none" stroke="#8452E3" stroke-width="1" d="..."></path> ... </g> ... </g> ... </g> ... </g> ... </svg> 在我們拿到的svg中,可能充斥着大量的裝飾性的圖形,並且路徑動畫的路徑也不止一條,我們最終要操作的那條路徑,在複雜的圖層、分組結構下,被祖先元素疊加了多次偏移量。但是這還沒有解釋發生錯誤的原因,雖然路徑發生了偏移,但是我們畢竟是用路徑上的點到左上角的距離來計算偏移量,這應該不會影響結果的正確性啊?但事實是,這樣計算真的會發生錯誤:

bad-4.gif
在這個例子中,我們選用那條紅色路徑作為動畫路徑,依舊把動畫目標放置在“畫布”左上角,但是紫色方塊並沒有按我們預期的沿着新的路徑運動,而是依然吸附在那條紫色殘影(僅供演示,實際不存在)上。
這是為什麼呢?原因出在源碼中使用的SVGGeometryElement.getPointAtLength()這個方法上,這個方法拿到的只是路徑上原始的點,而不會考慮其相對於“畫布”的偏移量,也就是説,不管一條路徑被施加了多少的偏移量,最後計算出的距離都是一樣的。
這樣看來,基準點不是一成不變的,在路徑沒有偏移的時候,它是最左上角的那個點,在路徑發生偏移時,我們要對動畫目標的初始位置施加同樣的偏移量,才能對它正確定位。

solution.gif

路徑變形問題的解析

bad-3.gif
這種問題初次發生時是最讓人摸不着頭腦的,但是原因和解決方案都是最簡單的。

截屏2022-09-30 18.39.23.png
在這個場景中,紅色方塊的初始位置看起來是正確的,但是運動軌跡卻非常扭曲,乍一看很難想到是什麼原因,但是當把實際的動畫目標顯示出來之後,一切就很清晰了: 截屏2022-09-30 18.41.39.png
實際的運動目標是這個白色的大方塊,方塊運動的過程中旋轉中心是白色方塊的幾何中心,這就是詭異軌跡的原因。 這種問題往往發生在設計師在同一個svg文件中繪製多個圖形時(自己編寫的HTML DOM一般不會出現這種問題),由於圖形繪製過程中一些圖層、分組的問題,會出現一些留白,而我們拿到svg之後又很難對其中的每個圖形進行校對,最終的結果就是這樣。

問題總結與建議

問題的總體原因分為兩類 - 動畫目標初始位置的偏差與動畫目標圖形的錯誤裁剪區域(留白問題)。這兩類共三種問題可能單獨出現,也可能同時出現,大家可以根據前文所述的這些特徵進行問題的辨別與定位,然後選擇恰當的方式分別解決。
對於後一種問題,最好的解決辦法是與設計師進行足夠的溝通,以保證拿到的圖形是可以直接使用的。而對於前一種問題,我們可以做的事情就比較多了,下面有兩種解決建議,大家可以根據實際情況選用:

方案1 - 把運動路徑與動畫目標組織在同一個父級“容器”中

如果條件允許的話,這是最簡單的解決方案。在animejs中,SVG路徑動畫的目標可以是一個DOM,也可以是svg中的一個圖形。在本文示例(或者説官方示例)中,圖形的組織結構非常簡單,只有一個路徑動畫,而且運動的路徑在svg文檔的最上層(根元素處),在這種場景中,沒有多層的<g>標籤的嵌套,也就不會有多層transform=translate(...)的疊加。所以動畫目標不論是svg外部的DOM還是svg內部的一個/一組圖形,我們都可以很容易地把它的幾何中心定位到運動路徑的最小矩形(邊界框)的左上角,即前文所述的“基準點”。
而對於比較複雜的svg圖形,設計稿中可能在同一幅圖中同時存在多個路徑動畫,而且每個路徑都有很深的嵌套層級。在目標路徑存在多層位移的情況下,我們想要把動畫目標正確定位到“基準點”是比較困難的。這時我們可以把動畫目標與其對應的運動路徑組織到同一個<g>標籤內,使它們擁有完全相同的父級“容器”,這樣他們自然擁有了同樣的多層位移。這裏需要注意的是,如果路徑元素本身也擁有translate屬性,我們需要對動畫目標的定位進行相應的調整,因為這個”末級位移“不是在父容器上,自然也不會對動畫目標產生作用,所以我們需要手動修正這一偏差。
如果動畫目標是一個HTML元素,我們可以通過foreignObject將其組織到svg中。

方案2 - 路徑與動畫目標完全分離

雖然第一種方案非常簡單,但是有時候我們可能因為種種限制(svg結構過於複雜、代碼可讀性的考慮)無法採取第一種方式。這時我們需要獲取到運動路徑的最小矩形到svg左上角的最終位移數值並據此來設置動畫目標的初始位置。在淺層的嵌套中,我們可以通過閲讀svg的代碼來計算偏移量,但是在深層嵌套時這會非常困難。在這裏推薦大家使用Element.getBoundingClientRect()這個API分別獲取運動路徑和最外層svg到視口左上角的距離,然後把它們相減,即為路徑相對於svg的偏移量。

最後的注意事項

無論採取哪種方式,切忌使用tranlsate設置動畫目標的初始位置,因為這會在動畫過程中被覆蓋。