飞道的博客

Flutter | 动画

246人阅读  评论(0)

简介

在任何系统的 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曲线 动画过程
linear 匀速
decelerate 匀减速
ease 开始减速,后加速
easeIn 由慢到快
easeOut 由快到慢
easeInOut 开始慢,然后加速,最后再减速

除了上面列举的,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<double>(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 树中;直接看一下源码即可理解;

这种写法会带来三个好处:

  1. 不必显示的添加帧监听器和调用 setState 了,这个好处和 AnimatedWidget 是一样的;

  2. 动画构建范围缩小,如果没有 builder,setState 将会在父组件的上下文中调用,这回导致父组件的 build 方法重新调用;而有了 builder 之后,只会导致动画 widget 自身的 build 重新调用,避免不必要的 rebuild。

  3. 通过 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 枚举中定义,如下:

枚举值 含义
dismissed 动画在起始点执行
forward 动画正在正向执行
reverse 动画正在反向执行
completed 动画在终点停止

例如,将上面的放大动画改为循环动画,只需要监听动画状态的改变即可,即,正向结束时反转动画,反向结束时正向执行动画,如下:

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 已经帮我们做了;

上例中的效果如下所示:由于是 gif 图,有些掉帧

交织动画

有时候,我们可能会使用一下比较复杂的动画,这些动画由一个动画序列或者重叠的动画组成,例如一个图片,先旋转,在移动,或者同时进行移动和旋转。这种场景中包含了多种动画,要实现这种效果,我们可以使用交织动画( Stagger Animation) 会非常简单,

使用交织动画需要注意以下几点:

  1. 创建交织动画,需要多个动画对象(Animation)
  2. 一个 AnimationController 控制所有的动画对象
  3. 给每一个动画指定事件的间隔 (Interval)

所有的动画都是由同一个 AnimationController 驱动,无论动画需要持续多长时间,控制器必须在 0.0 到 0.1 之间,而每个动画间隔(Interval) 也必须介于 0.0 和 0.1 之间。对于间隔中设置动画的每个属性,需要分别创建 Tween 用于指定该属性的开始和结束值。也就是说 0.0 到 1.0 代表整个动画的过程,我们可以给不同的动画指定不同的起点和终点来决定它们开始的时间和终止的时间。

示例

实现一个柱状图增长的动画

  1. 将动画的 widget 分离出来

    class StaggerAnimation extends StatelessWidget {
      final Animation controller;
      Animation<double> height;
      Animation<EdgeInsets> padding;
      Animation<Color> color;
    
      StaggerAnimation({Key key, this.controller}) : super(key: key) {
        //高度,Interval用来指定整个动画过程中的起点和终点,前60%的动画时间
        height = Tween<double>(begin: .0, end: 300.0).animate(CurvedAnimation(
            parent: controller, curve: Interval(0.0, 0.6, curve: Curves.ease)));
        //颜色
        color = ColorTween(begin: Colors.green, end: Colors.red).animate(
            CurvedAnimation(
                parent: controller, curve: Interval(0.0, 0.6, curve: Curves.ease)));
        //内边距
        padding = Tween<EdgeInsets>(
                begin: EdgeInsets.only(left: .0), end: EdgeInsets.only(left: 100))
            .animate(CurvedAnimation(
                parent: controller, curve: Interval(0.6, 1.0, curve: Curves.ease)));
      }
    
      Widget _buildAnimation(BuildContext context, Widget child) {
        return Container(
            alignment: Alignment.bottomCenter,
            padding: padding.value,
            child: Container(
              color: color.value,
              width: 50.0,
              height: height.value,
            ));
      }
    
      @override
      Widget build(BuildContext context) {
        return AnimatedBuilder(animation: controller, builder: _buildAnimation);
      }
    }
    

    其中,定义了三个动画,分别是,高度,颜色,和内边距,然后通过 Interval 来指定整个动画的起点和终点

  2. 使用该动画

    class StaggerTest extends StatefulWidget {
      @override
      _StaggerTestState createState() => _StaggerTestState();
    }
    
    class _StaggerTestState extends State<StaggerTest>
        with TickerProviderStateMixin {
      AnimationController _controller;
    
      @override
      void initState() {
        super.initState();
        _controller = AnimationController(
            duration: const Duration(milliseconds: 2000), vsync: this);
      }
    
      _playAnimation() async {
        try {
          //正向执行动画
          await _controller.forward().orCancel;
          //反向执行动画
          await _controller.reverse().orCancel;
        } on TickerCanceled {
          //动画被取消了,可能是因为我们被处理了
        }
      }
    
      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: () {
            _playAnimation();
          },
          child: Center(
            child: Container(
              width: 300.0,
              height: 300.0,
              decoration: BoxDecoration(
                  color: Colors.black.withOpacity(0.1),
                  border: Border.all(color: Colors.black.withOpacity(0.5))),
              child: StaggerAnimation(controller: _controller),
            ),
          ),
        );
      }
    }
    

    效果如下所示

    其实交织动画就是把多个动画放在一起使用一个 controller 进行控制;

