关于挤压动画的一种尝试

前言

前不久在codepen看到一个点击按钮出现挤压动画的demo,看起来很流畅,也比较简洁;

img

然后一看源码,使用的是GSAP这个动画库加上svg路径结合的,看起来SVG的路径有点复杂。然后心里想着能不能用更简单的代码或者思路来还原这种效果,看了一些资料后,心里大概出现了几种思路:

  • 方法1:尝试利用clip-path + animation来实现
  • 方法2:尝试利用clip-path + SVG clipPath animation
  • 方法3:尝试利用transformmatrix()进行矩阵变换 + animation

尝试

方法一:clip-path + animation

clip-path属性用于设置裁剪区域,使得元素只有裁剪区域的部分才会显示,最关键的是clip-path支持动画!但是经过一番尝试,clip-path目前支持的裁剪形状并不能满足挤压动画的需求,即凹曲线;目前clip-path支持的形状有:

  • inset():矩形;
  • circle():圆形;
  • ellipse():椭圆;
  • polygon():多边形;
  • url():引用SVG形状;
  • 几何框盒;

事实上,clip-path有个很强大的形状来源,即path()方法,该方法可以使用SVG Path语法来构建形状,但是该方法目前很多浏览器并不支持在clip-path属性中使用,所以就比较遗憾了;

img

方法二:clip-path + SVG clipPath aimation

没错,由于clip-path中可以使用url()方法来引用SVG图形,因此我们也可以借助SVG这条思路来实现挤压所需要的形状,毕竟SVG path语法是十分的强大,还支持贝塞尔曲线,几乎任何形状都可以绘制出来;

而具体到SVG中就是使用clipPath元素来声明一个裁剪区域,然后使用url(#name)来引用即可;这看名字就知道clip-path属性就是借鉴的SVG clipPath了,基本用法如下:

1
2
3
4
5
<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>
1
2
3
.demo {
clip-path: url("#m1");
}

clipPath元素内定义的形状就是裁剪区域,除了可以使用path,还可以使用SVG内其他用来定义形状的元素,如:<rect><circle>等;不仅如此,还可以使用SVG animation语法,对形状进行动画处理;但是经实践,pathd属性开启动画后,被引用时并没有预想中那样有插值关键帧过渡的效果,而是直接跳到最后一帧,也就是说clipPath内的动画对于path没有效果:

1
2
3
4
5
6
7
8
9
10
11
12
<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>
1
2
3
.demo {
clip-path: url("#m1");
}

像上面这种利用animate改变<path>元素的d属性,在clipPath并没有看到效果;不知道是不是用法不对,反正利用SVG animation来改变贝塞尔曲线实现挤压动画的尝试失败了……

方法三:matrix()/matrix3d()

突然想起transform属性中可以使用matrix()/matrix3d()这种方法,也就是说还有矩阵变换这条路可以走;于是乎去网上找了下有没有类似挤压动画这种的扭曲变换,没想到还真找到一个比较相似的,叫做“柱面投影变换”;原理很简单,就是通过把矩形区域投影到一个圆柱体外侧面或内侧面上,从而得到一个挤压或拉伸的图形:

img

如果投影平面是圆柱体的外侧,那么就能得到跟挤压效果类似的凹曲线

img

然而,很明显这种变换是非线性变换,而matrix()/matrix3d()只能接受常数作为矩阵元素,也就没办法实现非线性变换了!

改变思路:思考原理

上面尝试的三种方法都失败了,可能是把问题想的太简单了,想通过已有的属性直接插值形成动画,而不想增加任何额外的计算;事实上,由于clip-path属性中有个polygan()方法可以绘制任意形状的多边形,而且支持动画(也就是可以关键帧自动插值),然而在图形学中,所有的曲线本质上就是通过对曲线的插值绘制出线段得到的;也就是说我们可以通过插值得到一个近似挤压动画需要的多边形形状,只要能够找出描述那个挤压曲线的公式即可,实践证明这是可行的,而且最终代码还不怎么复杂且可控;

挤压曲线的插值点坐标求解

img

如图所示,以矩形区域左下角为原点,假设挤压曲线为一段圆弧,挤压曲线距离原底边最高处的高度(波峰高度)为aa,圆弧所处圆的半径为rr,再设圆弧对应的弦长度的一半为cc,于是就能得到:

r2=(ra)2+c2r=a2+c22a\begin{aligned}r^2 &= (r - a)^2 + c^2 \\[1em] \Rightarrow r &= \frac{a^2 + c^2}{2a} \end{aligned}

根据rr及圆心坐标就可以得到圆的轨迹方程:

(xc)2+(ya+r)2=r2(x - c)^2 + (y - a + r)^2 = r^2

根据圆的轨迹方程又可以得到挤压曲线(圆弧)部分yy的求解:

ya+r=±r2(xc)2y0ra>0ya+r=r2(xc)2y=r2(xc)2+ary - 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] \\[1em] \Rightarrow y = \sqrt{r^2 - (x - c)^2} + a - r

由于ac是已知的,然后就能得到rr;因此,当xx确定后,就能得到对应的yy值了;所以挤压曲线上每个点的坐标都可以求出,也就能够进行插值化处理了!

插值化处理

在底边上等间距选取nn个点,根据这些点的xx坐标和已确定的波峰高度就能够得到对应位置的挤压曲线上的坐标点位置(其实主要是yy值,以水平方向挤压曲线为例),然后按顺序连接这些插值得到的挤压曲线上的点,就可以得到近似挤压曲线的线段,这些线段闭合后就符合挤压动画所需的挤压效果了;

img

可以设计函数来根据参数(如插值点个数,波峰高度等)自动生成符合polygan()方法接受的路径格式;如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/**
* 获取弦上一点对应圆弧的高度差
* @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来设置变量值,如:

1
2
3
4
5
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')

然后在关键帧动画相应的位置引用变量即可,这样动态修改变量值后,对应的动画效果也会改变;如:

1
2
3
4
5
6
7
8
9
10
11
@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这个事件(该事件本质是在每次动画开始前触发,但不包括第一次,因此可用来当作动画每次播放结束的触发点);

1
2
3
4
5
6
demo.addEventListener('click', () => {
demo.classList.add('play') // 点击播放动画
})
demo.addEventListener('animationiteration', () => {
demo.classList.remove('play') // 动画一次结束后暂停
})

后话

我承认这种方法有点“硬核”,包含一些数学公式的推导,但实际上用到的知识只是高中数学里面的,过程并不复杂,只不过很久没用有点生疏了;而且第一次推导的时候还弄错了,有点尴尬,不过推导成功还是挺舒服的,最后得到的代码也并不复杂,最重要的是理解了本质问题,又加以应用,还是收获很大的;

img

上面就是推导过程的草稿,好久没写过数学推导了,还是挺有意思的;最后写了一个交互的demo,效果看起来还比较满意,可能动画参数还需要打磨一下;

img

这个交互demo还可以随时调整一些挤压动画的参数,然后查看改变后的效果;demo地址为:A squish animation demo

扩展资料:关于柱面投影变换的思路

相关文档