手把手教你實現「京喜工廠」的CSS動畫效果

本文做者@吳冠禧。javascript

0 契機與背景

今年Q1(2020年第一季度)參與了京喜事業部「京喜工廠」業務的前端開發。用戶能夠經過「京喜工廠」參與口罩、抽紙、大米等商品的「在線生產」,既能趣味造物,又能免費領獎品。目前能夠經過「京喜」小程序首頁訪問該活動。css

活動在上線一個月後,PV達到千萬的量級,引人注目。有很多前端同窗好奇裏面涉及到的動畫實現,文本應運而生。html

「京喜工廠」項目包括了微信小程序原生頁面和H5頁面這兩個平臺,項目中使用了大量的 CSS 動畫,在兩個平臺都可完美運行,還沒有發現明顯的兼容性問題。前端

本文就部分所涉及到的動畫效果進行復盤和總結。在真實的項目實戰中,手把手教你深刻學習 CSS 動畫的原理和實現細節。java

「京喜工廠」活動首頁的截圖效果以下:git

0.0 計算機動畫原理

動畫是指由許多幀靜止的畫面,以必定的速度(如每秒16張)連續播放時,肉眼因 視覺暫留 產生錯覺。 要達成最基本的視覺暫留效果至少須要 10幀/秒 ,普通電影是 24幀/秒 , 普通顯示器刷新頻率是 60幀/秒。github

Animexample

下面的兩個Gif都是用相同的6幀組成,可是播放速度不同,10幀/秒就有點動畫的效果,2幀/秒就會有卡頓的感受。web

10幀/秒: 2幀/秒:
Animexample
Animexample2

1 CSS 能作多複雜的動畫?

1.1 動畫展現

京喜工廠小人走路動畫(4倍速播放):canvas

京喜工廠小人走路動畫(4倍速播放)

1.2 動畫描述與分析

整個動畫大致上就是小人按照從右側進入工廠,繞着工廠內一圈的方式最後從右側出去:小程序

京喜工廠小人路徑

走路過程當中會有走路的動做:

走路的動做

注意從右走到左時嗎,小人朝向右;從左走到右時,小人朝向左:

朝向

路徑過程當中會有幾個駐留點,每一個點駐留一小段時間,作工做中的動做:

工做中的動做

2 爲何要用 CSS 作複雜動畫?(競品對比 SVG 、Javascript 、CSS)

咱們先來對比一下。

2.1 SVG

SVG 原生支持 SMIL(Synchronized Multimedia Integration Language), SMIL 容許你:

(1)變更一個元素的數字屬性(x、y……)<animate>

(2)變更變形屬性(translation或rotation)<animateTransform>

(3)變更顏色屬性 <animate> || <animateColor>(已廢棄)

(4)物件方向與運動路徑方向同步(路徑動畫) <animateMotion>

其實都算是常規的動畫能力,可是配合一些 SVG 專有的特性會產生一些奇妙和效果,例如 描邊動畫 就是利用 stroke-dasharraystroke-dashoffset 實現的。另外,同爲路徑動畫 SMIL 的 <animateMotion> 就比 CSS 的 offset-path 兼容性好不少。

caniuse-animateMotion

微信小程序:微信小程序不支持 SVG 及 SMIL 。

2.2 Javascript

理論上, Javascript 能作任何動畫。 通常來講 Javascript 動畫能夠分爲 操縱 DOM 屬性的動畫操縱 canvas api 的動畫,這兩種都的原理都是經過 window.requestAnimationFrame() 或者 window.setTimeout() 這類時間控制函數實現每 16.7ms 顯示一幀畫面,從而達成 60幀/秒 的動畫。 另外,還有 Web Animations API,將瀏覽器動畫引擎向開發者打開,並由JavaScript進行操做。它是將來對網絡上動畫化的支持最有效的方式之一,它使瀏覽器能夠進行本身的內部優化。可是兼容性較差。

caniuse-web-animation-api

微信小程序:微信小程序實現了本身的一套 WX Ainmation API, 不兼容 web 標準。

2.3 CSS

CSS 動畫都是聲明式,使用 @keyframe 建立關鍵幀,瀏覽器就會自動計算出每 16.7ms 內的畫面變化,這些計算不是用 JS ,從而避免 GC 。CSS 動畫還有一個好處是能夠利用 translateZ 開啓 GPU 硬件加速,並且在 2020 年的當下,CSS 動畫的兼容性能夠說是很是好了。

有點遺憾的是 CSS 在路徑動畫 offset-path 上的兼容性仍是比較差。