通用动画组件

实际开发过程中,我们经常会遇到切换 UI 元素的场景,例如 Tab 切换,路由切换等。为了增强用户体验,通常在切换时都会指定一个动画,使得切换过程显得顺滑。Flutter SDK 中提供了一下常用的切换组件,如 PageViewTabView 等,但是,这些组件并不能覆盖全部的需求场景,为此 Flutter SDK 提供了一个 AnimatedSwitch 组件,它定义了一种通用的 UI 切换抽象。


AnimatedSwitch

AnimatedSwitch 可以同时对其新,旧子元素添加显示,隐藏动画。也就是说在 AnimatedSwitch 的子元素发生变化时,会对其旧元素和新元素。定义如下:

const AnimatedSwitcher({
  Key key,
  this.child,
  @required this.duration, // 新child显示动画时长
  this.reverseDuration,// 旧child隐藏的动画时长
  this.switchInCurve = Curves.linear, // 新child显示的动画曲线
  this.switchOutCurve = Curves.linear,// 旧child隐藏的动画曲线
  this.transitionBuilder = AnimatedSwitcher.defaultTransitionBuilder, // 动画构建器
  this.layoutBuilder = AnimatedSwitcher.defaultLayoutBuilder, //布局构建器
})

当 AnimatedSwitch 的 child 发生变化时(类型或key不同),旧 child 会执行隐藏动画,新 child 会执行显示动画。动画效果则由 transitionBuilder 参数决定,即参数接收一个 AnimatedSwitchTransitionBUilder 类型的 builder,定义如下:

typedef AnimatedSwitcherTransitionBuilder = Widget Function(Widget child, Animation<double> animation);

该 builder 在 AnimatedSwitch 的 child 切换时会分别对新,旧 child绑定动画

1,对旧 child,绑定的动画会反向执行(reverse)

2,对新 child,绑定的动画会正向执行(forward)

这样一来,便实现了对新,旧 child 动画的绑定。AnimatedSwitch 的默认值是 AnimatedSwitch.defaultTransitionBuilder:

Widget defaultTransitionBuilder(Widget child, Animation<double> animation) {
  return FadeTransition(
    opacity: animation,
    child: child,
  );
}

可以看到返回了 FadeTransition 对象,也就是说,默认情况下,AnimatedSwitch 会对旧 child 执行渐隐和渐显动画。

栗子:

实现一个计数器,在每一次自增的过程中,旧数字缩小隐藏,新数字放大显示,如下:

class AnimatedSwitcherTest extends StatefulWidget {
  AnimatedSwitcherTest({Key key}) : super(key: key);

  @override
  _AnimatedSwitcherTestState createState() =>
      _AnimatedSwitcherTestState();
}

class _AnimatedSwitcherTestState
    extends State<AnimatedSwitcherTest> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          AnimatedSwitcher(
            duration: const Duration(milliseconds: 500),
            transitionBuilder: (Widget child, Animation<double> animation) {
              //执行缩放动画
              return ScaleTransition(scale: animation, child: child,);
            },
            //显式的指定 key,不同的 key 会被认为是不同的 Text,这样才能执行动画
            child: Text("$_count",
                key: ValueKey<int>(_count),
                style: Theme.of(context).textTheme.headline4),
          ),
          RaisedButton(
              child: const Text('+1'),
              onPressed: () {
                setState(() {
                  _count += 1;
                });
              })
        ],
      ),
    );
  }
}

效果如下:

注意:AnimatedSwitcher 的新旧 child,如果类型相同,则 可以必须不相等,应为只有不同的 key 才会被认为是不同的 Text,这样才能执行动画

