TweenJs补间动画库了解一下

4,882 阅读7分钟

0️⃣ 背景

小程序中要实现物体飞入动画,效果如下: 屏幕录制2021-03-15 下午3.gif 复杂点:

  1. 曲线运动轨迹。
  2. 动画链。

1️⃣ 技术选型

css动画

CSS 动画(补间动画、关键帧动画)可以在不借助 Javascript 的情况下做出一些简单的动画效果。 你也可以通过 Javascript 控制 CSS 动画,使用少量的代码,就能让动画表现更加出色。

  1. Transition:transition: ; 1. 需要触发时机,使用css伪类选择器 or 动态设置css/style 2. 无法直接实现曲线运动轨迹,需要套娃。简单举例:父元素x轴匀速运动,子元素y轴变速运动。 3. 手动控制动画链,计算delay时间 or 使用sleep方式。
  2. Animation:animation: 1. 小程序下,计算动画起始点后,无法动态修改keyframe 2. 同transition第ii点。 3. 同transition第iii点。

结论:css动画❌

  1. 无法快速实现目标
  2. 代码维护成本高

js动画

JavaScript 动画(逐帧动画),每一帧都需要手动控制,可以处理 CSS 无法处理的事情。 例如,沿着具有与 Bezier 曲线不同的时序函数的复杂路径移动,或者实现画布上的动画。

实现js动画,我们需要关注:

  1. 渲染时机:setTimeout、setInterval、requestAnimationFrame(小程序❌,可以用polyfill)。
  2. 渲染方式:dom、canvas 在我们的应用场景下,可以通过简单数学公式计算每一帧。简单拆解下: 变量:
  • 时间:t
  • 横坐标:x
  • 纵坐标:y 常量:
  • 横轴位移:offsetX
  • 纵轴位移:offsetY
  • 动画总时长:duration 公式:
  • 横轴time-function:x=v*t
  • 纵轴time-function:y=1/2at^2 先用常量算出速度v和a,再反向计算每一帧的x、y位置。 这是我们直接能想到的方式,先埋一个坑

结论:js动画✅

But: 这只是计算位置,还有缩放、透明度等等,有点…(这个需求做不了🤣)。还好tweenjs可以解放你的时间。

2️⃣ tweenjs

tweenjs是一个补间动画引擎。即设定动画的起止状态,也就是关键帧,而关键帧之间的过渡状态由tweenjs计算。也就是说tweenjs只进行计算,与小程序、H5等平台无关。

简介

tweenjs 2011年发布r1,最近更新时2020.10,一直在维护中,且业内有不错口碑。使用方便、api丰富、可以实现包括css animation在内的多种功能。如下简单例子:

import TWEEN from '@tweenjs/tween.js';
export default {
    data(){
        return {
            source: {x: 0, y: 0}
        }
    },
    mounted(){
        // 创建一个tween实例,起点是(0,0)
        var tween = new TWEEN.Tween(this.source);
        // 1s 内, x从0增加到200
        tween.to({x: 200},1000)
        // 启动计算
        tween.start();
        // 监听更新事件,
        tween.onUpdate(() => {
          // 设置css样式,or 绘制canvas样式
          const transform = `translate(${this.source.x}px, ${this.source.y}px)`;
          this.styleObject = {
            transform,
          };
        });
        // 启动动画
        this.animate()
    },
    methods: {
        animate() {
          window.requestAnimationFrame(this.animate);
          // 执行tween计算,可以传入time,如果未传入time则是当前时间
          TWEEN.update();
        },
    },
}

实例方法tween.xxx

tween实例上还有哪些方法:

  • 状态方法:start(启动)、stop(停止)、pause(暂停)、resume(恢复)
  • 控制方法:to(目标)、chain(链式)、repeat(重复)、delay(延迟)、repeatDelay(延迟重复)、yoyo(反弹)、interpolation(补间数组)
  • 事件:onStart(启动)、onStop(停止)、onUpdate(暂停)、onComplete(完成)。

动画组Group

TWEEN类(默认动画组)上还有哪些方法:

  • getAll 获取所有tween实例、removeAll删除所有tween实例
  • add 添加tween实例、remove 删除tween实例、update 更新tween实例

缓动函数Easing

