让你的动画「时间倒流」

「本文已参与好文召集令活动,点击查看: 后端、大前端双赛道投稿,2万元奖池等你挑战!

前言

我们在做网站或者游戏的时候, 一定见过很多很酷炫的「缓动」动画.

什么是缓动动画呢?

在一定频率内, 不规律的修改目标对象的属性值, 使其做平移、旋转、缩放、显隐等操作的动画.
你一定用过 CSS 的一些属性:

  • transition
  • animation

都可以达到动画的效果.

但是, 今天我们要来聊的是如何制作一个「时间轴动画」.
当你的动画播放完或者播放到一半的时候, 你想要使动画继续「缓动」恢复到原来的形态或者中途的某一状态.
那这个时候你有什么比较好的思路吗?
可能这样说有点意思不明确, 我们先来看一张动图:
test1.gif

想要达到图像中的效果, 我们该怎么做呢?
接下来给大家介绍如何从零到一写一个「时间轴动画」库.

一、核心思路

我们这个「时间轴动画」的原理, 就是在时间轴上, 添加不同时段的「任务」,
「任务」就是每一个动画, 「任务」具有开始时间, 持续时间.

背景.png

实现思路已经初具雏形了, 那我们再深入思考一下:

首先, 我们需要创建一个全局的时间轴, 时间轴具有时间长度的属性,

其次, 我们需要添加缓动动画, 缓动动画需要以下几个参数:

  • target 我们执行动画的目标
  • paused 动画是否是暂停状态
  • delay 动画开始时间
    同时, 我们的动画需要做线性运动, 需要有持续时间.
  • 对象上的线性属性
  • 动画持续时间

我们考虑实现一个「时间轴动画库」, 命名为TweenTimeLine.
在初始化实例的时候, 注册一个动画. 同时需要声明一个静态数组_timeTweens用于存储每一个动画.
接下来我们需要实现三个个方法:

  • add 把单个动画添加到时间轴中
  • to 实现动画的缓动走向
  • seek 触发时间节点

最重要的问题来了

我们怎么使动画动起来.

我们现在使用to方法可以得到一段动画的开始状态和结束状态以及开始时间和持续时间.

使用seek可以得到当前的时间节点.

那我们通过这些「已知条件」能不能计算出我们当前时间节点处在的动画状态呢?

答案是可以的!

已知条件:

  • 一段动画初始时间 t₀
  • 一段动画持续时间 dt
  • 对象开始属性 v₀
  • 对象结束属性 v₁
  • 当前时间 ct

可以得出相对于初始状态的增量.

∆v = (ct - t₀) / dt * (v₁ - v₀)
复制代码

想通了这一点, 我们就可以用代码来实现了.

二、代码实现

我们现在只是为了完成上面的动画效果, 定义接口和方法的时候从简, 以后再慢慢完善这个库.

1、声明两个简单接口

用来定义构造器传入参数的类型以及声明动画属性值类型.

/**
 * 时间轴动画参数
 * @export
 * @interface TweenTimeLineProps
 */
export interface TweenTimeLineProps {
  /** 暂停状态 */
  paused?: boolean;
  /** 动画延迟 */
  delay?: number;
}
/** 缓动参数 */
export type AniProps = { [k: string]: number }
复制代码

2、创建一个TweenTimeLine

首先, 我们需要先定义一个TweenTimeLine类.

它具有一个私有属性_target, 每一个TweenTimeLine都有一个需要操作的对象.

接下来, 我们定义两个私有方法:

  • to 定义动画的开始到结束, 以及持续时间
  • render 更新动画状态

最后, 定义两个静态方法:

  • add 将每一段动画添加到时间轴
  • seek 从外部触发, 监听到每次的时间节点

很清晰的可以看到我们这个库的结构如下:

export default class TweenTimeLine {
    /** 进行缓动的对象 */
    private _target: Object = null;
    constructor(target: Object, props: TweenTimeLineProps) { }

    /**
     *   缓动的最终状态
     * @param {AniProps} props
     * @param {number} [duration]
     * @memberof TweenTimeLine
     */
    public to(props: AniProps, duration: number = 0) { }
    /**
     *  跳到时间轴某个时间点
     * @static
     * @param {number} delay
     * @memberof TweenTimeLine
     */
    public static seek(delay: number): void { }