caniuse-offset-path

微信小程序:微信小程序支持 CSS 動畫。

選擇

考慮到項目主要運行在 H5 和 微信小程序平臺上,綜合兼容性和本身的熟練度,最後仍是選擇用 CSS 動畫。

3 CSS 動畫的分類

按照 animation-timing-function(時間函數) 的不一樣,將 CSS 動畫分紅 「線性變化動畫」 和「非線性變化動」:

  • 「線性變化動畫」 是指 animation-timing-function = linear(直線) || cubic-bezier-timing-function(貝塞爾曲線)

  • 「非線性變化動畫」 是指 animation-timing-function = step-timing-function(分段)

3.1 線性變化動畫

推薦一個貝塞爾曲線的可視化網址:✿ cubic-bezier.com

3.2 非線性變化動畫

非線性變化動畫,一般用來作 「幀動畫」。一般是設計師輸出一組序列幀圖片做背景圖,動畫時控制 background-position 屬性,並經過 step-timing-function 實現躍遷效果。

什麼意思?我來舉個小人走路的例子:

double

其實就兩張圖,分別是擡左腳和擡右腳(120 X 160),用工具將它們合成在一張圖裏(120 X 320)。

spirit

代碼以下:

<div class="anim linear"></div>
<div class="anim steps"></div>
複製代碼
.anim{
  width:120px;
  height:160px;
  background-image:url(./spirit.png); /* 合成圖 */
  background-position:0 0;
  background-size:100% auto;
}

.liear{
  animation:anim-walk 0.4s linear 0s infinite;
}

.steps{
  animation:anim-walk 0.4s steps(1) 0s infinite;
}

@keyframes anim-walk{
  0% {background-position:0px 0px;}
  50% {background-position:0px -160px;}
  100% {background-position:0px 0px;}
}
複製代碼

效果:

linear: steps:
anim-linear
anim-steps

在 CSS 代碼裏,咱們定義了一個 叫 anim-walk 的一組關鍵幀,關鍵幀 0% 時 background-position-y 是 0, 50% 時 爲 -160 ,100% 時又回到 0。 從效果圖裏能夠看出,不一樣的 animation-timing-function 設置對動畫效果的影響。

  • linear 由於是線性變化,因此 0 ~ -160 ~ 0 之間的數據計算出來就是 0 ~ -40 ~ -80 ~ -120 ~ -160 ~ -120 ~ -80 ~ -40 ~ 0

  • steps 由於是非線性變化, 因此 0 ~ -160 ~ 0 之間的數據計算出來就是 0 ~ 0 ~ 0 ~ 0 ~ -160 ~ -160 ~ -160 ~ -160 ~ 0

3.3 路徑動畫( CSS 怎麼作曲線路徑動畫?)

在京喜工廠項目裏,小人是要在工廠的幾個點內移動。

3.3.1 CSS offset-path

最簡單的方法是用 offset-path,用法在張鑫旭的這篇文章寫得很是詳細了:offset-path-css-animation

缺點是兼容性較差,這裏就不詳細說明了。

3.3.2 利用時間函數爲貝塞爾曲線的反作用

在京喜工廠項目裏小人移動的路徑能夠從下面這個設定圖,標註的圓點都是要停留工做的。

path-all

能夠肯定的是,這些標註的圓點位置在 CSS 動畫裏確定是一個關鍵幀,而圓點與圓點之間的直線路徑還好辦,那曲線呢?

這裏我用到「CSS分層動畫」和「時間函數爲貝塞爾曲線的反作用」。 簡單來講,就是經過使用兩個或多個元素實現動畫效果(分層),咱們能夠更加細粒度地控制某個元素的路徑,沿着 X 軸運動使用一種 timing-function ,沿着 Y 軸運動使用另外一種 timing-function 。

假設有 A[0,0]、B[100,100] 兩點。從 A 移動到 B ,咱們能夠分拆成 X 軸的變化量,和 Y 軸的變化量。直線移動時,就是 X 軸與 Y 軸的累計變化量是同樣的:

<div class="anim-x">
  <div class="anim-y">
  </div>
</div>
複製代碼
.anim-x{
  animation: anim-x 1000ms 0s linear infinite;
}
.anim-y{
  animation: anim-y 1000ms 0s linear infinite;
}
@keyframes anim-x {
  0%{ transform:translate3d(0 , 0 , 0) }
  100%{ transform:translate3d(100px , 0 , 0) }
}
@keyframes anim-y {
  0%{ transform:translate3d(0 , 0 , 0) }
  100%{ transform:translate3d(0 , 100px , 0) }
}
複製代碼