tween内置多种缓动函数,它们按表示的方程式类型分组:Linear(线性),Quadratic(二次方),Cubic(三次方),Quintic(四次方),Quintic(五次方),Sinusoidal(正弦波),Exponential(指数),Circular(圆形),Elastic(弹性),Back(后退)和Bounce(反弹),然后按缓动类型:In(进),out(出)和InOut(进出)。

进阶用法

  • interpolation(数组插值),如下示意,easing负责计算全局进度(0-1),interpolation负责计算局部进度

3️⃣ tweenjs源码浅析

目录结构

  • src
    • Easing.ts # 缓动函数
    • Group.ts # 动画组
    • Index.ts # 对外导出TWEEN和多个方法
    • Now.ts # 获取当前时间函数
    • Sequence.ts # id生成器
    • Tween.ts # tween.js核心逻辑
    • Version.ts # 版本
    • mainGroup: # 默认动画组

核心概念 & 逻辑

时间轴Timing Line

  1. 很巧妙,使用时间计算进度,tween.start()标记开始时间。
  2. tween.update(time)计算当前进度,可以传入time进行时间穿梭。
  3. 也方便stop、pause、resume 操作。

这里填坑,tweenjs使用时间轴来计算。

使用时间计算当前进度 -> 进度缓动函数=实际进度 -> 实际进度(结束值-起始值)+起始值=实际值

动画组Group

  1. 动画组(简称group)由多个Tween实例(简称tween)组成
  2. group.update()会遍历调用tween.update()
  3. tween.update()会改变初始值(简称source)动画组可以通过共用source共享变动后的数据。

因为共享source & 动画组,我们可以实现一些复杂的功能,比如:曲线运动、动态追捕等。

源码浅析

start方法

  // 开始方法
  start(time?: number): this {
    if (this._isPlaying) {
      return this
    }

    // 添加动画组
    this._group && this._group.add(this as any)
    // 重复次数
    this._repeat = this._initialRepeat
    // yoyo方法用到的,待补充
    if (this._reversed) {
      // If we were reversed (f.e. using the yoyo feature) then we need to
      // flip the tween direction back to forward.

      this._reversed = false

      for (const property in this._valuesStartRepeat) {
        this._swapEndStartRepeatValues(property)
        this._valuesStart[property] = this._valuesStartRepeat[property]
      }
    }
    // 运行状态true
    this._isPlaying = true
    // 暂停状态false
    this._isPaused = false
    // startCallback未触发
    this._onStartCallbackFired = false
    // 链式调用未停止
    this._isChainStopped = false
    // 标记开始时间,不传time使用当前时间,time字符串是当前时间+time,其他是time
    this._startTime = time !== undefined ? (typeof time === 'string' ? now() + parseFloat(time) : time) : now()
    // 开始时间 = 开始时间 + 延迟时间
    this._startTime += this._delayTime
    // 设置属性,source对象,起始值,结束值,
    this._setupProperties(this._object, this._valuesStart, this._valuesEnd, this._valuesStartRepeat)

    return this
  }

start方法主要做了:

  1. 初始化状态参数_isPlaying、_isPaused、_onStartCallbackFired、_isChainStopped
  2. 设置内部属性
  3. 将new Tween(source)的source参数和Tween内部属性_valuesStart关联起来,达到动画组内的实例共享source的目的
  4. 设置_startTime

_setupProperties方法