    /**
     * 时间轴渲染
     * @private
     * @param {number} delay
     * @memberof TweenTimeLine
     */
    private render(delay: number) { }

    /**
     *  添加动画
     * @private
     * @param {TweenTimeLine} act
     * @memberof TweenTimeLine
     */
    public static add(act: TweenTimeLine) { }
}
复制代码

3、构造函数

我们初始化一个时间轴动画, 存储目标对象, 以及动画开始时间、暂停状态.

const { paused = false, delay = 0 } = props;
this.paused = paused;
this.startDelay = delay;
this.durationSum = this.startDelay;
复制代码

durationSum为动画总的持续时间, 初始值为传入的参数delay.

4、实现 to 方法

to方法主要是用来定义一段动画的结束状态和持续时间.
to可以有多段. 意味着一个时间轴动画可以具有多次动画状态改变.

这个方法我们主要做两件事:

  • 1、存储初始以及最终动画的状态.
  • 2、把每一个动画的步骤都记录一下.

定义两个私有方法:

this._appendProps(nextProps);
this._addStep({ duration, curProps, nextProps });
复制代码

定义_appendProps方法来存储最后的动画状态和初始状态:

for (let i in props) {
  this._origintargetProps[i] = this._curTargetProps[i] = this._target[i];
  this._curTargetProps[i] = props[i];
}
复制代码

定义_addStep方法存储每一步的动画到一个数组_steps:

this._steps.push(props);
props.startFlagTime = this.durationSum;
this.durationSum += props.duration;
复制代码

startFlagTime为动画开始时间, durationSum为多段to动画步骤的总的持续时间.

5、实现 render 方法

首先, 当时间节点小于 0 时, 我们默认等于 0;

如何判断动画结束

当外部触发的时间节点大于持续时间总和时, 动画结束.

//动画完成-需要暂停
if (_t >= this.durationSum) {
  _t = this.durationSum;
  end = true;
}
复制代码

当处于动画持续时间区间时, 可以做动画.

if (this._target && this._steps.length > 0) {
  let _l = this._steps.length;
  let _curStepIndex = -1;
  //找到当前动画
  for (let i = 0; i < _l; i++) {
    _curStepIndex = i;
    if (
      this._steps[i].startFlagTime <= _t &&
      this._steps[i].duration + this._steps[i].startFlagTime >= _t
    ) {
      break
    }
  }

  //更新属性
  if (_curStepIndex >= 0) {
    let step = this._steps[_curStepIndex];
    if (_t >= step.startFlagTime && _t <= step.startFlagTime + step.duration)
      this.updateTargetProps(
        step,
        Math.min((_t - step.startFlagTime) / step.duration || 0, 1),
      )
  }
}
复制代码

通过判断传入时间节点大于开始时间, 同时传入的「当前时间节点 < 开始时间 + 持续时间」, 得到当前的动画区间.

if (
  this._steps[i].startFlagTime < _t &&
  this._steps[i].duration + this._steps[i].startFlagTime >= _t
) {
  break;
}
复制代码

然后定义一个更新目标对象属性的方法updateTargetProps.

private updateTargetProps(step: any, ratio: number) {
    let { curProps, nextProps } = step;
    let v0: number, v1: number, delatV: number;
    if (!step && ratio == 1) {
        curProps = nextProps = this._curTargetProps;
    }
    for (let i in this._origintargetProps) {
        if ((v0 = curProps[i]) == null) {
            curProps[i] = v0 = this._origintargetProps[i];
        }
        if ((v1 = nextProps[i]) == null) {
            nextProps[i] = v1 = v0;
        }
        const delta = (nextProps[i] - curProps[i]) * ratio;

        let deltaV: number;
        deltaV = v0 + delta;
        this._target[i] = deltaV;
    }
}
复制代码

这里就是动画如何动起来的实现了.
可以通过「已知条件」计算得出:

ratio = Math.min((_t - step.startFlagTime) / step.duration || 0, 1);
delta = (nextProps[i] - curProps[i]) * ratio;
deltaV = v0 + delta;
this._target[i] = deltaV;
复制代码