AnimatedSwitch实现原理

要实现新旧的 child 动画切换,只需要明确一个问题:动画执行的时机是如何对新旧 child 执行的动画

从 AnimatedSwitch 可以看到,当 child 发生变化时(子 widget 的 key 和类型不同时相等则认为是发生变化),则会重新执行 build,然后动画开始执行。我们可以通过继承 StatefulWidget 来实现 AnimatedSwitch ,具体做法是在 didUpdateWidget 中判断新旧 child 是否发生变化,如果发生变化,则对旧 child 执行反向退场(reverse)动画,对新的 child 执行正向(forward)入场动画即可。下面是 AnimatedSwitch 实现的部分核心伪代码:

Widget _widget; //
void didUpdateWidget(AnimatedSwitcher oldWidget) {
  super.didUpdateWidget(oldWidget);
  // 检查新旧child是否发生变化(key和类型同时相等则返回true,认为没变化)
  if (Widget.canUpdate(widget.child, oldWidget.child)) {
    // child没变化,...
  } else {
    //child发生了变化,构建一个Stack来分别给新旧child执行动画
   _widget= Stack(
      alignment: Alignment.center,
      children:[
        //旧child应用FadeTransition
        FadeTransition(
         opacity: _controllerOldAnimation,
         child : oldWidget.child,
        ),
        //新child应用FadeTransition
        FadeTransition(
         opacity: _controllerNewAnimation,
         child : widget.child,
        ),
      ]
    );
    // 给旧child执行反向退场动画
    _controllerOldAnimation.reverse();
    //给新child执行正向入场动画
    _controllerNewAnimation.forward();
  }
}

//build方法
Widget build(BuildContext context){
  return _widget;
}

上面的伪代码展示了 AnimatedSwitcher 的核心逻辑,当然真正的逻辑比这个更加复杂,他可以自定义退场过度动画已经执行动画的布局等,在此,我们通过伪代码主要是为了看到主要的实现思路;

另外,Flutter SDK 中还提供了一个 AnimatedCrossFade 的组件,它也可以切换两个子元素,切换过程中执行渐隐和渐显动画,和 AnimagedSwticher 不同的是 AnimatedCrossFade 是针对两个子元素,而 AnimatedSwitch 是在一个子元素的新旧值之间切换。

AnimatedSwitch高级用法

如果我们要实现一个类似路由平移的动画:旧页面屏幕中向左侧退出,新页面从屏幕右侧平移进入。我们很快就会发现,做不到,我们可能会写出下面的代码:

AnimatedSwitcher(
  duration: Duration(milliseconds: 200),
  transitionBuilder: (Widget child, Animation<double> animation) {
    var tween=Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0))
     return SlideTransition(
       child: child,
       position: tween.animate(animation),
    );
  },
  ...//省略
)

上面代码的问题,我们前面说过 AnimatedSwitch 的 child 切换动画时会分别对新 child 执行正向动画,对旧 child 执行反向动画,所以正真的效果是新 child 从屏幕右侧平移进入了,但是旧 child 却会不会从左侧退出,而是右侧退出。因为在没有特殊处理的情况下,同一个动画的正向和逆向刚好是相反(对称)的。

那么问题就来了,我们不能使用 AnimatedSwitch 了吗?,答案是否定的,究其原因,就是因为 Animation 是对称的,所以只要打破这个规则就可以了,下面我们封装一个 MySlideTransition,他与 SlideTransition 唯一的不同就是对动画的反向执行进行了定制(从左边划出隐藏),代码如下:

class MySlideTransition extends AnimatedWidget {
  final bool transformHitTests;
  final Widget child;

  Animation<Offset> get position => listenable;

  MySlideTransition(
      {Key key,
      @required Animation<Offset> position,
      this.transformHitTests = true,
      this.child})
      : assert(position != null),
        super(key: key, listenable: position);

