有关补间动画库的奇思妙想

2023-03-18coding动画

补间动画库是很常见的东西,那么直接让我们开始吧。

Animate(0, 100, 1000);

接着,我们试着让这个动画来回运行。

Animate(0, 100, 1000)
  .transform('yoyo');

它只能在 x 轴上移动吗,要不拓展一点,让它先在水平方向移动,再往竖直方向移动?

Animate({
  top: 0,
  left: 0,
  opacity: 1
}).to({
    left: 100
  }, 1000, Easing.Cubic.InOut)
  .to({
    top: -120,
    opacity: 0
  }, 600, Easing.Quadratic.Out)
  .transform('yoyo')

Amazing! 它做到了,在多段动画里,我们让它来回移动的命令依旧还在生效。

那么在以上的基础上,我们试着让它一步步地变化

Animate.transform('step', 20)

Wow~

事实上,在上面这些动画里,其实还有一个没有展示出来的逻辑:

Animate.transform('loop', Infinity);

因为这样,它才能够一直重复运行。

以上这些就是通过 tween-animate 这个动画补间库实现的。

tween-animate

github 地址 - https://github.com/voderl/tween-animate/

import Animate from 'tween-animate';

const { Easing } = Animate;

// internal global requestAnimationFrame start
Animate.play();

Animate(0, 10, 1000)
  .transform('yoyo')
  .transform('step', 20)
  .on('update', v => console.log(v));

// console.log
// 0 1 2 3 4 5 6 7 8 9 10 9 8 7 6 5 4 3 2 1 0

了解更多以及尝试,前往 Codepen.io

get start

<script src="https://unpkg.com/tween-animate@4/dist/tween-animate.umd.js"></script>
npm install tween-animate
yarn add tweeen-animate

具体使用文档参照:https://github.com/voderl/tween-animate/

在这里就不去再重复一遍使用方法了。

本文的话是会按照时间顺序,写写 tween-animate 的设计思路,每次重构的想法,告诉大家它为什么能够变成今天这个样子。

让我们先简单梳理下时间线吧:

  • v1 - 20.05
  • v2 - 20.10
  • v3 - 21.04
  • v4 - 23.03

最开始为什么要做这个呢?

v1

BingGhat 来给我介绍下目前 js 的补间动画库吧

Q:js 补间动画库推荐

BingChat: 你好,这是 Bing。我可以帮你找到一些 js 补间动画库的推荐。根据搜索结果,有以下几个比较好用的 js 补间动画库:

GreenSock:一个强大的 JavaScript 工具集,可以让开发者轻松创建各种现代化的 web 动画。它关注性能、兼容性和支持,并提供了多种插件和效果。

tween.js:一个简单的 JavaScript 补间动画库,使用优化过的 Robert Penner 的方程式。它可以补间数字对象属性和 CSS 样式属性,并支持链式调用和自定义缓动函数。

TweenJS:一个简单的补间动画库,用于 JavaScript。它与 EaselJS 库很好地集成,但不依赖或特定于它。它支持补间数字对象属性和 CSS 样式属性,并提供了简单但非常强大的 API。

你可以点击搜索结果中的链接来了解更多详情和示例。

比较轻量的补间动画库是后两个,在最开始时我主要参考的是第二个。

它有以下不能满足我的地方:

  • 没有使用 eventemitter, 只能通过 onUpdate、onComplete 来监听一个函数
  • tween.js 里的动画转换,如 loop,yoyo 等是不能嵌套使用的。

所以在最开始时,我其实主要想做两部分工作。

这样我可以通过 animate.on('update', (v) => console.log(v)) 来监听多个函数

  • 实现一个可以嵌套变化的动画。

比如 animate.loop(2).loop(2) 其实是把一个动画循环了 4 遍。 又如 animate.yoyo().loop(2)animate.loop(2).yoyo() 是两个完全不同的动画。