这样就改变了_target的属性值了.

那我们要如何去调用render方法呢?

6、实现 add 方法

我们定义静态方法add, 用来往时间轴中添加动画.

let tweenAnis: TweenTimeLine[] = TweenTimeLine._timeTweens;
if (tweenAnis.indexOf(act) == -1) {
  tweenAnis.push(act);
}
复制代码

定义一个_timeTweens数组存放我们所有的动画.
那我们要更新每一段动画, 是不是就只需要遍历TweenTimeLine._timeTweens数组, 调用他自己的render方法就可以了.

7、实现 seek 方法

seek方法主要用来查找传入的时间节点对应的动画.

let aniList: TweenTimeLine[] = TweenTimeLine._timeTweens.concat();
let _l = aniList.length - 1;
for (let i = _l; i >= 0; i--) {
  let ani: TweenTimeLine = aniList[i];
  ani.render(delay);
}
复制代码

现在我们就可以执行seek来跳转到我们对应的动画状态了.
当然了, 我们按一定的频率连续调用seek就可以实现动画的播放、暂停和倒退了.
接下来, 我们来看具体的用法.

三、实践应用

我们就来实现一下上面动图中的文字效果吧.
先准备一首小诗, 唐代骆宾王的《咏鹅》.

这里我们使用 canvas 引擎绘制视图. 这里的引擎基本上市场上的都可以, pixi,phaser,egret等.
毕竟这个「时间轴动画」只是一个库. 操作的只是一个对象上的线性属性.

1、配置文字

想要实现上面, 每个文字不同的入场方式, 我们需要先定义一个文字的配置项.
声明文字的 属性:

  • text 文字
  • fill 文字颜色
  • blod 加粗
  • x 位置
  • y 位置
  • size 字体大小
  • textAligin 是否居中
const textCfg = [
  {
    text: '咏鹅',
    fill: '#000000',
    blod: true,
    x: 250,
    y: 420,
    size: 50,
    textAligin: 'center',
  },
  ...此处省略
  {
    text: '红掌拨清波。',
    fill: '#000000',
    blod: true,
    x: 250,
    y: 440,
    size: 40,
    textAligin: 'center',
  },
]
复制代码

2、创建文字实例

使用引擎的 API 创建文字, 遍历每一个文字, 并赋予他们对应的属性值.
以下例子中的GAME.TextField()是自研引擎中的, 和其他诸如pixi中的文字API差不多.

let textList = [];
for (let i = 0; i < textCfg.length; i++) {
  let curText = textCfg[i];
  var allText = curText.text.split('');
  let _x = 0;
  if ((curText.textAligin == 'center') != null) {
    _x = (750 - allText.length * curText.size) / 2;
  }
  //每一个文字
  allText.forEach((item, index) => {
    let realY = curText.y + (curText.size + 20) * i;
    let _text = con.addChild(new GAME.TextField());
    _text.x = curText.textAligin;
      ? _x + curText.size * index
      : curText.x + (curText.size + 15) * index;
    _text.y = realY;
    _text.text = item;
    _text.size = curText.size;
    _text.fillColor = curText.fill;
    _text.alpha = 0;
    _text.rotation = 0;
    _text.textWidth = curText.size;
    _text.anchorX = _text.anchorY = 0.5 * curText.size;
    _text.textAlign = FYGE.TEXT_ALIGN.CENTER;

    textList.push(_text);
  })
}
复制代码

然后为了方便, 我们在每一个文字上挂载 2 个动画的最终的属性.

let _random = Math.random();
let status1 = {
  alpha: 0,
  x: curText.textAligin
    ? _x + curText.size * index
    : curText.x + (curText.size + 15) * index,
  y: _random > 0.5 ? realY - Math.random() * 180 : realY,
  rotation: _random > 0.6 ? 360 * Math.random() : 0,
};
let status2 = {
  alpha: 0,
  x: curText.x + (curText.size + 15) * index + 500,
  y: realY,
  rotation: 0,
};
let status3 = {
  alpha: 0,
  x: curText.textAligin
    ? _x + curText.size * index
    : curText.x + (curText.size + 15) * index,
  y: 0,
  rotation: 0,
  scaleX: 0.1,
  scaleY: 0.1,
};
if (i == 0) {
  _text['to1'] = status3;
} else if (i == 1) {
  _text['to1'] = status2;
} else {
  _text['to1'] = status1;
}

