动画实现剖析:基于纯 JS 的 React Animation 组件

经过一段时间的修修补补,Nautil 终于算是完工了。在 Nautil 中,内置了一个用纯 JS 写的 Animation 组件,你可以在这里阅读它的源码。这篇文章,就来聊一聊该组件的实现。

问题背景

首先,在读者你心里可能会有个疑问:为什么要用纯 JS 去写一个动画组件呢?直接用市面上已经有的 react 动画组件,或者自己写一个配合 css 的动画组件不就可以了吗。我在正在做的另外一个项目中偶尔遇到一个问题,我利用 css 的 transition 去处理一个动画,但是由于时间戳问题,发现画面会有跳动,究其原因,是因为 transition 一旦被打断,剩余动画就不会被执行了,会跳到下一个 transition 直接执行新的动作,所以画面会有跳动。

在写 Nautil 的时候,又产生了另外一个问题,即跨端开发。css 在 web 上是没有问题的,但是到 react-native 中呢?或者再移植到其他平台中呢?

总之,使用纯 JS 去实现动画可以有效解决上述两个问题。当然,也会面临性能问题,这需要开发者自己去把握好。本文接下来的 Animation 组件在讲解过程中,就不再去考虑性能问题,对于使用者而言,也不需要在任何时候去用这个组件,而是在其他动画组件无法实现某些效果的时候,才考虑本组件。

架构设计

一个简单的动画组件还需要架构吗?我们这里谈架构,是希望从不同层面拆解一个动画组件的构成和实现,从而更好的帮助开发者理解该组件的实现思路和使用技巧。它不是一个系统,谈不上架构。但是拆解式理解,是我们学习的好方法。如果你读过我之前写的《动效:补间值缓动计算类》这篇文章,但这篇文章讲的不系统,借鉴意义不大,因此,本文会详细去解剖编程中的动画开发知识结构。

以上是该组件实现的所有部分,你可以通过这里获得源码。你可以看到,它其实分了 5 个部分,接下来,我会对每一个部分做详细的讲解。

补间

动画补间,简单的说,就是在未给出所有值的情况下,通过计算,算出特定位置的特定值。一般而言,你需要得到一个动画的开始状态和结束状态,然后,通过这两个状态去算出两点之间某个位置的值。

例如,你的动画开始时,值为 0,结束时,值为 100,在 1min 内完成。请算出当时间进行到 30s 时的状态值。这样一道简单的题目很容易被算出来,我们知道当时间为 30s 时,值应该为 50. 但是,我们是怎么算出来的呢?实际上,我们的公式如下:

100*0.5=50

现在题目变了,起始和和结束值变为 20 和 80,那么 30s 时的补间值该是多少呢?40. 如果整体时间延长至 2min 呢?

实际上,计算补间值有一个规律性的公式:

value = start + (end-start) * factor

结束值减去起始值就是变化的值域,值域乘以变化因子就是已经变化了的值,在起始值的基础上加上该值,就是当前的补间值。

其中,factor(补间因子)是一个 [0, 1] 的值。该值在动画中,是一个时间点和整个持续时间(duration)的比值。而在数学中,它可以是任何值域在 [0, 1] 的函数上的任意一个 y 值,例如 sin 这样的三角函数。

色值补间。

颜色本质上也是由值组成,无论是 rgb 还是 hex 的色值,甚至加入了透明度的 rgba,都可以利用上述补间理论计算补间结果。在一些应用中,它会提供一个色盘,用于让你拾取颜色。难道它需要把所有的颜色都铺满在色盘上吗?当然不是,它只需要提供纵横值域,并通过你拾取位置在整个色盘位置中的比例,再按照上述公式,就可以算出你当前取得的是什么颜色。而色盘上的颜色本身,可以通过 css 的渐变函数来实现。

总之,上面讲到的补间值计算函数,你可以在这里得到源码进行阅读。

缓动

我们取得一个补间值,它本身是静态的,除非像上面那种取色器应用才有意义,对于我们要进行动画制作时,单纯一个补间值完全没有意义。我们必须取得连续的补间值,如何取得连续的补间值呢?

动画的重要元素就是时间。

通过时间的推进,来取得连续的补间动画,这样的过程被称为“缓动”。缓动的本质,就是获取上述公式中的 factor。但是,缓动的方式有很多种,不同的方式,我们用一种叫“缓动函数”的东西来表达。

缓动函数让 factor 在时间轴上的变化规律不尽相同。我们可以用 factor 的增长加速度不同来表达这个“不同”之处。你可以在这个网站体验到不同缓动函数的加速度(它用视觉的效果让你看到 factor 增长的速度变化)。当你了解 ease 函数之后,你会发现,我们前文指出 factor 是一个 [0, 1] 的值,其实不完全正确,因为在某些情况下,factor 会超出这个值域,但是总体而言,factor 是从 0 开始增长,最终结束时达到 1. 这就是缓动函数的效果,让 factor 的变化具备多种多样性。