private _setupProperties(
    _object: UnknownProps, // 起始值
    _valuesStart: UnknownProps, // 起始值
    _valuesEnd: UnknownProps, // 结束值
    _valuesStartRepeat: UnknownProps, // 起始值的拷贝,用来实现repeat和yoyo
  ): void {
    // 循环结束值的属性,假设起始值{x: 0, y: 0},结束值{y: 100},遍历到y
    for (const property in _valuesEnd) {
      // 起始值的某一属性的值 x=0
      const startValue = _object[property]
      // 起始值的某一属性的值是否是数组 false
      const startValueIsArray = Array.isArray(startValue)
      // 起始值的某一属性的值的类型 number
      const propType = startValueIsArray ? 'array' : typeof startValue
      // 是否是数组插值
      const isInterpolationList = !startValueIsArray && Array.isArray(_valuesEnd[property])

      // 如果to的属性在source中不存在,不设置
      if (propType === 'undefined' || propType === 'function') {
        continue
      }
      // 数组插值的处理
      if (isInterpolationList) {
        let endValues = _valuesEnd[property] as Array<number | string>

        if (endValues.length === 0) {
          continue
        }
        // 处理相对值
        endValues = endValues.map(this._handleRelativeValue.bind(this, startValue as number))

        // 数组插值将开始值查到结束值前面,方便后续计算。
        _valuesEnd[property] = [startValue].concat(endValues)
      }

      // 深层对象,递归设置。
      if ((propType === 'object' || startValueIsArray) && startValue && !isInterpolationList) {
        _valuesStart[property] = startValueIsArray ? [] : {}

        for (const prop in startValue as object) {
          _valuesStart[property][prop] = startValue[prop]
        }

        _valuesStartRepeat[property] = startValueIsArray ? [] : {} // TODO? repeat nested values? And yoyo? And array values?

       // 递归设置
        this._setupProperties(startValue, _valuesStart[property], _valuesEnd[property], _valuesStartRepeat[property])
      } else {
        // 简单数值,直接赋值
        if (typeof _valuesStart[property] === 'undefined') {
          _valuesStart[property] = startValue
        }

        if (!startValueIsArray) {
          _valuesStart[property] *= 1.0 // Ensures we're using numbers, not strings
        }
        // 初始化初始值拷贝
        if (isInterpolationList) {
          _valuesStartRepeat[property] = _valuesEnd[property].slice().reverse()
        } else {
          _valuesStartRepeat[property] = _valuesStart[property] || 0
        }
      }
    }
  }

_setupProperties方法主要做了:

  1. (递归)赋值,并设置起始拷贝值_valueStartRepeat
  2. 对数组插值进行处理

update方法

update(time = now(), autoStart = true): boolean {
    if (this._isPaused) return true

    let property
    let elapsed
    // 结束时间
    const endTime = this._startTime + this._duration
    // 未到达终点 并且 未在运行
    if (!this._goToEnd && !this._isPlaying) {
      // time > 结束时间,返回false
      if (time > endTime) return false
      // 如果自动开始,则开始
      if (autoStart) this.start(time)
    }
    // 标记未结束
    this._goToEnd = false
    // time 小于 开始时间,返回true,
    if (time < this._startTime) {
      return true
    }
    // 开始事件未触发,则触发。
    if (this._onStartCallbackFired === false) {
      if (this._onStartCallback) {
        this._onStartCallback(this._object)
      }
      this._onStartCallbackFired = true
    }
    // 时间进度0-1
    elapsed = (time - this._startTime) / this._duration
    elapsed = this._duration === 0 || elapsed > 1 ? 1 : elapsed
    // 经过缓动函数计算后的值
    const value = this._easingFunction(elapsed)
    // 更新属性
    this._updateProperties(this._object, this._valuesStart, this._valuesEnd, value)
    // 更新事件触发
    if (this._onUpdateCallback) {
      this._onUpdateCallback(this._object, elapsed)
    }
    // 如果进度满了
    if (elapsed === 1) {
      // 重复次数大于1,减少次数
      if (this._repeat > 0) {
        if (isFinite(this._repeat)) {
          this._repeat--
        }
        // 重新设置开始值,重新开始,startTime = now
        for (property in this._valuesStartRepeat) {
          if (!this._yoyo && typeof this._valuesEnd[property] === 'string') {
            this._valuesStartRepeat[property] =
              this._valuesStartRepeat[property] + parseFloat(this._valuesEnd[property])
          }
          if (this._yoyo) {
            // 交换end和startRepeat的值。
            this._swapEndStartRepeatValues(property)
          }
          // repeat,将备份startRepeat赋值回start
          this._valuesStart[property] = this._valuesStartRepeat[property]
        }
        // yoyo,反转
        if (this._yoyo) {
          this._reversed = !this._reversed
        }
        // 重置startTime并加上repeatDelayTime or delayTime
        if (this._repeatDelayTime !== undefined) {
          this._startTime = time + this._repeatDelayTime
        } else {
          this._startTime = time + this._delayTime
        }
        // 触发onRepeat事件
        if (this._onRepeatCallback) {
          this._onRepeatCallback(this._object)
        }
        return true
      } else {
        // 触发结束事件
        if (this._onCompleteCallback) {
          this._onCompleteCallback(this._object)
        }
        // 开始动画链的下一个tween的动画,并重新设置开始时间。
        for (let i = 0, numChainedTweens = this._chainedTweens.length; i < numChainedTweens; i++) {
          this._chainedTweens[i].start(this._startTime + this._duration)
        }
        this._isPlaying = false
        return false
      }
    }

    return true
  }