  @override
  Widget build(BuildContext context) {
    Offset offset = position.value;
    if (position.status == AnimationStatus.reverse) {
      offset = Offset(-offset.dx, offset.dy);
    }
    return FractionalTranslation(
        translation: offset,
        transformHitTests: transformHitTests,
        child: child);
  }
}
AnimatedSwitcher(
  duration: const Duration(milliseconds: 500),
  transitionBuilder: (Widget child, Animation<double> animation) {
    var tween = Tween(begin: Offset(1, 0), end: Offset(0, 0));
    //执行缩放动画
    return MySlideTransition(
      position: tween.animate(animation),
      child: child,
    );
  },
  //显式的指定 key,不同的 key 会被认为是不同的 Text,这样才能执行动画
  child: Text("$_count",
      key: ValueKey<int>(_count),
      style: Theme.of(context).textTheme.headline4),
)

效果如上图所示,实际上 Flutter 路由也是通过 AnimatedSwtcher 来实现的

SlideTransitionX

我们通过封装一个通用的 SlideTransitionX 来实现这种出入滑动的动画, 如下:

class SlideTransitionX extends AnimatedWidget {
  Animation<double> get position => listenable;
  final bool transformHitTests;
  final Widget child;

  //退场/出场 方向
  final AxisDirection direction;
  Tween<Offset> _tween;

  SlideTransitionX(
      {Key key,
      @required Animation<double> position,
      this.transformHitTests = true,
      this.direction = AxisDirection.down,
      this.child})
      : assert(position != null),
        super(key: key, listenable: position) {
    switch (direction) {
      case AxisDirection.up:
        _tween = Tween(begin: Offset(0, 1), end: Offset(0, 0));
        break;
      case AxisDirection.right:
        _tween = Tween(begin: Offset(-1, 0), end: Offset(0, 0));
        break;
      case AxisDirection.down:
        _tween = Tween(begin: Offset(0, -1), end: Offset(0, 0));
        break;
      case AxisDirection.left:
        _tween = Tween(begin: Offset(1, 0), end: Offset(0, 0));
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    Offset offset = _tween.evaluate(position);
    if (position.status == AnimationStatus.reverse) {
      switch (direction) {
        case AxisDirection.up:
          offset = Offset(offset.dx, -offset.dy);
          break;
        case AxisDirection.right:
          offset = Offset(-offset.dx, offset.dy);
          break;
        case AxisDirection.down:
          offset = Offset(offset.dx, -offset.dy);
          break;
        case AxisDirection.left:
          offset = Offset(-offset.dx, offset.dy);
          break;
      }
    }
    return FractionalTranslation(
        translation: offset,
        transformHitTests: transformHitTests,
        child: child);
  }
}
AnimatedSwitcher(
  duration: const Duration(milliseconds: 500),
  transitionBuilder: (Widget child, Animation<double> animation) {
    return SlideTransitionX(
      direction: AxisDirection.down,
      position: animation,
      child: child,
    );
  },
  //显式的指定 key,不同的 key 会被认为是不同的 Text,这样才能执行动画
  child: Text("$_count",
      key: ValueKey<int>(_count),
      style: Theme.of(context).textTheme.headline4),
),

效果如图:

动画过度组件

为了方便表示,我们将 widget 属性发生变化时会执行过度动画的组件称为 “动画过度组件”,而动画过度最明显的一个特征就是他会在内部管理自己的 AnimationController。我们指定,为了方便使用者可以自定义动画的时长,曲线等,这些通常都是使用者自己提供的。但是如此一来,使用者就必须手动管理 AnimationController,这样会增加使用的复杂性。因此如果能将 AnimationController 进行封装,就会大大提高动画组件的易用性。

自定义动画过度组件

我们实现一个 AnimatedDecoratedBox ,他可以在 decorated 属性发生变化时,从旧状态变成新状态的过程中执行一个过度动画,根据上面学到的执行,我们写出如下代码:

class AnimatedDecoratedBox1 extends StatefulWidget {
  final BoxDecoration decoration;
  final Widget child;

  //执行时间
  final Duration duration;

  //曲线
  final Curve curve;

  //反向执行时间
  final Duration reverseDuration;

  AnimatedDecoratedBox1(
      {Key key,
      @required this.decoration,
      this.child,
      @required this.duration,
      this.reverseDuration,
      this.curve = Curves.linear});