现在,我们来想象有一个弹珠,从天上掉下来,掉到地上之后,它会有一个回弹的过程,弹几次之后,才最终静止在地面上。

上面这个视频演示了一个 easeOutBounce 的效果,你再看的时候,应该去看右边的那个游标的效果。将它反过来,就是我们上面描述的弹珠坠地的效果了。easeOutBounce 是一个缓动函数,它根据当前的时间游标计算出 factor。最终,我们可以得到一个公式:

factor = ease(t/duration)

其中,duration 一旦确定之后,基本上是不会变的,可以认为是一个常量。t 则是不断持续增长的值,而且这个增长是匀速的等差数列增长。

再结合前面的一个公式,我们就可以得到:

value = start + (end-start) * ease(t/duration)

在这个公式中,start, end, ease, duration 一旦在一开始定下来之后,就不会变,所以,这是一个 value 关于 t 的一个公式。

value = fn(t)

所有 Nautil 支持的缓动函数,你可以在这里看源码。css 中的缓动函数,你可以看这里

过渡

到目前为止,我们知道利用缓动函数,可以获得非线性增长的补间值。但是,到目前为止,我们还没有动起来,我们得到的仍然是静态值,我们只能算出某一个值。接下来,我们通过“过渡”来认识动起来的补间效果。

我们虽然更感知时间在流逝,但是我们无法直接表达这种流逝。为了简介表达时间流逝,人类发明了“时钟”。通过钟表的运动,指示时间的流逝。而在计算机中,也提供了原子钟给程序使用。但是在 JS 中,我们仍然无法按照准确的时间刻度推进时间。最终,我选择了使用 requestAnimationFrame 作为时间演进的工具。

一个过渡,是从时间的这头演进到那头。但是,怎么表达呢?这就是代码层面的设计问题了。我最终设计为,把它设计为一个 Transition 类,实例化的时候,传入 duration,通过 start 方法开始时间推进。另外,我还设计了 pause 方法用来暂停时间推荐,设计了 stop 方法来结束时间推进。

同时,需要传入 ease,通过上面的公式 factor = ease(t/duration) Transition 可以给出在时间推进中,每一个 requestAnimationFrame 的 factor,这需要通过提前在 onUpdate 方法中注册接收函数。

const tr = new Transition({ duration, ease })

tr.on('update', (e, factor) => {
  // 再使用补间函数,获得补间值
  const value = tween(start, end, factor)
  // ...
})
tr.start() // 开始推进时间

Transition 只是在时间流逝过程中,获得 factor,但要得到具体补间值,还需要使用补间函数获取。这样使得整体的可复用可扩展性极强。

具体的代码,你可以通过这里阅读源码。

动画

所有以上的内容,目的都是为了实现一个动画。动画实质上,是将一个或多个过渡应用到物体身上。动画意味着用户可以看到的界面效果在变化,因此,它是一个涉及到界面操作的过程。

让我们再回头看 css transition 属性:

transition: name duration ease delay;

一个 transition 属性,实际上是确定了 ease, duration,通过 name 样式的值变化来决定 start 和 end。每一次一个样式的值发生变化时,transition 被触发,此时,原来的老样式值被作为 start,新的样式被作为 end。一旦 transition 被触发之后,浏览器引擎就会按照 transition 的这些参数进行运算,并将补间值赋值给样式,直到结束。其中 delay 本文未涉及。css 的 transition 不仅解决了补间、缓动、过渡,而且直接将过渡效果运用到属性所在的样式上去,所以,它本身是一个动画效果。

再来看看 css animation 属性:

@keyframes my_animation {
  from {
    margin-left: -150px;
    opacity: 0;
  }
  to {
    margin-left: 0;
    opacity: 1;
  }
}
animation: name duration ease delay iteration-count direction fill-mode play-state;

要使用 animation 需要先定义一个 keyframes,这个 keyframes 定义了多个属性的多段 start 和 end。这里有两个点:“多个属性”“多段”,这两个特点使得 animation 和 transition 属性区别巨大。同时,animation 还比 transition 多了 iteration-count, direction 这些值,它们可以实现动画的持续性播放,而不是像 transition 一样,只有一个过程。

animation 弥补了 transition 的不足:1. 可对多个属性同时应用过渡效果,2. 可将整个 duration 拆分为多段,3. 可设置重复动画,以及动画的过渡方式(方向)。所以,动画的本质就是一堆 transition 的组合。transition 其实也可以做到多个属性同时设置,只要用逗号连接多个设置即可,但是相对而言,它仍然只是过渡,而非可以持续触发的动画。

现在让我们回到 Animation 这个组件。