_text['to2'] = {
  alpha: 1,
  x: curText.textAligin
    ? _x + (curText.size + 10) * index
    : curText.x + (curText.size + 15) * index,
  y: realY,
  rotation: 0,
  scaleX: 1,
  scaleY: 1,
};
复制代码

status1status2status3分别对应三种动画状态.
这样子, 我们就在页面上渲染出这一首古诗了.

WechatIMG291.png

万事具备, 只欠东风. 抬出我们上面写的「时间轴动画」库.

3、加入时间轴动画

我们现在就仨方法, 一个to, 一个add, 一个seek;
把我们之前文字列表添加进时间轴.

textList.forEach((item, index) => {
    let timeLine = new TweenTimeLine(item, { paused: false, delay: 0 / 1624 * 2 });
    timeLine.to(item.to1, 0 / 1624 * 2);
    timeLine.to(item.to2, 200 / 1624 * 2);
    TweenTimeLine.add(timeLine);
});
复制代码

我们设置在0时间点的时候就开始动画.
第一段动画持续时间为0, 也即是第一个动画我们直接设置为item.to1.
第二段动画持续时间为200 / 1624 * 2, 最后停止的状态是item.to2.

为什么要设置为200 / 1624 * 2呢

因为我们的时间轴总长为1624 * 2 / 1624 * 2 = 1, 应用到页面上就是 2个屏幕的高度.

200 / 1624 * 2即是在屏幕运动到200距离的时候.

那么问题来了, 我们说的是时间轴动画, 你跟我这扯什么距离?

其实我们的动画速率和屏幕速率一样的时候, 那么路程不就是代表时间了吗?

t = s / v
复制代码

添加完「时间轴动画」之后, 我们就要去触发动画「动」的事件seek了.

TweenTimeLine.seek(120 / 1624*2);
复制代码

运行上面这句代码之后, 我们可以看到页面上已经直接跳到120距离的位置了.具体如下图:

WechatIMG290.png

那我们要实现开篇文章的动画, 要怎么做呢?

我们只需要在屏幕滚动的时候不停的触发seek就可以了.
这里我们引用一个库「PhyTouch」来滚动我们的页面.
然后在每次滚动change的时候触发我们的时间轴动画.

//@ts-ignore
window.alloyTouchs = null;
var lastValue = 0;
//@ts-ignore
window.alloyTouchs = new PhyTouch({
    touch: "body",//反馈触摸的dom
    property: "translateY",  //被运动的属性
    min: -1624, //不必需,运动属性的最小值
    step: 45,//用于校正到step的整数倍
    sensitivity: 0.65,//不必需,触摸区域的灵敏度,默认值为1,可以为负数
    maxSpeed: 1,
    max: 0, //不必需,滚动属性的最大值
    bindSelf: false,
    value: 0,
    change: function (value) {
        //触发动画
        TweenTimeLine.seek(-value / 1624 * 2);
    }
});
复制代码

这样子就大功告成了, 能看到随着我们鼠标或者手指的上滑下滑, 动画随之播放、暂停、回退.

有兴趣的同学也可以思考下「滑线」和「小船开进来」的动画的实现, 也很简单的.

展望

一个简单的「时间轴动画」就完成了.
不过可以看到, 我们只有稀稀疏疏两三个方法, 可以扩展的还有很多.
比如我们可以定义一些有用的方法:

  • set 设置动画初始属性
  • fromTo 定义动画从哪里来, 到哪里去
  • call 动画回调
  • change 动画更新时回调
  • remove 移除动画

同时我们还可以设置很多的属性:

  • repeat 动画重复次数
  • tween 还可以封装tween
  • stagger 动画错开
  • yoyo 动画往返

这样我们就差不多可以完善一个我们自己的动画库了.
后续还会继续完善这个库, 尽请关注.

文章粗浅, 望诸位不吝您的评论和点赞~
注: 本文系作者呕心沥血之作,转载须声明

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享