简介
在任何系统的 UI 框架中,动画的实现原理都是相同的,即:在一段时间内,快速地多次改变 UI 外观;由于人眼会产生视觉停留,所以最终看到的就是一个连续的动画;
我们将 UI 的一次改变称为一个动画帧,对应一次屏幕的刷新,而决定动画流畅度的一种重要指标就是 FPS,即每秒的动画帧数。帧数越高,动画就会越流畅;
一般情况下,动画帧率超过 16FPS ,就比较流畅了,超过 32FPS 就会非常细腻平滑,而超过32FPS 人眼基本就感受不到差别了,由于动画每一帧都是要改变 UI 输出,对设备的软硬件要求都较高,所以在 UI 系统中,动画的平均帧数是重要的指标,而在 Flutter 中,理想状态下是可以实现 60FPS 的,这和原生应用基本是持平的
Flutter 中动画抽象
为了方便开发者创建动画,不同的 UI 系统对动画都进行了抽象,如 Android 中可以通过 xml 来描述一个动画并设置给 View,Flutter 中也对动画进行了抽象,主要涉及 Animation,Curve,Controller,Tween 这四个角色,他们一起配合完成一个完整的动画。
Animation
Animation 是一个抽象类,它本身和 UI 渲染没有任何关系,它主要的功能是保存动画的插值和状态,其中比较常用的是 Animation 。Animation 对象是一个在一段时间内依次生成一个区间(Tween) 之间的值。
Animation 对象在动画执行的过程中输出可以使线性的,曲线的,一个步进函数或者曲线函数等,这由 Curve 来决定。根据 Animation 对象的控制方式,动画可以正向,反向运行,也可以在中间切换方向。Animation 还可以生成 Animation,或者 Animation 等,在动画的每一帧中,我们可以通过 Animation 对象的 value 属性获取动画的当前值。
Flutter 中的动画时基于 Animation 对象的,widget 可以在 build 函数中读取 Animation 对象的当前值,并且可以监听动画的状态改变
动画感知
我们可以通过 Animation 来监听动画的每一帧以及执行状态的变化,nimation 有如下两种写法:
1,addListener() ,给 Animation 添加帧监听器,在每一帧都会被调用。帧监听器中最常见的行为是改变状态后调用 setState 触发 UI 重建
2,addStateListener,添加动画状态改变监听器,可以监听动画开始,结束,正向,方向等,触发是会调用改监听器
Curved
动画的过程可以使匀速,加速,先加后减等。Flutter 中通过 Curve(曲线) 来描述动画过程,我们把匀速动画称为(Curves.linear),而非匀速动画称为非线性。
我们可以通过 CurvedAnimation 来指定动画的曲线,如:
final CurvedAnimation curve =
new CurvedAnimation(parent: controller, curve: Curves.easeIn);
CurvedAnimation 继承自 Animation 类
CurvedAnimation 可以通过包装 AnimationController 和 Curve 生成一个新的动画对象,我们正式通过这种方式来讲动画和动画执行的曲线关联起来的;
我们指定动画的曲线为 Curves.easeIn,表示由慢到快,Curves 类是一个预置的枚举类,定义了很多常用的曲线,如下:
除了上面列举的,Curves 类中海油很多其他的曲线,并且附有动画执行过程,大家可自行查看
当然,我们也可以创建自己的 Curve,如定义一个正弦曲线:
class ShakeCurve extends Curve { @override double transform(double t) { return math.sin(t * math.PI * 2); } }
AnimationController
AnimationController 用于控制动画,它包含 forward(启动),stop(停止),reverse(反向) 等方法,AnimationController 会在动画的每一帧生成一个新的值,默认情况下给定的默认区间是 0.0 到 1.0。例如下面代码创建一个 Animaction 对象:
final AnimationController controller = new AnimationController(duration: const Duration(milliseconds: 2000), vsync: this);
AnimationController 生成的数字区间可以用 lowerBound 和 upperBound 来指定,如:
final AnimationController controller = new AnimationController( duration: const Duration(milliseconds: 2000), lowerBound: 10.0, upperBound: 20.0, vsync: this );
AnimationControler 派生子 Animation ,因此可以在需要 Animation 对象的任何地方使用。但是它具有控制动画的其他方法,如启动正向动画,反向动画等。在动画执行后开始生成动画帧,屏幕每刷新一次就是一个动画帧;
在动画的每一帧,会随着动画曲线来生成当前的动画值(Animation.value) 。然后根据当前动画值去构建 UI ,当所有动画帧依次触发时,动画值就会改变,对应的 UI 就会发生变化,最终就可以看到完整的动画;
duration 表示动画执行的时长,通过它可以控制动画的速度。
注意:在某些情况下,动画可能会超出 0.0 到 1 的返回,这取决于具体的曲线,例如,fing() 函数可以根据手势滑动(甩出) 的速度,力量来模拟一个手指甩出的动画,因此,他的动画值可以在 [0.0,1.0] 的范围之外。也就是说,根据选择的曲线,CurvedAnimation 的输出可以比输入有更大的范围。
例如 Curves.elasticln 等弹性曲线会生成大于或小于默认范围的值
Ticker
当创建一个 AnimationController 时,需要传递一个 vsync 参数,它接收一个 TickerProvider 类型的对象,他的主要职责是创建 Ticker,定义如下:
abstract class TickerProvider { //通过一个回调创建一个Ticker Ticker createTicker(TickerCallback onTick); }
Flutter 应用在启动时都会绑定一个 SchedulerBinding,通过 SchedulerBinding 可以给每一次屏幕刷新添加回调,而 Ticker 就是通过 SchedulerBinding 来添加屏幕刷新的回调,这样依赖,每次屏幕刷新都会调用 tIckerCallback,使用 Ticker 来驱动动画会防止屏幕外动画(动画的 UI 不在当前屏幕时,如锁屏时)消耗不必要的资源,因为 Flutter屏幕刷新时会通知到绑定的 SchedulerBinding,而 Ticker 是受 SchedulerBinding 驱动的,由于锁屏后屏幕就会停止刷新,所以 Ticker 就不会触发;
通常我们会将 SingleTickerProviderStateMixin 添加到 State 定义中,然后将 State 对象作为 vsync 的值,这在后面的例子中可以看到;
Tween
默认情况下,AnimationController 对象范围是 [0.0 ,1.0] 。如果我们需要构建的 UI 的动画值在不同的范围,或者是不同的数据类型,则可以使用 Tween 来添加映射以生成不同范围或数据类型的值。例如:生成 [-200.0, 0.0] 的值
final Tween doubleTween = new Tween(begin: -200.0, end: 0.0);
Tween 构造函数需要 begin 和 end 两个数。Tween 的唯一职责就是定义从输入范围到输出范围的映射。通常输入范围是 [0.0,1.0] ,我们可以自定义这个范围
Tween 继承自 Animatable ,而不是 Animation ,Animatable 中主要定义的是动画值的映射规则。
例如将动画输入范围映射为两种颜色值之间过度输出:
final Tween colorTween = new ColorTween(begin: Colors.transparent, end: Colors.black54);
Tween 对象不存储任何状态,想法,它提供了 evaluate 方法,可以获取动画当前映射值。Animation 对象的当前值可以通过 value 方法获取到。evaluate 函数还执行一些其他处理,例如分别确保在动画值为 0.0 和 1.0 是返回开始和结束状态。
Tween.animate
要使用 Tween 对象,需要调用其 animate() 方法,然后传入一个控制器对象,例如,在 500 毫秒内生成从 0 到 255 的整数值,
final AnimationController controller = new AnimationController( duration: const Duration(milliseconds: 500), vsync: this); Animation<int> alpha = new IntTween(begin: 0, end: 255).animate(controller);
注意 animate 方法返回的是一个 Animation,而不是 Animatable。
final AnimationController controller = new AnimationController( duration: const Duration(milliseconds: 500), vsync: this); final Animation curve = new CurvedAnimation(parent: controller, curve: Curves.easeOut); Animation<int> alpha = new IntTween(begin: 0, end: 255).animate(curve);
上面代码构建了一个控制器,一条曲线,和一个 Twwen;
动画的基本结构
在 Flutter 中,可以通过多种方式来实现动画,如下:
最基础的实现方式
class AnimationTest extends StatefulWidget { @override _AnimationTestState createState() => _AnimationTestState(); } ///需要继承 TickerProvider,如有有多个 AnimationController,则 ///应该使用 TickerProviderStateMixin class _AnimationTestState extends State<AnimationTest> with SingleTickerProviderStateMixin { Animation<double> animation; AnimationController controller; @override void initState() { super.initState(); controller = AnimationController(duration: const Duration(seconds: 3), vsync: this); animation = Tween(begin: 0.0, end: 300.0).animate(controller) ..addListener(() { setState(() => {}); }); controller.forward(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("动画")), body: Center( child: Image.asset("images/avatar.jpg", width: animation.value, height: animation.value), ), ); } @override void dispose() { //销毁时释放动画资源 controller.dispose(); super.dispose(); } }
上面代码中在 addListener 中调用了 setState(),所以每次动画生成一个新的数字时,当前帧表标记为脏(dirty),就会导致 Widget 的 build 方法被调用。在 build 中,通过 animation.value 改变图片的宽高,所以就会逐渐放大。需要注意的是在动画完成之后需要调用 disponse 方法进行释放,以防止内存泄漏;
我们给动画指定一个曲线(Curve),来实现一个曲线的动画,如下:
controller = AnimationController(duration: const Duration(seconds: 3), vsync: this); animation = CurvedAnimation(parent: controller, curve: Curves.bounceIn); animation = Tween(begin: 0.0, end: 300.0).animate(animation) ..addListener(() { setState(() => {}); }); controller.forward();
使用 AnimatedWidget 简化
在上面代码中通过 addListener 和 setState 来更新 UI 这一步其实是通用的,如果每个动画中都加这么一句就显得非常繁琐了。
AnimatedWidget 类封装了 setState() 的细节,并允许我们将 widget 分离出来,重构后代码如下:
class AnimatedImage extends AnimatedWidget { AnimatedImage({Key key, Animation<double> animation}) :super(key: key, listenable: animation); @override Widget build(BuildContext context) { final Animation<double> animation = listenable; return Center( child: Image.asset( "images/avatar.jpg", width: animation.value, height: animation.value), ); } }
class AnimationTest extends StatefulWidget { @override _AnimationTestState createState() => _AnimationTestState(); } class _AnimationTestState extends State<AnimationTest> with SingleTickerProviderStateMixin { Animation<double> animation; AnimationController controller; @override void initState() { super.initState(); controller = AnimationController(duration: const Duration(seconds: 3), vsync: this); animation = CurvedAnimation(parent: controller, curve: Curves.bounceIn); animation = Tween(begin: 0.0, end: 300.0).animate(animation); controller.forward(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("动画")), body: AnimatedImage(), ); } @override void dispose() { //销毁时释放动画资源 controller.dispose(); super.dispose(); } }
使用 AnimatedBuilder 重构
使用 AnimatedWidget 可以从动画中分离出 Widget,而动画的渲染过程仍在 AnimatedWidget 中,假设我们添加一个 widget 透明度变化的动画,那么就需要再去实现一个 AnimatedWidget ,这样不是很优雅,如果能将渲染的过程也抽象出来,就会好很多;
AnimatedBuilder 正是将渲染逻辑分离歘来,上面的 build 方法代码可以改为:
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("动画")), body: AnimatedBuilder( animation: animation, child: Image.asset("images/avatar.jpg"), builder: (BuildContext context, Widget child) { return Center( child: Container( width: animation.value, height: animation.value, child: child), ); }, ), ); }
上面的代码有一个疑惑的问题就是 child 看起来被指定了两次,但实际上发生的事情是:将外部引用 child 传递给 AnimatedBuilder 后,AnimatedBuilder 再将其传递到匿名构造器,然后将该对象作为其子对象,最终的结果是 AnimatedBuilder 返回的对象插入到 widget 树中;直接看一下源码即可理解;
这种写法会带来三个好处:
不必显示的添加帧监听器和调用 setState 了,这个好处和 AnimatedWidget 是一样的;
动画构建范围缩小,如果没有 builder,setState 将会在父组件的上下文中调用,这回导致父组件的 build 方法重新调用;而有了 builder 之后,只会导致动画 widget 自身的 build 重新调用,避免不必要的 rebuild。
通过 AnimatedBuild 可以封装场景的过渡效果来复用动画,如下:
class GrowTransition extends StatelessWidget { final Widget child; final Animation<double> animation; GrowTransition(this.child, this.animation); @override Widget build(BuildContext context) { return Center( child: AnimatedBuilder( animation: animation, child: child, builder: (context, child) { return Container( height: animation.value, width: animation.value, child: child, ); }, ), ); } }
上面封装了一个动画,他可以对子 Widget 实放大动画,调用如下:
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("动画")), body: GrowTransition(Image.asset("images/avatar.jpg"), animation)); }
Flutter 中通过这种方式封装了很多动画,如:FadeTransition,ScaleTransition,SizeTransition 等,很多时候都是可以复用这些预置的过渡类
动画状态监听
我们可以通过 Animattion 的 addStatusListener 方法来添加动画状态的监听器。Flutter 中,有四种动画状态,在 ANimationStatus 枚举中定义,如下:
例如,将上面的放大动画改为循环动画,只需要监听动画状态的改变即可,即,正向结束时反转动画,反向结束时正向执行动画,如下:
void initState() { super.initState(); controller = AnimationController(duration: const Duration(seconds: 3), vsync: this); animation = CurvedAnimation(parent: controller, curve: Curves.bounceIn); animation.addStatusListener((status) { //结束时反向执行 if (status == AnimationStatus.completed) { controller.reverse(); } else if (status == AnimationStatus.dismissed) { //在初始状态则正向执行 controller.forward(); } }); animation = Tween(begin: 0.0, end: 300.0).animate(animation); controller.forward(); }
自定义路由切换动画
Material 组件库中通过了一个 MaterialPageRoute 组件,它可以是用和平台风格一致的路由切换动画,如在 IOS 上会左右滑动切换,而在 Android 上是上下滑动切换。如果在 Android 中要使用左右切换风格,该怎么做?
最简单的做法是直接使用 CupertinoPageRoute:
Navigator.push(context, CupertinoPageRoute( builder: (context)=>PageB(), ));
CupertinoPageRoute 是 Cupertino 组件库提供的 IOS 风格路由切换组件,它实现的就是左右滑动切换,那么如何自定义路由切换动画呢?
答案就是使用 PageRouteBuilder。例如我们想要以渐隐渐入动画来实现路由过度,代码如下:
Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("动画")), body: GestureDetector( child: GrowTransition(Image.asset("images/avatar.jpg"), animation), onTap: () { Navigator.push(context, PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) { return FadeTransition( //使用渐隐渐入过度 opacity: animation, child: RouteTestPage()); })); }, ), ); }
可以看到 pageBuilder 有一个 animation 参数,这是 Flutter 路由管理器提供的,在路由切换时,每一帧都会被回调,因此我们可以通过 animation 对象来自定义过度动画
无论是 MaterialPageRoute,CupertinoPageRoute,还是 PageRouteBuilder,他们都继承 PageRoute 类,而 PageRouteBuilder 只是 PageRoute 的一个包装,我们可以直接继承自 PageRoute 类来实现自定义路由,如下:
class FadeRoute extends PageRoute { final WidgetBuilder builder; ///动画时间 @override final Duration transitionDuration; ///透明 @override final bool opaque; ///您是否可以通过点击模式障碍来消除此路线。 @override final bool barrierDismissible; @override final Color barrierColor; @override final String barrierLabel; ///路由处于非活动状态时是否应保留在内存中 @override final bool maintainState; FadeRoute( {@required this.builder, this.transitionDuration = const Duration(milliseconds: 300), this.opaque = true, this.barrierDismissible = false, this.barrierColor, this.barrierLabel, this.maintainState = true}); ///构建此路由的主要内容 @override Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) => builder(context); ///路由动画 @override Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) { //打开新路由 if (isActive) { return FadeTransition(opacity: animation, child: builder(context)); } //是返回,不应用过度动画 return Padding(padding: EdgeInsets.zero); } }
使用:
Navigator.push(context, FadeRoute(builder: (context) { return RouteTestPage(); }));
虽然上面两种方法都可以实现自定义动画切换,但是实际使用应该优先考虑使用 PageRouteBuilder,这样无需定义一个新的路由类,使用会比较方便。
有些时候 PageRouteBuilder 是不能够满足需求的,例如在过度动画的时候需要获取当前路由的属性,这就直接通过继承 PageRoute 的方式了,如 打开路由和返回是使用的不是同一个动画,这种就必须判断当前路由 isActivie 属性是否为 true,商人上例中所示;
关于其他的参数信息可直接查看源码或者文档
Hero 动画
hero 指的是可以在页面之间飞行的 widget,简单的说就是在路由切换时,有一个共享的 widget 可以在新旧路由中间切换。由于共享的 widget 在新旧页面上的位置,外观可能有所差异,所以在路由切换时会逐渐过渡到新路由中指的的位置,这样就会产生一个 Hero 动画。
你可能见到过 hero 动画,例如,一个路由中显示代售商品缩略图,点击进入就会跳转到详情,新路由中的详情包含商品的图片和购买按钮。
为什么要将这种可飞行共享组件称为 hero(英雄),有一种说法是美国文化中超人是可以飞的,那是美国人心中的大英雄,还有漫威中的超级英雄基本都会飞,所有 flutter 就对这种会飞的 widget 起了一个附有浪漫主义的名字 hero。这种说法并非官方解释,单却很有意思
在 Flutter 中图片从一个路由飞到另一个路由称为 Hero 动画,尽管相同动作有时也称为 共享元素转换,例如:
class HeroAnimationTestA extends StatelessWidget { @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.only(top: 50), alignment: Alignment.topCenter, child: InkWell( child: Hero( //唯一标记,前后两个路由页 Hero 的 tag 必须相同 tag: "avatar", child: ClipOval( child: Image.asset("images/avatar.jpg", width: 50), ), ), onTap: () { Navigator.push(context, PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) { return FadeTransition( opacity: animation, child: Scaffold( appBar: AppBar(title: Text("原图")), body: HeroAnimationTestB(), )); })); }, ), ); } }
class HeroAnimationTestB extends StatelessWidget { @override Widget build(BuildContext context) { return Center( child: Hero( tag: "avatar", child: Image.asset("images/avatar.jpg"), ), ); } }
通过上面代码可以看到只要将 Hero 组件将要共享的 Widget 包装起来,并提供一个相同的 tag 即可,中间的过度帧都是 Flutter Framework 自动完成的。
需要注意的是 Hero 的 tag 必须是相同的,Flutter Framework 内部正是通过 tag 来确定新旧路由页 widget 的对应关系的。
Hero 动画原理比较简单,Flutter Framework 知道新旧路由页中共享的元素和大小,并根据这两个端点,在动画执行过程中求出过度的插值即可。而幸运的是这件事情 Flutter 已经帮我们做了;