在过去的几个月里,我成了 Framer Motion 的忠实粉丝。在研究了如何用它来为我的 styled-components 添加动画效果后,我一直在捣鼓弹簧动画,并重建了好几个 UI 项目中几乎所有组件的过渡和动画效果。当向一些开发同行展示成果时,他们对我用来设置弹簧动画的一些术语和选项(如质量、刚度和阻尼)的含义提出了一些问题。他们中的大多数人都是在不太清楚这些参数如何影响最终动画效果的情况下进行设置的。幸运的是,我大学时学过数学和物理,能够解释这类动画背后的物理原理。

本文旨在解释像 Framer Motion 这样的库中的弹簧动画是如何工作的,其背后的物理定律,以及你可以为弹簧动画设置的不同选项之间的关系。

胡克定律

首先,弹簧动画得名于其运动轨迹遵循弹簧的物理特性,亦即我们所说的简谐振荡器。这个术语及其相关数学原理可能看似复杂骇人,但请稍安勿躁,我将用最浅显的方式逐一解析。在大学时期,我们对简谐振荡器的定义如下:

当偏离平衡位置时,该系统会受到与位移 x 成正比的力(F)的作用。

这种力的公式被称为胡克定律,其定义如下:

F = -k*x

其中 k 是一个被称为劲度系数的正常数,我们也可以将其写作:

力 = -劲度系数 × 位移

这意味着:

  • 如果我们拉伸弹簧(即 x > 0),使其偏离平衡位置一定距离,它就会开始运动
  • 如果我们不拉伸它,它就不会运动(即 x = 0)

不过,你可能在学校或某个科普 YouTube 频道上听说过,力等于物体的质量乘以加速度,这可以用以下公式表示:

F = m*a

其中 m 表示质量, a 表示加速度。

因此,根据这个公式和上面的公式,我们可以推导出:

ma = -kx

这等价于

a = -k *x / m

(加速度 = -刚度 * 位移 / 质量)

现在我们得到了一个方程,可以根据弹簧的位移和附着在弹簧上的物体质量来定义加速度。从加速度我们可以推导出以下结论:

  • 物体在任意时刻的速度
  • 物体在任意时刻的位置

要获取物体的速度,需要将加速度与先前记录的速度相加,这可以转化为以下公式:

v2 = v1 + a*t

(速度 = 原速度 + 加速度 × 时间间隔)

最后,我们可以根据类似原理获取位置:物体的位置等于先前记录的位置加上速度:

p2 = p1 + v*t

(位置 = 原位置 + 速度 × 时间间隔)

在时间间隔方面,作为前端开发者,我们可能更熟悉帧率或"每秒帧数"的概念。考虑到 Framer Motion 动画的流畅性,我们可以推断其弹性动画以每秒 60 帧运行,这意味着时间间隔是恒定且等于 1/600.01666 的。

将数学公式转换为 JavaScript

完成数学计算后,你会发现只要知道物体的质量、弹簧的劲度系数和位移量,就能确定任意时刻(即任意帧)弹簧所系物体的位置。我们可以将上述所有方程转化为 JavaScript 代码,针对特定位移量计算出物体在 600 帧(即 10 秒)内的所有位置。

返回物体沿弹簧运动轨迹位置的函数:

const loop = (stiffness, mass) => {
  /* Spring Length, set to 1 for simplicity */
  let springLength = 1;

  /* Object position and velocity. */
  let x = 2;
  let v = 0;

  /* Spring stiffness, in kg / s^2 */
  let k = -stiffness;

  /* Framerate: we want 60 fps hence the framerate here is at 1/60 */
  let frameRate = 1 / 60;

  /* Initiate the array of position and the current framerate i to 0 */
  let positions = [];
  let i = 0;

  /* We loop 600 times, i.e. for 600 frames which is equivalent to 10s*/
  while (i < 600) {
    let Fspring = k * (x - springLength);

    let a = Fspring / mass;
    v += a * frameRate;
    x += v * frameRate;

    i++;

    positions.push({
      position: x,
      frame: i,
    });
  }

  /**
   * positions is an array of number where each number
   * represents the position of the object in a spring
   * motion at a specific frame
   *
   * We use this array to plot all the position of the
   * object for 10 seconds.
   */
  return positions;
};

考虑到阻尼效应

在观察实际效果时,你或许会疑惑:为何这个弹簧动画永无止境,与你使用 Framer Motion 时体验到的效果截然不同?这是因为我们用于生成物体位置的数学公式未考虑摩擦力与热能因素。若想实现自然流畅的弹簧动画,应当观察到物体运动随时间推移逐渐减速,直至完全静止——这正是阻尼概念登场之时。当你在查阅 Framer Motion 文档时可能见过这个术语,并好奇其含义及对弹簧动画的影响,以下便是我们的定义方式:

阻尼是一种通过消耗能量来减缓并最终停止振荡的力

其公式为:

Fd = -d * v

其中 d 为阻尼比, v 为速度

(阻尼力 = 负阻尼系数 × 速度)

考虑阻尼因素后,我们需对第一部分建立的加速度公式进行调整。已知

F = m*a

然而,此处的 F 等于弹簧力与阻尼力之和,而非仅弹簧力,因此:

Fs + Fd = m*a -> a = (Fs + Fd)/m

现在我们可以将这个新公式添加到之前展示的 JavaScript 代码中:

考虑阻尼比的更新函数:

const loop = (stiffness, mass, damping) => {
  /* Spring Length, set to 1 for simplicity */
  let springLength = 1;

  /* Object position and velocity. */
  let x = 2;
  let v = 0;

  /* Spring stiffness, in kg / s^2 */
  let k = -stiffness;

  /* Damping constant, in kg / s */
  let d = -damping;

  /* Framerate: we want 60 fps hence the framerate here is at 1/60 */
  let frameRate = 1 / 60;

  let positions = [];
  let i = 0;

  /* We loop 600 times, i.e. for 600 frames which is equivalent to 10s*/
  while (i < 600) {
    let Fspring = k * (x - springLength);
    let Fdamping = d * v;

    let a = (Fspring + Fdamping) / mass;
    v += a * frameRate;
    x += v * frameRate;

    i++;

    positions.push({
      position: x,
      frame: i,
    });
  }

  return positions;
};

我们现在有了一个弹簧动画,由于阻尼作用将能量从系统中耗散,它最终会停止。上方的图表通过向最终“静止位置”收敛展示了这一点。将阻尼滑块调至较高值时,你可以观察到受弹簧动画影响的对象趋向于以远低于低阻尼值的速度收敛至“静止位置”。

一个现实生活中的例子

默认情况下,根据文档说明,Framer Motion 将弹簧动画的刚度设置为 100,阻尼设置为 10,质量设置为 1。下面我编写了一个更接近实际应用场景的动画组件 Button ,您可能会在 UI 项目中用到它。既然您已经了解了质量、刚度和阻尼的含义,现在可以尝试微调您的弹簧动画了。

为保持文章简洁,我略去了 Framer Motion 为弹性动画提供的其他选项,例如:

  • 速度:在上述示例中,我将初始速度视为等于 0
  • 静止速度
  • 剩余差值

这些参数都在文档中有定义,我建议您将它们添加到上面的交互演示中,观察它们如何影响最终动画效果。

本文译自 https://blog.maximeheckel.com/posts/the-physics-behind-spring-animations/ (有删改,demo见原文)


A Student on the way to full stack of Web3.