乐趣区

关于挤压动画的一种尝试

前言

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

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

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

<!– more –>

尝试

方法一:clip-path + animation

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

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

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

方法二:clip-path + SVG clipPath aimation

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

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

<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 没有效果:

<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 来改变贝塞尔曲线实现挤压动画的尝试失败了……

方法三:matrix()/matrix3d()

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

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

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

改变思路:思考原理

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

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

如图所示,以 矩形区域左下角为原点 ,假设挤压曲线为 一段圆弧,挤压曲线距离原底边最高处的高度(波峰高度)为 $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$ 值,以水平方向挤压曲线为例),然后按顺序连接这些插值得到的挤压曲线上的点,就可以得到近似挤压曲线的线段,这些线段闭合后就符合挤压动画所需的挤压效果了;

可以设计函数来根据参数(如插值点个数,波峰高度等)自动生成符合 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') // 动画一次结束后暂停
})

后话

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

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

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

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

  • 28. 图像扭曲 – 知乎
  • 2D 射影几何和变换——柱面投影,图像拼接柱面投影_yang6464158 的专栏 -CSDN 博客
  • 3d – Warp Image to Appear in Cylindrical Projection – Stack Overflow:有详细的原理推导和代码示例
  • css3 动画的更深层次的探究(矩阵变换)– 简书

相关文档

  • Animating with Clip-Path | CSS-Tricks Flywheel logo
  • javascript – SVG animate path’s d attribute – Stack Overflow
  • html – How to animate clipping paths defined in SVG elements? – Stack Overflow
  • 使用 JavaScript 修改 CSS 变量
  • <basic-shape> – CSS(层叠样式表)| MDN
  • CSSStyleSheet – Web API 接口参考 | MDN
退出移动版