  @override
  _AnimatedDecoratedBox1State createState() => _AnimatedDecoratedBox1State();
}

class _AnimatedDecoratedBox1State extends State<AnimatedDecoratedBox1>
    with SingleTickerProviderStateMixin {
  AnimationController _controller;
  Animation<double> _animation;
  DecorationTween _tween;

  @protected
  AnimationController get controller => _controller;

  @protected
  Animation get animation => _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
        duration: widget.duration,
        reverseDuration: widget.reverseDuration,
        vsync: this);
    _tween = DecorationTween(begin: widget.decoration);
    _updateCurve();
  }

  void _updateCurve() {
    if (widget.curve != null) {
      _animation = CurvedAnimation(parent: _controller, curve: widget.curve);
    } else {
      _animation = _controller;
    }
  }

  @override
  void didUpdateWidget(covariant AnimatedDecoratedBox1 oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.curve != oldWidget.curve) _updateCurve();
    _controller.duration = widget.duration;
    _controller.reverseDuration = widget.reverseDuration;
    if (widget.decoration != (_tween.end ?? _tween.begin)) {
      _tween
        ..begin = _tween.evaluate(_animation)
        ..end = widget.decoration;
      _controller
        ..value = 0.0
        ..forward();
    }
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return DecoratedBox(
            decoration: _tween.animate(_animation).value, child: child);
      },
      child: widget.child,
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

效果如下:

上面代码虽然实现了我们期望的功能,但是代码却有些复杂,其实 AnimationController 的管理和 Tween 这部分代码都是可以抽出来的,如果将这部分代码封装为基类,那么要实现过渡组件只需要继承基类即可,然后自定义自身不同的diam即可。这样就会大大的简化代码;

在 Flutter SDK 中提供了一个 ImplicitlyAnimatedWidgetState 类,他继承自 StatefulState ,同时提供了一个对应的 ImplicitlyAnimatedWidgetState 类,AnimationController 的管理就在这个类中。开发者要封装动画,只需要继承 ImplicitlyAnimatedWidgetImplicitlyAnimatedWidgetState 类即可;

我们需要分两步实现:

  1. 继承 ImplicitlyAnimatedWidget

    
    class AnimatedDecoratedBox extends ImplicitlyAnimatedWidget {
      final BoxDecoration decoration;
      final Widget child;
    
      AnimatedDecoratedBox(
          {Key key,
          @required this.decoration,
          this.child,
          Curve curve = Curves.linear, //动画曲线
          @required Duration duration, //动画执行时长
          Duration reverseDuration})
          : super(key: key, curve: curve, duration: duration);
    
      @override
      ImplicitlyAnimatedWidgetState<ImplicitlyAnimatedWidget> createState() =>
          _AnimatedDecoratedBoxState();
    }
    

    其中 curve,duration 属性在 父类中已定义,可以看到其实和普通继承自 StatefulWidget 的类没有什么不同。

  2. State 类继承自 AnimatedWidgetBaseState (该类继承自 ImplicitlyAnimatedWidgetState 类)

    class _AnimatedDecoratedBoxState
        extends AnimatedWidgetBaseState<AnimatedDecoratedBox> {
      DecorationTween _decorationTween;
    
      @override
      Widget build(BuildContext context) {
        return DecoratedBox(
          decoration: _decorationTween.evaluate(animation),
          child: widget.child,
        );
      }
    
      @override
      void forEachTween(visitor) {
        //在需要更新 Tween 时,基类会调用此方法
        _decorationTween = visitor(_decorationTween, widget.decoration,
            (value) => DecorationTween(begin: value));
      }
    }
    

    我们实现了 build 和 forEachTween 方法。

    在动画的执行过程中,每一帧都会调用 build 方法(调用逻辑在父类中),所以在 build 方法中我们需要构建每一帧的 DecoratedBox 状态,因此需要算出每一帧 decoration 状态,这个我们可以通过 _decoraitionTween.evaluate(animation) 来算出,其中 animation 是 ImplicitlyAnimatedSidgetState 基类中定义的对象,_decoration 是我吗自定义的一个 DecoratioNTween 类型的对象,那么 _decorationTween 是在什么时候被赋值的呢? 我们知道 _decorateionTween 是一个 Tween,主要是定义动画的起始状态 begin 和终止状态 end ,对于 AnimatedDecoratedBox 来说,decoration 的终止状态就是用户传给他的值,而起始状态是不确定的,有两种情况:

    1. AnimatedDecoratedBox 首次 build,此时直接将其 decoration 值设置为起始状态,即 _decorationTween 值为 DecorationTween(begin:decoration)。
    2. AnimatedDecoratedBox 的 decoration 更新时,则起始状态为 _decoration.animate(animation),即 _decorationTween 值为 DecorationTween(begin:_decoration.animate(animation),end:decoration)。

    现在 forEachTween 作用就很明显了,他正是用来更新 Tween 的初始值的。在上述两种情况下会被调用,而我们只需要重写该方法,并在此方法中更新 Tween 的起始状态值即可。而一些更新的逻辑被屏蔽在了 visitor 回调,我们只需要给他传递正确的参数即可,visitor 方法前面如下:

    Tween visitor(
         Tween<dynamic> tween, //当前的tween,第一次调用为null
         dynamic targetValue, // 终止状态
         TweenConstructor<dynamic> constructor,//Tween构造器,在上述三种情况下会被调用以更新tween
       );
    