update方法主要做了:

  1. 触发onStart事件
  2. 计算更新后的属性值
  3. 检查进度,如果满了
  4. 如果没有repeat,触发onComplete事件,检查是否有动画链,有则调用,结束当前动画
  5. 如果有repeat,处理repeat、delay、yoyo,使用_valuesRepeatStart作为中间值swap or reassign,并更新startTime

4️⃣ 飞入动画实现

  1. 多个金币:小程序无法动态创建dom,v-for产生多个节点
  2. 起止位置:getBoudingClientRect获取金币栏位置,并用top判断是否下拉后飞入 or 直接飞入
  3. 补间动画:tweenjs拆分横向运动和纵向运动,循环设置延时,生成动画链。
// 简单实现,忽略起止位置计算
<template>
  <div>
    <div class="coin-bar" :style="coinBarStyle"></div>
    <div class="coin" v-for="n in coinNum" :key="n" :style="coinStyleList[n]"/>
  </div>
</template>

<script>
import TWEEN from "@tweenjs/tween.js";
export default {
  data() {
    return {
      coinNum: 5,
      source: {
        x: 300,
        y: 400,
        scale: 1,
        opacity: 1,
      },
      target: {
        x: 0,
        y: 0,
        scale: 0.4,
        opacity: 0.4,
      },
      coinStyleList: [],
      coinBarStyle: {},
    };
  },
  mounted() {
    this.init();
  },
  methods: {
    coinBar(source, target) {
      const tween = new TWEEN.Tween(source).to(target);
      tween.onUpdate((res) => {
        const transform = `translate(${res.x}px, ${res.y}px)`;
        this.coinBarStyle = {
          transform,
        };
      });
      return tween;
    },
    coinFly(n = 1, delay = 0) {
      const tween1 = new TWEEN.Tween(this.source)
        .to({ x: this.target.x }, 1000)
        .delay(delay);
      const tween2 = new TWEEN.Tween(this.source)
        .to(
          {
            y: this.target.y,
            scale: this.target.scale,
            opacity: this.target.opacity,
          },
          1000
        )
        .easing(TWEEN.Easing.Cubic.Out)
        .delay(delay);
      tween2.onUpdate((res) => {
        const transform = `translate(${res.x}px, ${res.y}px) scale(${res.scale})`;
        this.coinStyleList[n] = {
          transform,
          opacity: res.opacity,
        }
         
      });
      return [tween1, tween2];
    },
    init() {
      setTimeout(() => {
        // 金币栏降下
        const tween1 = this.coinBar({ x: 0, y: -30 }, { y: 0 });
        let tween2 = [];
        // 金币飞入
        for(var i = 1;i <= this.coinNum; i++){
            const tweens = this.coinFly(i, i*200);
            tween2 = [...tween2, ...tweens];
        }
        // 金币栏上升
        const tween3 = this.coinBar({ x: 0, y: 0 }, { y: -30 });
        // 生成动画链
        tween2[tween2.length-1].chain(tween3)
        const tween = tween1.chain(...tween2);
        tween.start();
      }, 1000);

      this.animate();
    },
    // 执行时机
    animate() {
      requestAnimationFrame(this.animate);
      TWEEN.update();
    },
  },
};
</script>

<style>
.coin {
  position: absolute;
  width: 50px;
  height: 50px;
  background: yellow;
  border-radius: 50%;
  transform: translate(300px, 400px);
}
.coin-bar {
  position: fixed;
  top: 0px;
  height: 30px;
  width: 200px;
  background: blue;
  transform: translate(0, -30px);
}
</style>

5️⃣ 更多

tweenjs还可以做什么?

  1. 配合threejs进行3d渲染
  2. 做数字增长动效
  3. ...

6️⃣ 参考

  1. tweenjs github