但在实现的过程中,又遇到了些问题,越来越多的想法冒了出来。

  • 在每次更新状态时,tween.js 都会进行一次遍历来更新需要变化的值。那我能不能预先计算出需要变化的属性。

比如在动画创建时,进行一次遍历,比如 {x: 0} => {x: 1} 会生成一个数组结构 [{ key: 'x', from: 0, step: 1}]。在之后的每帧刷新中,只需要遍历一下数组结构就能得到需要改变的值了。

  • 既然它是一个动画,那么我是不是应该先写 to,然后再拿到 from ?这样的话我可以把一个动画同时应用到多个元素上。

就这样,我把两个概念区分开了。我们最开始通过 to 来创建一个动画 Animate, 当调用 Animate.apply(from1, from2) 时才会生成动画 AnimateInstance。

这样的话可以支持把一个动画应用到多个元素上,新问题来了,我们的 to 是不是应该需要支持函数。

我们能不能直接把动画应用到 dom 元素、字符串、颜色上,是不是需要有一个适配层来做一次解析

最终 v1 所设计的实现就是这个样子:

const { Animate, update, Easing, Transform, List } = tween;

const move = Animate({
  from(v) {
    return (el) => {
      return {
        x: parseInt(el.style.left) || 0,
        y: parseInt(el.style.top) || 0,
      };
    };
  },
  to(v) {
    return {
      x: v.x,
      y: v.y,
    };
  },
  easing: Easing.Quadratic.In,
  time: 1000,
  update(el, v) {
    el.style.left = `${v.x}px`;
    el.style.top = `${v.y}px`;
  },
  assign: false, // is directly change from object
});
const moveYoyo = move.extend(Transform.yoyo(), Transform.loop(Infinity));
moveYoyo.render({ x: 300, y: 150 }).apply(greenBox, redBox);

v1 的 Codepen 地址:https://codepen.io/voderl/pen/LYGYYeM

但在本质上,不管是动画的 transform 如 loop、yoyo 还是多个动画的组合。其实都是基于 AnimateInstance 内部的事件回调。内部的事件其实是包括:

  • start - 动画开始
  • begin - 动画每次开始
  • end - 动画每次结束
  • complete - 动画结束

每一次动画播放完成就会触发一次 end,在 end 后会根据条件判断是否触发 complete,如果触发 complete 就结束了,否则动画会重复播放。

loop 的实现,其实就是内部维护一个 loopCount 当 AnimateInstance 触发 end 时,计数减 1,直到为 0 的时候才触发动画的 complete。

Transform.register('loop', function (animate, num) {
  animate.on('start', (instance) => {
    instance.loopCount = num;
  });
  animate.on('end', (instance) => {
    instance.loopCount--;
    if (instance.loopCount <= 0) instance.isCompleted = true;
    else {
      instance.isCompleted = false;
    }
  });
});

能支持嵌套的原理,就是每做一次 transfrom,都在原来动画的上面再包一层控制层,叫 AnimateInstanceWrapper,每一层又有独立的 loopCount,底层在触发 complete 时,上一层触发 end 事件,所以支持了动画的嵌套实现。

总体上,这部分功能是比较取巧和脆弱的。实现的 transform 功能也只有 loop,reverse,yoyo。

还有一些隐藏的坑点。假设有一个动画 10ms,你让它 loop 100 次,当时间每次更新间隔是大于 10ms 时,动画其实只会更新 10ms,因为本次的更新只会让一次的动画去结束,而下一次动画没有去继承延续的时长。

如果说我们让它去继承延续的时长,那么当你的 loop 比较大时,你离开页面一会,等再回到页面后,你就会发现这个报错:Maximum call stack size exceeded。 对的,循环太多导致爆栈了。

当然这部分问题也是 tween.js 都存在的,就像丑陋的小乌云飘在空中。

v2

v2 的话做了一部分重构工作。使用方法有一定的变更,但整体功能相比于 v1 没啥区别。