exp2

反過來,若是 X軸 與 Y軸 的累計變化量不同,就會走出曲線:

<div class="anim-x">
  <div class="anim-y">
  </div>
</div>
複製代碼
.anim-x{
  animation: anim-x 1000ms 0s ease-in infinite;
}
.anim-y{
  animation: anim-y 1000ms 0s ease-out infinite;
}
@keyframes anim-x {
  0%{ transform:translate3d(0 , 0 , 0) }
  100%{ transform:translate3d(100px , 0 , 0) }
}
@keyframes anim-y {
  0%{ transform:translate3d(0 , 0 , 0) }
  100%{ transform:translate3d(0 , 100px , 0) }
}
複製代碼

exp3

這篇文章把原理寫得很詳細:CSS分層動畫可讓元素沿弧形路徑運動

3.4 組合起來

路徑動畫的問題解決了,小人走路和工做的幀動畫也準備好,下面還有兩個小問題:

(1)小人走路和工做的幀動畫不能同時出現。

(2)路徑動畫從左走到右時小人的朝向,應該與從右走到左時相反。

這裏的解決方法也是「CSS分層動畫」和 「非線性動畫」。

再加多一層轉向動畫,一層控制 「小人走路幀動畫」 的動畫、一層控制 「小人工做幀動畫」 的動畫,這三個控制動畫都是「非線性動畫」。

大概的代碼能夠這樣寫:

<div class="anim-turn">
  <div class="anim-walk"></div>
  <div class="anim-work"></div>
</div>
複製代碼
.anim-turn{
  animation: anim-turn ${allTime}ms 0s steps(1) infinite;
}
.anim-walk{
  animation: anim-walk-opacity ${allTime}ms 0s steps(1) infinite,anim-walk 0.4s steps(1) 0s infinite;
}
.anim-work{
  animation: anim-work-opacity ${allTime}ms 0s steps(1) infinite,anim-working 0.4s steps(1) 0s infinite;
}

@keyframes anim-turn { // 轉向動畫
  0% {transform:scale(1,1)} // 正向
  50% {transform:scale(-1,1)} // 反向
  100% {transform:scale(1,1)} // 正向
}

@keyframes anim-walk-opacity { // 控制「小人走路幀動畫」的動畫
  0% {opacity:1}
  50% {opacity:0}
  100% {opacity:1}
}

@keyframes anim-work-opacity { // 控制「小人工做幀動畫」的動畫
  0% {opacity:0}
  50% {opacity:1}
  100% {opacity:0}
}
複製代碼

再加上 X軸 和 Y軸 的分層 CSS:

<div class="anim-x">
  <div class="anim-y">
    <div class="anim-turn">
      <div class="anim-walk"></div>
      <div class="anim-work"></div>
    </div>
  </div>
</div>
複製代碼
.anim-x{
  animation: anim-x ${allTime}ms 0s linear infinite;
}
.anim-y{
  animation: anim-y ${allTime}ms 0s linear infinite;
}
.anim-turn{
  animation: anim-turn ${allTime}ms 0s steps(1) infinite;
}
.anim-walk{
  animation: anim-walk-opacity ${allTime}ms 0s steps(1) infinite,anim-walk 0.4s steps(1) 0s infinite;
}
.anim-work{
  animation: anim-work-opacity ${allTime}ms 0s steps(1) infinite,anim-working 0.4s steps(1) 0s infinite;
}

@keyframes ...
複製代碼

3.5 寫個可視化工具,提高效率

上面只是簡單的寫了這個動畫的簡單架構,而具體的動畫數據 @keyframes 纔是重點,並且這些關鍵幀確定不是手寫。

工欲善其事,必先利其器。

因此咱們來用 Vue 打造一個可視化工具[doge]。

大概長這樣子:

基本操做是「添加關鍵幀」、「調整每一個關鍵幀的屬性」、「生成測試動畫」、「輸出動畫CSS」。

「添加關鍵幀」:

添加關鍵幀

「調整每一個關鍵幀的屬性」:

調整每一個關鍵幀的屬性

「生成測試動畫-輸出動畫CSS」:

生成測試動畫-輸出動畫CSS

工具的具體實現略過,這裏說一些關鍵細節:

(1)如何畫出動畫路徑?

(2)動畫時間怎麼算?

3.6 畫出動畫路徑