在 Nautil 的设计中,一个动画被设计为一个组件的隐现方式。组件如何出现在用户的视野里,如何从界面上消失。这两个过程的动画效果是我所关注的和实现的。当然,动画还有很多场景,例如 loading 效果,但这些暂时不在本文的实现范围中,以后有机会会和你分享。它提供了 Animation 组件,用以解决这个问题。通过和前面的描述对比,你会发现实际上,Nautil 提供的 Animation 组件并没有实现真正的 animation,而是只实现了一个可多次触发的 transition 的效果。这是由我们开发场景中的实际需求决定的,在 Animation 这个组件的思路上,你可以扩展出更多的动画组件出来,而且它们都是纯 JS 实现的。

在设计 Animation 组件时,我把它设计的极其简单,你不需要开发 css 样式。在下文我会详细讲解该组件。

跨平台

同一个组件,在不同的平台上如何工作呢?在 web 中,我们通过修改 css 属性值来触发动画,在 react-native 中,我们通过其内置的样式规则来触发。虽然它们大部分都兼容,但是唯独在 transform 属性上兼容的不是很好。因此,我将这部分提炼出来,从而可以实现跨平台。

实际上,transform 在 web 平台中和 react-native 平台中的主要区别是输出不同,web 中输出的是字符串,而 react-native 中输出的是数组。因此,我写了一个来控制输入,然后通过原型链覆盖的方式,在 web 平台下输出字符串,在 react-native 平台下输出数组。

通过这种方式,我就可以保证,一套代码,同一个组件,在不同平台下都可以按照相同的方式运行。

具体实现

上述的技术理论给我们做好了铺垫。接下来,我们来看下 Animation 这个组件的实现过程。它是一个 React 组件,而且它是一个纯 UI 组件。对于使用者而言,它足够简单,只接收 props,通过外部修改 props 的值来触发内部的动画效果。

你可以通过这里阅读视频 demo 的源码。

使用方法

我一直认为,任何开发出来的东西的一个重要原则,就是简单。什么是简单,简单就是“少”,理解起来容易。我在写 Animation 组件的时候就是这个想法。那么怎么少才算少呢?我们来看下示例代码:

<Animation 
  enter="easeInElastic 500 fade:in move:right,top scale:1.5/1" 
  leave="easeInQuad 500 fade:out move:0/-300,-100 scale:1/1.5" 
  show={this.state.show}
>
  <Text>这是要动画隐现的部分</Text>
</Animation>

上面这段代码,它会创建一个动画区域,作用是通过 show 来控制这个区域的显示和隐藏。显示的时候,使用 enter 属性内配置的动画效果,隐藏的时候使用 leave 属性内配置的动画效果。就是这么简单。

语法解释

上面 enter 和 leave 两个属性是对各自动画效果的配置。我们来解释一下这两个属性的语法是怎么样的。

ease duration effect:from/to effect2:from/to ...
  • ease 缓动函数名,查看
  • duration 持续时间,数字
  • effect:from/to 动画效果
    • effect 动画效果名,仅支持 fade, move, rotate, scale
      • fade 渐隐渐显,通过 opacity 实现
      • move 位置移动,通过 transform 的 translate 实现
      • rotate 旋转,通过 transform 的 rotate 实现
      • scale 缩放,通过 transform 的 scale 实现
    • from 起始值
    • to 结束值

起始值和结束值需要根据 effect 名字来决定:

  • fade 时,必须传入用以表示透明度的值
    • in 或者 out,例如 fade:in, fade:out
    • 0-1 之间的数字,例如 fade:0/1, fade:1/0.5
  • move 时,必须传入一个坐标
    • left, right 和 top, bottom 的组合,会使用内置值,例如 move:left,top/right,bottom
    • 数字,例如 move:-300,12/0,0
  • rotate 时,必须传入角度,且 from 和 end 的单位相同,例如 rotate:60deg/0deg rotate:0.5turn/0turn
  • scale 时,必须传入数字,例如 scale:1.2/1 scale:1/0.5

另外,from 不可以省略,to 可以省略,缺省值为:

  • fade: 1
  • move: 0,0
  • rotate: 0deg
  • scale: 1

对于坐标而言,x 不可以省,y 可省,缺省值为 0. 例如:move:left 表示从左边 -100px,0 位置向右位移至 0,0 位置。

  • left: -100
  • right: 100
  • top: -50
  • bottom: 50

事件回调

Animation 组件隐现过程中,你可以通过如下属性进行监听:

  • onEnterStart
  • onEnterUpdate
  • onEnterStop
  • onLeaveStart
  • onLeaveUpdate
  • onLeaveStop

自定义容器

默认情况下,Animation 依赖 Section 作为容器来实现隐现效果。有的时候,你希望使用类似 Button 甚至 Text 作为容器,可以传入 component 来使用对应的组件。

<Animation component={Text}>

小结

本文虽然是介绍 Nautil 中 Animation 组件的实现,但是通过一层层概念解释,实际上把 JS 编程中动画编程的原理都捋了一遍,对于想要实现一些动画效果的同学,这篇文章其实也是一个比较好的借鉴。当然,文章还有一些不足之处,希望你在下方留言和我讨论。

2019-07-19 284 ,

为价值买单

本文价值2.84RMB