其中最重要的其实是改变了预处理的逻辑,在 v1 中,我们会把相关的值放到数组里,在更新时遍历数组。
而 v2 的预处理则更进一步,通过 new Function 把数组编译到 js 的函数。

parseFromTo(
  { x: [1, 2, 3], y: 1 }, // from
  { x: [1, 2, 6], y: 3 }, // to
  { isAssign: false },
);
// 得到实际更新函数如下
/** 由from和to得到更新函数
 * @param a - from
 * @param b - status - range 0 - 1
 */
function anonymous(a,b) {
  var a={};var a_x=a["x"]=[];a_x["2"]=3+b*3;a["y"]=1+b*2;return a;
}
// 当 isAssign 为 `true` 时(默认为true);
function anonymous(a,b) {
var a_x=a["x"];a_x["2"]=3+b*3;a["y"]=1+b*2;return a;
}

性能优化显著。也正是在这一版本里,把 github 仓库中的描述改成了 faster tween library。

v3

v3 的话继续进行代码重构,给了更完善的 ts 支持。

v3 的重构主要是改变了 transform 的逻辑,之前每做一次 transform 就嵌套一层 AniamteWrapper 的逻辑终于被干掉了。

这次的话使用更偏向于函数式,相信这句话一出大家估计就明白一点了。

补间动画,其核心就是在于它的 tween 函数,在 v2 的重构中,我们根据 from 和 to 会预编译生成对应的 tween 函数,它接受一个 status,根据 status 去改变当前的状态。

那我们的 transform 比如 loop,reverse 等,是不是也可以由之前的 tween 函数重新包装成一个新的 tween 函数呢,显然是可以的,以 loop 为例:

register('loop', (ins: AnimateInstance, count) => {
  const _fullTime = ins.fullTime;
  ins.fullTime *= count;
  const tween = ins.tween;
  // 如果我们 loop(Infinity),status 不管怎么样都是 0
  // 所以我们仍然需要传递 duration 等参数
  ins.tween = (status: number, duration: number, fullTime: number) => {
    if (status >= 1) return tween(1, _fullTime, _fullTime);
    const _duration = duration % _fullTime;
    return tween(_duration / _fullTime, _duration, _fullTime);
  };
});

通过这种方法,我们可以轻而易举地去掉 Wrapper 这种反人类的东西,我们可以避免无穷无尽的监听事件和回调,不管时间过去多久,都能立即找到当前的状态。

同时,基于此,我们还可以去掉动画中不必要的属性,比如 isReversed、loopCount,这些都不再需要。

我们第一次可以把动画的事件变得如此简洁,start、update、complete,就这些,感觉一身轻松。

我们还可以增加更多的 transform,比如 step,同时还支持自定义 transform。

同时在 v3 中,开始引入了测试用例。在一定程度上提高了稳定性。

v3 的话在概念上依旧保留了 Animate 和 AnimateInstance, Animate 的每一次 transform 也会生成不同的实例。

使用用法大概是这样的:

import Animate from 'tween-animate';

const { Easing } = Animate;

// internal requestAnimationFrame start
Animate.play();

const animate = Animate({
  x: 100,
  y: (v) => v + 100,
}, {
  time: 1000,
  easing:Easing.Quadratic.In,
  isAssign: false,
}).transform('yoyo').on('update', e => console.log(e));

animate.apply({
  x: 0,
  y: 0
});
// tween to {x: 100, y: 100}

Codepen expmple: https://codepen.io/voderl/pen/PozZBXj

 

v4

终于讲到 v4 了,需要短暂休息一下了hh。

其实 v3 在 21 年 5 月后基本就不去更新了,那为什么又搞出来个 v4 呢。实话就是因为 v3 很难用,这是我在开发一个网页时用到了它后意识到的,时间是在 22 年 10 月份。

虽然我最终用 v3 实现了相关功能,但我不得不在引入的地方含泪写下:

