關於擠壓動畫的一種嘗試

前言

前不久在codepen看到一個點擊按鈕出現擠壓動畫的demo,看起來很流暢,也比較簡潔;javascript

img

而後一看源碼,使用的是GSAP這個動畫庫加上svg路徑結合的,看起來SVG的路徑有點複雜。而後內心想着能不能用更簡單的代碼或者思路來還原這種效果,看了一些資料後,內心大概出現了幾種思路:css

  • 方法1:嘗試利用clip-path + animation來實現
  • 方法2:嘗試利用clip-path + SVG clipPath animation
  • 方法3:嘗試利用transformmatrix()進行矩陣變換 + animation

<!-- more -->html

嘗試

方法一:clip-path + animation

clip-path屬性用於設置裁剪區域,使得元素只有裁剪區域的部分纔會顯示,最關鍵的是clip-path支持動畫!可是通過一番嘗試,clip-path目前支持的裁剪形狀並不能知足擠壓動畫的需求,即凹曲線;目前clip-path支持的形狀有:java

  • inset():矩形;
  • circle():圓形;
  • ellipse():橢圓;
  • polygon():多邊形;
  • url():引用SVG形狀;
  • 幾何框盒;

事實上,clip-path有個很強大的形狀來源,即path()方法,該方法可使用SVG Path語法來構建形狀,可是該方法目前不少瀏覽器並不支持在clip-path屬性中使用,因此就比較遺憾了;css3

img

方法二:clip-path + SVG clipPath aimation

沒錯,因爲clip-path中可使用url()方法來引用SVG圖形,所以咱們也能夠藉助SVG這條思路來實現擠壓所須要的形狀,畢竟SVG path語法是十分的強大,還支持貝塞爾曲線,幾乎任何形狀均可以繪製出來;瀏覽器