在路徑動畫裏,每兩個關鍵幀肯定了一段時間內元素的起點與終點,而時間函數着決定了這段時間內 X軸 與 Y軸 的變化量,咱們能夠將這段時間平均分爲 N 端,而後分別求出這 N 端時間終點時元素的位置,用直線連起來就能夠獲得一條近似的曲線。

point-line

舉個例子:

<div class="anim-x">
  <div class="anim-y">
  </div>
</div>
複製代碼
@keyframes anim-x {
  0%{ transform:translate3d(0 , 0 , 0); animation-timing-function:linear}
  100%{ transform:translate3d(300px , 0 , 0) }
}
@keyframes anim-y {
  0%{ transform:translate3d(0 , 0 , 0); animation-timing-function:cubic-bezier(0,.26,.74,1) }
  100%{ transform:translate3d(0 , 300px , 0) }
}
.anim-x{
  animation: anim-x 1000ms 0s;
}
.anim-y{
  animation: anim-y 1000ms 0s;
}
複製代碼

在這個例子裏,元素的 X軸 從 0 ~ 300 , animation-timing-functionlinear, Y軸 從 0 ~ 300, animation-timing-functioncubic-bezier(0,.26,.74,1),而後將時間長度定義爲 1,平均分爲 100 段,使用 for 循環求出不一樣進度時的 X 和 Y:

const moveTo = [0,0];
  const step = 100;
  const dX = 300;
  const dY = 300;
  const timeFunX = "linear";
  const timeFunY = "cubic-bezier(0,.26,.74,1)";
  if (Math.abs(dX) > 0 || Math.abs(dY) > 0) {
    ctx.moveTo(moveTo[0],moveTo[1]);
    for(let i = 0;i <= step;i ++) {
      const x = getTimeFunctionValue(timeFunX,i/step) * dX + moveTo[0]; // 求出 timeFunX(linear) 對應時間進度下的 x
      const y = getTimeFunctionValue(timeFunY,i/step) * dY + moveTo[1]; // 求出 timeFunY(cubic-bezier(0,.26,.74,1)) 對應時間進度下的 y
      ctx.fillRect(x, y, 1, 1);
      if (i % 10 === 0) {
        ctx.font = "16px serif";
        ctx.fillText(`(${x},${(y).toFixed(2)})`, x + 20, y + 20);
      }
    }
  }
複製代碼

效果以下:

point-text

剩下就是這個 getTimeFunctionValue(時間函數,時間進度[0,1]) 應該怎麼寫?

首先要把 linear 和 其餘的貝塞爾曲線分開, linear 其實就是一條直線,時間進度輸入任何值,都返回相同的值。

function getTimeFunctionValue(timeFunctionName = "linear",x = 0){
  ...
  if (timeFunctionName === "linear") return x;
  ...
}
複製代碼

貝塞爾曲線呢?先來補習一下 CSS 動畫裏的貝塞爾曲線時間函數。

3.7 CSS 動畫裏的貝塞爾曲線時間函數

「貝塞爾曲線」是一種參數函數。計算機中應用比較普遍的是「三次貝塞爾曲線」。

P0、P一、P二、P3四個點在平面或在三維空間中定義了三次方貝塞爾曲線。曲線起始於P0走向P1,並從P2的方向來到P3。通常不會通過P1或P2;這兩個點只是在那裏提供方向信息。P0和P1之間的間距,決定了曲線在轉而趨進P2以前,走向P1方向的「長度有多長」。

曲線的參數形式爲:

math-x

math-y

CSS 動畫裏的貝塞爾曲線時間函數是一個簡化版的「三次貝塞爾曲線」,P0 固定爲 [0,0], P3 固定爲 [1,1]。

並且其直角座標系是區別於幾何座標(x,y),而是有其餘意義的,橫軸表明的是「時間進度(time)」,取值爲[0% ~ 100%]。豎軸表明的是屬性的「變化程度(progression)」,這個取值通常會在[0% ~ 100%],能夠小於0%,也能夠大於100%。

因此 這個簡化版的 CSS 貝塞爾曲線能夠用下面這兩個方程表示(代入 P0[0,0] P3[1,1]):

T(時間進度) = ...

math-2

P(變化百分比) = ...

math-3

cubic-bezier(0,.26,.74,1) 裏面的參數其實就是(P1_time,P1_progression,P2_time,P2_progression)。以下圖所示:

exp

cubic-bezier(0,.26,.74,1) 裏面的參數(P1[0,0.26],P2[0.74,1]),代入上面的兩個公式,獲得下面的結果(方程):

T(時間進度) = ...

math-4

P(變化百分比) = ...

math-5