// tween-animte is an unstable library now, please don't use it in production. you can replace it with another tween library.
import Animate, { AnimateOptions, AnimateInstanceType } from 'tween-animate';

这促使我需要再重构一个新的版本。终于在 23 年 3 月份,我启动了 v4 的开发。

主要的改动是下面的几点:

  • 去掉 Animate 和 AnimateInstance 的区分。按照之前版本的逻辑,需要先通过 to 创建一个 Animate,再通过 apply(from) 来创建一个 AnimateInstance 才能开始动画的。

为什么要去掉这部分呢,因为这部分是导致很难用的罪魁祸首。需要先设置 to,再设置 from,这一点就不符合人的思维结构。

在很多场景中,to 是基于 from 的,所以先 from 再 to 是一个自然而然的事情。Animate 在大多数场景中是一个用完即销毁的东西,也没有必要去考虑复用。

同时 Animate 和 AnimateInstance 有很多相同的 api,比如 eventListener、transform,这一点很容易让人搞混与困惑。

  • 支持了多段动画。

在之前的版本中实现多段动画,你可以在 A 动画触发 complete 事件后触发 B 动画。 但这里的多段动画是指合成为一个动画,支持 transform 的所有方法。

设计思路

我把补间动画库重新定位成一个生成动画元的库。

不管动画是应用到 dom,string,还是颜色。这点都和我没有关系,因为我只是生成一个正在 tweening 的值就可以了,你可以在 update 方法中监听到相关值的改变。

基于这个思路,完善了 update 的事件触发逻辑。

Animate(0, 100, 1000).on('update', v => console.log(v));
// 会在最开始时触发值为 0 的 update,会在结束时触发值为 100 的 update

应用到多个元素,应用到函数都不过是写一个 map 的事情。

const str = `会有清亮的风使草木伏地...`
Animate(0, str.length, str.length * 200)
  .on('update', (v) => {
    const count = Math.floor(v);
    el.innerText = str.slice(0, v);
  })

同时简化了一些动画的配置项,只保留了 time 和 easing。 比如 isAssign 来判断是否改变之前的值。这一点其实是没有必要的,如果不想改之前的值,通过 pick 等方法新建一个对象再生成 Animate 就可以了。

简化后的动画就是下面的写法:

Animate(from, to, time, easing);
Animate(from).to(to, time, easing);

在一个动画中支持多段动画这个还是有点难度的。这点意味着它的原理需要和 transform 类似,需要拿到多段动画的 tween 函数,再生成一个全新的 tween 函数。

举例如下:

Animate({
  x: 0,
  y: 0,
  z: 0
}).to({
  x: 100,
  y: 100,
}, 1000).to({
  z: 100
}, 1000);

可能初看起来比较好实现,分时嘛,不同时间转到不同的 tween 函数里去。但如果你真的这样实现了一版就踩到坑里去了 = =

比如上面的那个例子,第一段动画,x 和 y 从 0 变到 100, 第二段动画 z 从 0 变到 100。 那在第 990ms 时触发一次更新,此时值为 { x: 99, y: 99, z: 0},下次更新时间为 1024ms, 这时如果仅仅只是转到第二段的 tween 函数后,值应该是 { x: 99, y: 99, z: 2.4},但我们需要的值应该是 { x: 100, y: 100, z: 2.4}

是的,我们会遇到临界条件。

那我们能不能加一个状态标志着现在进行到哪一个动画了呀?如果进行到下一段了就先把值更新为前面那一段的临界条件。

这个思路也是不可行的,比如它一下子从开始直接跳到结束,中间有很多动画实际都没播放。那这个时候,中间的一些动画变化了一些特有的值,你要怎么保证最后结束时所有值都和预期一致?

又比如你的 status 有可能是从 0 到 1,但也有可能是从 1 到 0,你是拿不到当前播放的方向的。那么前面那一段的临界条件是最左边呢还是最右边呢。

