关于挤压动画的一种尝试

前言

前不久在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

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

相关文档

相关文章
相关标签/搜索