第一條方程中的 T 就是時間進度,是入參,解出這條 關於 t 的一元三次函數的根,代入第二條方程中,就能夠求得 P。P 就是 T 「時間進度」下的「變化程度」。

注意:三次函數有3個根,可是隻有實數並且在[0 ~ 1]之間的是正解。

3.8 動畫裏時間怎麼算?

上面咱們用積分的方法將動畫路徑近似的畫出來,就至關於咱們能夠求出動畫路徑的長度的近似值。長度 / 速度 = 動畫時間。其中速度能夠自定義。

4 其餘

4.1 解決層級不正確的問題(translateZ)

京喜工廠還有一個傳送帶動畫,你們能夠看看下圖的最第一版本:

before

上面這張圖,能夠看到貨物從左往右沿着傳送帶移動。最初左邊看着還挺正常,可是到了右邊會出現後方貨物把前邊貨物蓋壓的現象。

緣由也很簡單,由於這幾個貨物是並排的元素,後面的元素層級總會比前面的高。就像下面這樣:

<div>
  <div>1</div>  <!-- 顯示層級最低 -->
  <div>2</div>
  <div>3</div>
  <div>4</div>
  <div>5</div>
  <div>6</div>  <!-- 顯示層級最高 -->
</div>
複製代碼

但這個動畫想表現的層級是中間高,兩邊低。

有些同窗可能會想到用 z-index ,惋惜 z-index 在 CSS 動畫裏是不起做用的。

正確的解決方案是用 translateZ 將其轉換成 3D 顯示,從而實現中間高,兩邊低的層級:

@keyframes anim-z{
  0% {transform: perspective(500px) translateZ(0);}
  50% {transform: perspective(500px) translateZ(50px);}
  100% {transform: perspective(500px) translateZ(0);}
}
複製代碼

增長後的效果:

after

4.2 解決逐幀動畫抖動問題

dou

幀動畫這裏還有一個抖動的問題,看上面的 gif 能夠發現小人有點抖動。這張 gif 是在 iPhone 6 Plus(手機屏幕像素是 414 px)上的顯示效果。

問題是出在單位轉換上:移動端的適配時,一般是用 rem ,小程序是用 rpx,他們在計算成 px 過程當中可能會出現取整的問題,從而形成幀動畫抖動。

逐幀動畫抖動的研究,看 「凹凸實驗室」 的這篇文章就夠了:《CSS技巧:逐幀動畫抖動解決方案》

這篇文章提出了三個方案 A、B、C ,其中方案C是「終極解決方案」。惋惜的是,這個方案用到的是 SVG,而小程序是不支持 SVG 的。

退而求其次,我選擇了方案 A ,就是用 CSS 的媒體查詢來寫斷點,斷點裏都用 px 作單位。

/* 300 ~ 349 之間用 iphone5(320)的數據 */
@media screen and (min-width: 300px) and (max-width: 349px) {
    .m_worker_employee {
        width:51px;
        height: 68px
    }
    @keyframes anim-working {
        0% {
            background-position: 0px -204px
        }
        50% {
            background-position: 0px -272px
        }
        100% {
            background-position: 0px -204px
        }
    }
}
/* 350 ~ 399 之間用 iphone6(375)的數據 */
@media screen and (min-width: 350px) and (max-width: 399px) {
    .m_worker_employee {
        width:60px;
        height: 80px
    }
    @keyframes anim-working {
        0% {
            background-position: 0px -240px
        }
        50% {
            background-position: 0px -320px
        }
        100% {
            background-position: 0px -240px
        }
    }
}
/* 400 ~ 449 之間用 iphone6P(414)的數據 */
@media screen and (min-width: 400px) and (max-width: 449px) {
    .m_worker_employee {
        width:66px;
        height: 88px
    }
    @keyframes anim-working {
        0% {
            background-position: 0px -264px
        }
        50% {
            background-position: 0px -352px
        }
        100% {
            background-position: 0px -264px
        }
    }
}
複製代碼

budou

斷點應用後,幀動畫就不抖了。

最後總結一句:前端動畫方案有不少種,可是要根據使用的環境選擇最合適的;調試動畫很繁瑣,關鍵是要用合適的工具,沒有就本身造一個。

5 公式相關

本文的公式是使用 「TeX」軟件,而後利用「MathJax」輸出爲 SVG ,在此推薦一下:www.mathjax.org/#demo


若是你以爲這篇內容對你有價值,請點贊,並關注咱們的官網和咱們的微信公衆號(WecTeam),每週都有優質文章推送:

相關文章
相關標籤/搜索