我们需要的是在没有任何前置条件的时候,从任一 status 都能得到正确的当前的值。

难道我们真的只能在变化时将所有的 key 都做一次更改吗?像下面一样:

Animate({ x: 0, y: 0, z: 0 })
  .to({ x: 100, y: 100, z: 0})
  .to({ x: 100, y: 100, z: 100})
// 同时我们还要确保在预编译时 from 和 to 的值一样的时候不能跳过,需要保持赋值

这样的实现也太丑了吧。。。

还有没有别的实现呢?

其实是有的。 我们假设有一个动画是 A -> B -> C -> D,每一个字母都意味着一个变化过程。 拿到当前的播放时间,假如播放到过程 B 了。我们仅需要像下面这样调用就可以确保当前值正确。

// B 之前的
A.tween(1);
// B 之后的
D.tween(0);
C.tween(0);
// B 当前动画
B.tween(status);

因为每一段过程都可能有当前过程不包含的值,因此为了确保所有值都正确,我们需要都调用一遍。最后调用当前过程的值去做一次覆盖。

不过这样一下子一个 tween 函数里需要同时调用多个 tween 函数了,也是不合理的,因此我们还需要做一些优化。

我们可以给每一段过程加一个 lastStatus 标志,如果执行了一次 0 临界条件,lastStatus 就改为 0,之后就不再执行 0 临界条件。1 临界条件同理。如果当前过程正在执行,lastStatus 改为 null。

那么从一个过程跳转到另一个过程,一定至少有一个最近的过程发生了 lastStatus 改变。如果最近的过程里改了当前过程里没有的值,那么该值一定在当前过程的两边的临界条件保持一致,只需要一个最近的过程执行临界条件即可。如果最近的过程改了当前过程里有的值,那在最后执行当前过程时会把值给覆盖成最新的。

这部分逻辑暂时没发现问题,最终实现的效果也比较完美。代码实现如下:

function chain(
  ins: AnimateInstance,
) {
  ...
  ins.fullTime = fullTime;

  const tweenFunctionList: Array<{
    startTime: number;
    endTime: number;
    fullTime: number;
    lastStatus: null | number;
    tween: (status) => any;
  }> = [...]

  ins.tween = (status: number, duration: number) => {
    let hitIdx = -1;
    const len = tweenFunctionList.length;
    for (let i = 0; i < len; i++) {
      const item = tweenFunctionList[i];
      if (duration > item.endTime) {
        if (item.lastStatus !== 1) {
          item.tween(1, item.fullTime);
          item.lastStatus = 1;
        }
      } else {
        hitIdx = i;
        break;
      }
    }
    for (let i = len - 1; i > hitIdx; i--) {
      const item = tweenFunctionList[i];
      if (item.lastStatus !== 0) {
        item.tween(0, 0);
        item.lastStatus = 0;
      }
    }
    const currentItem = tweenFunctionList[hitIdx];
    const _duration = currentItem.fullTime - (currentItem.endTime - duration);
    if (currentItem.lastStatus !== null) currentItem.lastStatus = null;
    return currentItem.tween(_duration / currentItem.fullTime, _duration);
  };
}

尾声

终于看到了这里,让我们再回看一下前面的动画,会不会有一些不同感受呢?

Animate({
  top: 0,
  left: 0,
  opacity: 1
}).to({
    left: 100
  }, 1000, Easing.Cubic.InOut)
  .to({
    top: -120,
    opacity: 0
  }, 600, Easing.Quadratic.Out)
  .transform('yoyo')

感谢看到这里 (感觉大部分人应该看不到这里了hhh,如果看到这里了就麻烦点个赞吧~

以上的所有 demo 都能通过开发者工具拿到具体实现代码。

tween-animate 还不是一个成熟的库,欢迎使用,遇到问题欢迎来提 issue

github 地址:https://github.com/voderl/tween-animate

点赞 0