Flutter 预置的动画过度组件

Flutter SDK 里面预置了很多过度组件,实现方式和大都和 AnimatedDecoratedBox 差不多,

组件名 功能
AnimatedPadding 在 padding 发生变化时会执行过渡动画到新状态
AnimatedPositioned 配合 Stack 一起使用,当定位状态发生变化时会执行过渡动画到新的状态
AnimatedOpactity 在透明度 opacity 发生变化时执行过渡动画到新状态
AnimatedAlign 当 aligment 发生变化时会执行过渡动画到新的状态
AnimatedContainer 当 Container 属性发生变化时会执行过渡动画到新的状态
AnimatedDefaultTextStyle 当字体样式发生变化时,子组件中继承改样式的文本组件会动态过度到新的样式

示例:

class AnimatedWidgetsTest extends StatefulWidget {
  @override
  _AnimatedWidgetsTestState createState() => _AnimatedWidgetsTestState();
}

class _AnimatedWidgetsTestState extends State<AnimatedWidgetsTest> {
  double _padding = 10;
  Alignment _align = Alignment.topRight;
  double _height = 100;
  double _left = 0;
  Color _color = Colors.red;
  TextStyle _style = TextStyle(color: Colors.black);
  Color _decorationColor = Colors.blue;

  @override
  Widget build(BuildContext context) {
    var duration = Duration(seconds: 5);
    return SingleChildScrollView(
      child: Column(
        children: [
          RaisedButton(
            onPressed: () => setState(() => _padding = 20),
            child: AnimatedPadding(
              duration: duration,
              padding: EdgeInsets.all(_padding),
              child: Text("AnimatedPadding"),
            ),
          ),
          SizedBox(
            height: 50,
            child: Stack(
              children: [
                AnimatedPositioned(
                  child: RaisedButton(
                    onPressed: () => setState(
                      () => _left = 100,
                    ),
                    child: Text("AnimatedPositioned"),
                  ),
                  duration: duration,
                  left: _left,
                ),
              ],
            ),
          ),
          Container(
            height: 100,
            color: Colors.grey,
            child: AnimatedAlign(
              duration: duration,
              alignment: _align,
              child: RaisedButton(
                onPressed: () => setState(() => _align = Alignment.center),
                child: Text("AnimatedAlign"),
              ),
            ),
          ),
          AnimatedContainer(
            duration: duration,
            height: _height,
            color: _color,
            child: FlatButton(
              onPressed: (() => setState(() => this
                .._height = 150
                .._color = Colors.blue)),
              child: Text("AnimatedContainer",
                  style: TextStyle(color: Colors.white)),
            ),
          ),
          AnimatedDefaultTextStyle(
              child: GestureDetector(
                  child: Text("hello world"),
                  onTap: () => _style = TextStyle(
                      color: Colors.blue,
                      decorationStyle: TextDecorationStyle.solid,
                      decorationColor: Colors.blue)),
              style: _style,
              duration: duration),
          AnimatedDecoratedBox(
            decoration: BoxDecoration(color: _decorationColor),
            duration: duration,
            child: FlatButton(
              onPressed: () => setState(() => _decorationColor = _color),
              child: Text("AnimatedDecoratedBox",
                  style: TextStyle(color: Colors.white)),
            ),
          )
        ]
            .map((e) => Padding(
                  padding: EdgeInsets.symmetric(vertical: 16),
                  child: e,
                ))
            .toList(),
      ),
    );
  }
}

效果如下所示:

参考 Flutter 实战


转载:https://blog.csdn.net/baidu_40389775/article/details/116518822
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场