而具體到SVG中就是使用clipPath元素來聲明一個裁剪區域,而後使用url(#name)來引用便可;這看名字就知道clip-path屬性就是借鑑的SVG clipPath了,基本用法以下:css3動畫

<svg id="mask" width="0" height="0">
  <clipPath id="m1">
    <path d="M0 200 L200 200 Q181 131 144 144 Q113 156 69 150 Q25 144 0 200"></path>
  </clipPath>
</svg>
.demo {
    clip-path: url("#m1");
}

clipPath元素內定義的形狀就是裁剪區域,除了可使用path,還可使用SVG內其餘用來定義形狀的元素,如:<rect><circle>等;不只如此,還可使用SVG animation語法,對形狀進行動畫處理;可是經實踐,pathd屬性開啓動畫後,被引用時並無預想中那樣有插值關鍵幀過渡的效果,而是直接跳到最後一幀,也就是說clipPath內的動畫對於path沒有效果:app

<svg id="mask" width="0" height="0">
  <clipPath id="m1">
    <path d="M0 200 L200 200 Q181 131 144 144 Q113 156 69 150 Q25 144 0 200"></path>
    <animate
        attributeType="XML"
        attributeName="d" 
        from="M0 200 L200 200 Q181 131 144 144 Q113 156 69 150 Q25 144 0 200" 
        to="M0 200 L200 200 Q188 100 119 150 Q88 175 44 181 Q13 188 0 200"
        dur="2s"
        repeatCount="indefinite"/>
  </clipPath>
</svg>
.demo {
    clip-path: url("#m1");
}

像上面這種利用animate改變<path>元素的d屬性,在clipPath並無看到效果;不知道是否是用法不對,反正利用SVG animation來改變貝塞爾曲線實現擠壓動畫的嘗試失敗了……frontend

方法三:matrix()/matrix3d()

忽然想起transform屬性中可使用matrix()/matrix3d()這種方法,也就是說還有矩陣變換這條路能夠走;因而乎去網上找了下有沒有相似擠壓動畫這種的扭曲變換,沒想到還真找到一個比較類似的,叫作「柱面投影變換」;原理很簡單,就是經過把矩形區域投影到一個圓柱體外側面或內側面上,從而獲得一個擠壓或拉伸的圖形:svg

img

若是投影平面是圓柱體的外側,那麼就能獲得跟擠壓效果相似的凹曲線

img

然而,很明顯這種變換是非線性變換,而matrix()/matrix3d()<font color=red>只能接受常數</font>做爲矩陣元素,也就沒辦法實現非線性變換了!

改變思路:思考原理

上面嘗試的三種方法都失敗了,多是把問題想的太簡單了,想經過已有的屬性直接插值造成動畫,而不想增長任何額外的計算;事實上,因爲clip-path屬性中有個polygan()方法能夠繪製任意形狀的多邊形,並且支持動畫(也就是能夠關鍵幀自動插值),然而在圖形學中,全部的<font color=#39f>曲線本質上就是經過對曲線的插值繪製出線段獲得</font>的;也就是說咱們能夠經過插值獲得一個近似擠壓動畫須要的多邊形形狀,只要可以找出描述那個擠壓曲線的公式便可,實踐證實這是可行的,並且最終代碼還不怎麼複雜且可控;

擠壓曲線的插值點座標求解

img

如圖所示,以矩形區域左下角爲原點,假設擠壓曲線爲一段圓弧,擠壓曲線距離原底邊最高處的高度(波峯高度)爲$a$,圓弧所處圓的半徑爲$r$,再設圓弧對應的弦長度的一半爲$c$,因而就能獲得:

$$ \begin{aligned}r^2 &= (r - a)^2 + c^2 \\[1em] \Rightarrow r &= \frac{a^2 + c^2}{2a} \end{aligned} $$

根據$r$及圓心座標就能夠獲得圓的軌跡方程:

$$ (x - c)^2 + (y - a + r)^2 = r^2 $$

根據圓的軌跡方程又能夠獲得<font color=#39f>擠壓曲線(圓弧)部分</font>$y$的求解:

$$ y - a + r = \pm \sqrt{r^2 - (x - c)^2} \\[1em] \because y \geqslant 0 \quad \land \quad r - a > 0 \\[1em] \therefore y - a + r = \sqrt{r^2 - (x - c)^2} \\[1em] \Rightarrow y = \sqrt{r^2 - (x - c)^2} + a - r $$

因爲ac是已知的,而後就能獲得$r$;所以,當$x$肯定後,就能獲得對應的$y$值了;因此擠壓曲線上每一個點的座標均可以求出,也就可以進行插值化處理了!

插值化處理

在底邊上等間距選取$n$個點,根據這些點的$x$座標和已肯定的波峯高度就可以獲得對應位置的擠壓曲線上的座標點位置(其實主要是$y$值,以水平方向擠壓曲線爲例),而後按順序鏈接這些插值獲得的擠壓曲線上的點,就能夠獲得近似擠壓曲線的線段,這些線段閉合後就符合擠壓動畫所需的擠壓效果了;

img

能夠設計函數來根據參數(如插值點個數,波峯高度等)自動生成符合polygan()方法接受的路徑格式;以下所示:

/**
 * 獲取弦上一點對應圓弧的高度差
 * @param {number} x 圓弧對應的弦偏移位置
 * @param {number} length 擠壓圓弧對應的弦長度
 * @param {number} crest 擠壓圓弧波峯高度
 */
function getSquishOffset (x, length, crest) {
  const half = length / 2
  const half_2 = half * half
  const crest_2 = crest * crest
  const r = (half_2 + crest_2) / (2 * crest)
  return Math.sqrt(
    r * r - Math.pow(x - half, 2)
  ) + crest - r
}

/**
 * 根據配置獲取相應元素的擠壓動畫關鍵幀參數,拼接形式爲多邊形(polygan)
 * @member {number} width 元素寬度
 * @member {number} height 元素高度
 * @member {number} crestX 水平擠壓曲線波峯高度
 * @member {number} crestY 垂直擠壓曲線波峯高度
 * @member {number} pointX 水平方向插點個數
 * @member {number} pointY 垂直方向插點個數
 */
function getSquishPath ({width, height, crestX = 3, crestY = 3, pointX = 11, pointY = 11}) {
  let fromTop = [] // 上 + 右
  let fromBottom = [] // 下 + 左
  let toTop = []
  let toBottom = []
  const perX = 100 / (pointX - 1)
  const perY = 100 / (pointY - 1)

  for (let i = 0; i < pointX; i++) {
    const curX = Number((i * perX).toFixed(2)) // 當前水平位置百分比
    const offset = Number(getSquishOffset(width * curX / 100, width, crestX).toFixed(2))
    fromTop.push(`${curX}% 0%`)
    fromBottom.unshift(`${curX}% 100%`)
    toTop.push(`${curX}% ${offset}px`)
    toBottom.unshift(`${curX}% calc(100% - ${offset}px)`)
  }

  for (let i = 1; i < pointY - 1; i++) {
    const curY = Number((i * perY).toFixed(2)) // 當前垂直位置百分比
    const reverseY = Number((100 - i * perY).toFixed(2))
    const offset = Number(getSquishOffset(height * curY / 100, height, crestY).toFixed(2))
    fromTop.push(`100% ${curY}%`)
    fromBottom.push(`0% ${reverseY}%`)
    toTop.push(`calc(100% - ${offset}px) ${curY}%`)
    toBottom.push(`${offset}px ${reverseY}%`)
  }

  console.log([fromTop.join(', '), fromBottom.join(', ')].join(', '))
  console.log([toTop.join(', '), toBottom.join(', ')].join(', '))
  return {
    from: [fromTop.join(', '), fromBottom.join(', ')].join(', '), // 初始幀(實際上就是矩形)
    to: [toTop.join(', '), toBottom.join(', ')].join(', ') // 擠壓最後幀(擠壓圓弧插值)
  }
}

其餘注意事項

動態修改擠壓效果

若是想要動態修改動畫效果,即修改@keyframes裏面的內容;有一種思路就是利用原生的CSS變量,用CSS變量來存儲關鍵幀中clip-path屬性的值,而後利用:root(即根文檔節點)元素的style來設置變量值,如:

const root = document.documentElement // 獲取根文檔節點
// 設置css變量用於傳遞動畫參數
root.style.setProperty('--test-from', `polygon(${info.from})`)
root.style.setProperty('--test-to', `polygon(${info.to})`)
root.style.setProperty('--test-duration', config.duration + 's')

而後在關鍵幀動畫相應的位置引用變量便可,這樣動態修改變量值後,對應的動畫效果也會改變;如:

@keyframes test {
  from {
    clip-path: var(--test-from);
  }
  50% {
    clip-path: var(--test-to);
  }
  to {
    clip-path: var(--test-from);
  }
}

如何在每次點擊的時候觸發動畫

簡單粗暴的經過點擊事件添加動畫,動畫完成後移除動畫這種方式我沒試過是否可行;我使用的是另外一種思路:將動畫播放次數設置爲無限次數,可是默認的animation-play-statepaused(即暫停狀態),點擊後將動畫的播放狀態設置爲running(即播放狀態),每次動畫結束後自動切換爲暫停狀態。

順便說一下,監聽動畫每一次結束的時機可使用animationiteration這個事件(該事件本質是在每次動畫開始前觸發,但不包括第一次,所以可用來看成動畫每次播放結束的觸發點);

demo.addEventListener('click', () => {
  demo.classList.add('play') // 點擊播放動畫
})
demo.addEventListener('animationiteration', () => {
  demo.classList.remove('play') // 動畫一次結束後暫停
})

後話

我認可這種方法有點「硬核」,包含一些數學公式的推導,但實際上用到的知識只是高中數學裏面的,過程並不複雜,只不過好久沒用有點生疏了;並且第一次推導的時候還弄錯了,有點尷尬,不過推導成功仍是挺舒服的,最後獲得的代碼也並不複雜,最重要的是理解了本質問題,又加以應用,仍是收穫很大的;

img

上面就是推導過程的草稿,很久沒寫過數學推導了,仍是挺有意思的;最後寫了一個交互的demo,效果看起來還比較滿意,可能動畫參數還須要打磨一下;

img

這個交互demo還能夠隨時調整一些擠壓動畫的參數,而後查看改變後的效果;demo地址爲:A squish animation demo

擴展資料:關於柱面投影變換的思路

相關文檔

相關文章
相關標籤/搜索