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

AnimatedSwitcher

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

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

当AnimatedSwitcher的child发生变化时(类型或Key不同),旧child会执行隐藏动画,新child会执行执行显示动画。究竟执行何种动画效果则由transitionBuilder参数决定,该参数接受一个AnimatedSwitcherTransitionBuilder类型的builder,定义如下:

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

该builder在AnimatedSwitcher的child切换时会分别对新、旧child绑定动画:

  1. 对旧child,绑定的动画会反向执行(reverse)
  2. 对新child,绑定的动画会正向指向(forward)

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

  1. Widget defaultTransitionBuilder(Widget child, Animation<double> animation) {
  2. return FadeTransition(
  3. opacity: animation,
  4. child: child,
  5. );
  6. }

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

例子

下面我们看一个列子:实现一个计数器,然后再每一次自增的过程中,旧数字执行缩小动画隐藏,新数字执行放大动画显示,代码如下:

  1. import 'package:flutter/material.dart';
  2. void main() => runApp(new MyApp());
  3. class MyApp extends StatelessWidget {
  4. @override
  5. Widget build(BuildContext context) {
  6. return MaterialApp(
  7. debugShowCheckedModeBanner: true,
  8. title: 'AnimatedSwitcher',
  9. theme: ThemeData(
  10. primarySwatch: Colors.blue,
  11. ),
  12. home: Scaffold(
  13. appBar: AppBar(title: Text("AnimatedSwitcher")),
  14. body: AnimatedSwitcherCounterRoute(),
  15. ));
  16. }
  17. }
  18. class AnimatedSwitcherCounterRoute extends StatefulWidget {
  19. const AnimatedSwitcherCounterRoute({Key key}) : super(key: key);
  20. @override
  21. _AnimatedSwitcherCounterRouteState createState() => _AnimatedSwitcherCounterRouteState();
  22. }
  23. class _AnimatedSwitcherCounterRouteState extends State<AnimatedSwitcherCounterRoute> {
  24. int _count = 0;
  25. @override
  26. Widget build(BuildContext context) {
  27. return Center(
  28. child: Column(
  29. mainAxisAlignment: MainAxisAlignment.center,
  30. children: <Widget>[
  31. AnimatedSwitcher(
  32. duration: const Duration(milliseconds: 500),
  33. transitionBuilder: (Widget child, Animation<double> animation) {
  34. //执行缩放动画
  35. return ScaleTransition(child: child, scale: animation);
  36. },
  37. child: Text(
  38. '$_count',
  39. //显示指定key,不同的key会被认为是不同的Text,这样才能执行动画
  40. key: ValueKey<int>(_count),
  41. style: Theme.of(context).textTheme.display1,
  42. ),
  43. ),
  44. RaisedButton(
  45. child: const Text('+1',),
  46. onPressed: () {
  47. setState(() {
  48. _count += 1;
  49. });
  50. },
  51. ),
  52. ],
  53. ),
  54. );
  55. }
  56. }

运行示例代码,当点击“+1”按钮时,原先的数字会逐渐缩小直至隐藏,而新数字会逐渐放大,我截取了动画执行过程的一帧,如图所示:
通用“动画切换”组件(AnimatedSwitcher) - 图1
上图是第一次点击“+1”按钮后切换动画的一帧,此时“0”正在逐渐缩小,而“1”正在“0”的中间,正在逐渐放大。

注意:AnimatedSwitcher的新旧child,如果类型相同,则Key必须不相等key: ValueKey<int>(_count)

AnimatedSwitcher实现原理

要想实现新旧child切换动画,只需要明确两个问题:

  • 动画执行的时机是和如何对新旧child执行动画。从AnimatedSwitcher的使用方式我们可以看到,当child发生变化时(子widget的key和类型不同时相等则认为发生变化),则重新会重新执行build,然后动画开始执行。
  • 我们可以通过继承StatefulWidget来实现AnimatedSwitcher,具体做法是在didUpdateWidget 回调中判断其新旧child是否发生变化,如果发生变化,则对旧child执行反向退场(reverse)动画,对新child执行正向(forward)入场动画即可

下面是AnimatedSwitcher实现的部分核心伪代码:

  1. Widget _widget; //
  2. void didUpdateWidget(AnimatedSwitcher oldWidget) {
  3. super.didUpdateWidget(oldWidget);
  4. // 检查新旧child是否发生变化(key和类型同时相等则返回true,认为没变化)
  5. if (Widget.canUpdate(widget.child, oldWidget.child)) {
  6. // child没变化,...
  7. } else {
  8. //child发生了变化,构建一个Stack来分别给新旧child执行动画
  9. _widget= Stack(
  10. alignment: Alignment.center,
  11. children:[
  12. //旧child应用FadeTransition
  13. FadeTransition(
  14. opacity: _controllerOldAnimation,
  15. child : oldWidget.child,
  16. ),
  17. //新child应用FadeTransition
  18. FadeTransition(
  19. opacity: _controllerNewAnimation,
  20. child : widget.child,
  21. ),
  22. ]
  23. );
  24. // 给旧child执行反向退场动画
  25. _controllerOldAnimation.reverse();
  26. //给新child执行正向入场动画
  27. _controllerNewAnimation.forward();
  28. }
  29. }
  30. //build方法
  31. Widget build(BuildContext context){
  32. return _widget;
  33. }

上面伪代码展示了AnimatedSwitcher实现的核心逻辑,当然AnimatedSwitcher真正的实现比这个复杂,它可以自定义进退场过渡动画以及执行动画时的布局等。在此,我们删繁就简,通过伪代码形式让读者能够清楚看到主要的实现思路,具体的实现读者可以参考AnimatedSwitcher源码。
另外,Flutter SDK中还提供了一个AnimatedCrossFade组件,它也可以切换两个子元素,切换过程执行渐隐渐显的动画,和AnimatedSwitcher不同的是AnimatedCrossFade是针对两个子元素,而AnimatedCrossFade是在一个子元素的新旧值之间切换。AnimatedCrossFade实现原理比较简单,也有和AnimatedSwitcher类似的地方,因此不再赘述,读者有兴趣可以查看其源码。

AnimatedSwitcher高级用法(左进右出)

假设现在我们想实现一个类似路由平移切换的动画:旧页面屏幕中向左侧平移退出,新页面重屏幕右侧平移进入。如果要用AnimatedSwitcher的话,我们很快就会发现一个问题:做不到!我们可能会写出下面的代码:

  1. AnimatedSwitcher(
  2. duration: Duration(milliseconds: 200),
  3. transitionBuilder: (Widget child, Animation<double> animation) {
  4. var tween=Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0))
  5. return SlideTransition(
  6. child: child,
  7. position: tween.animate(animation),
  8. );
  9. },
  10. ...//省略
  11. )

上面的代码有什么问题呢?我们前面说过在AnimatedSwitcher的child切换时会分别

  • 对新child执行正向动画(forward)
  • 而对旧child执行反向动画(reverse)

所以真正的效果便是

  • 新child确实从屏幕右侧平移进入了
  • 但旧child却会从屏幕右侧(而不是左侧)退出

其实也很容易理解,因为在没有特殊处理的情况下,同一个动画的正向和逆向正好是相反(对称)的。
那么问题来了,难道就不能使用AnimatedSwitcher了?答案当然是否定的!仔细想想这个问题,究其原因,就是因为同一个Animation正向(forward)和反向(reverse)是对称的。所以如果我们可以打破这种对称性,那么便可以实现这个功能了,下面我们来封装一个MySlideTransition,它与SlideTransition唯一的不同就是对动画的反向执行进行了定制(从左边滑出隐藏),代码如下:

  1. class MySlideTransition extends AnimatedWidget {
  2. MySlideTransition({
  3. Key key,
  4. @required Animation<Offset> position,
  5. this.transformHitTests = true,
  6. this.child,
  7. })
  8. : assert(position != null),
  9. super(key: key, listenable: position) ;
  10. Animation<Offset> get position => listenable;
  11. final bool transformHitTests;
  12. final Widget child;
  13. @override
  14. Widget build(BuildContext context) {
  15. Offset offset=position.value;
  16. //动画反向执行时,调整x偏移,实现“从左边滑出隐藏”
  17. if (position.status == AnimationStatus.reverse) {
  18. offset = Offset(-offset.dx, offset.dy);
  19. }
  20. return FractionalTranslation(
  21. translation: offset,
  22. transformHitTests: transformHitTests,
  23. child: child,
  24. );
  25. }
  26. }

调用时,将SlideTransition替换成MySlideTransition即可:

  1. import 'package:flutter/material.dart';
  2. void main() => runApp(new MyApp());
  3. class MyApp extends StatelessWidget {
  4. @override
  5. Widget build(BuildContext context) {
  6. return MaterialApp(
  7. debugShowCheckedModeBanner: true,
  8. title: 'AnimatedSwitcher',
  9. theme: ThemeData(
  10. primarySwatch: Colors.blue,
  11. ),
  12. home: Scaffold(
  13. appBar: AppBar(title: Text("AnimatedSwitcher")),
  14. body: AnimatedSwitcherCounterRoute(),
  15. ));
  16. }
  17. }
  18. class AnimatedSwitcherCounterRoute extends StatefulWidget {
  19. const AnimatedSwitcherCounterRoute({Key key}) : super(key: key);
  20. @override
  21. _AnimatedSwitcherCounterRouteState createState() =>
  22. _AnimatedSwitcherCounterRouteState();
  23. }
  24. class _AnimatedSwitcherCounterRouteState
  25. extends State<AnimatedSwitcherCounterRoute> {
  26. int _count = 0;
  27. @override
  28. Widget build(BuildContext context) {
  29. return Center(
  30. child: Column(
  31. mainAxisAlignment: MainAxisAlignment.center,
  32. children: <Widget>[
  33. AnimatedSwitcher(
  34. duration: Duration(milliseconds: 200),
  35. transitionBuilder: (Widget child, Animation<double> animation) {
  36. var tween = Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 1));
  37. return MySlideTransition(
  38. child: child,
  39. position: tween.animate(animation),
  40. );
  41. },
  42. child: Text(
  43. '$_count',
  44. //显示指定key,不同的key会被认为是不同的Text,这样才能执行动画
  45. key: ValueKey<int>(_count),
  46. style: Theme.of(context).textTheme.display1,
  47. ),
  48. ),
  49. AnimatedSwitcher(
  50. duration: const Duration(milliseconds: 300),
  51. transitionBuilder: (Widget child, Animation<double> animation) {
  52. //执行缩放动画
  53. return ScaleTransition(child: child, scale: animation);
  54. },
  55. child: Text(
  56. '$_count',
  57. //显示指定key,不同的key会被认为是不同的Text,这样才能执行动画
  58. key: ValueKey<int>(_count),
  59. style: Theme.of(context).textTheme.display1,
  60. ),
  61. ),
  62. RaisedButton(
  63. child: const Text(
  64. '+1',
  65. ),
  66. onPressed: () {
  67. setState(() {
  68. _count += 1;
  69. });
  70. },
  71. ),
  72. ],
  73. ),
  74. );
  75. }
  76. }
  77. class MySlideTransition extends AnimatedWidget {
  78. MySlideTransition({
  79. Key key,
  80. @required Animation<Offset> position,
  81. this.transformHitTests = true,
  82. this.child,
  83. }) : assert(position != null),
  84. super(key: key, listenable: position);
  85. Animation<Offset> get position => listenable;
  86. final bool transformHitTests;
  87. final Widget child;
  88. @override
  89. Widget build(BuildContext context) {
  90. Offset offset = position.value;
  91. //动画反向执行时,调整x偏移,实现“从左边滑出隐藏”
  92. if (position.status == AnimationStatus.reverse) {
  93. offset = Offset(-offset.dx, offset.dy);
  94. }
  95. return FractionalTranslation(
  96. translation: offset,
  97. transformHitTests: transformHitTests,
  98. child: child,
  99. );
  100. }
  101. }

运行后,我截取动画执行过程中的一帧,如图所示:
通用“动画切换”组件(AnimatedSwitcher) - 图2
上图中“0”从左侧滑出,而“1”从右侧滑入。可以看到,我们通过这种巧妙的方式实现了类似路由进场切换的动画,实际上Flutter路由切换也正是通过AnimatedSwitcher来实现的。

知识点

  • key: ValueKey(_count)
  • style: Theme.of(context).textTheme.display1

SlideTransitionX

上面的示例我们实现了“左出右入”的动画,那如果要实现“右入左出”、“上入下出”或者 “下入上出”怎么办?当然,我们可以分别修改上面的代码,但是这样每种动画都得单独定义一个“Transition”,这很麻烦。本节将分装一个通用的SlideTransitionX 来实现这种“出入滑动动画”,代码如下:

  1. class SlideTransitionX extends AnimatedWidget {
  2. SlideTransitionX({
  3. Key key,
  4. @required Animation<double> position,
  5. this.transformHitTests = true,
  6. this.direction = AxisDirection.down,
  7. this.child,
  8. })
  9. : assert(position != null),
  10. super(key: key, listenable: position) {
  11. // 偏移在内部处理
  12. switch (direction) {
  13. case AxisDirection.up:
  14. _tween = Tween(begin: Offset(0, 1), end: Offset(0, 0));
  15. break;
  16. case AxisDirection.right:
  17. _tween = Tween(begin: Offset(-1, 0), end: Offset(0, 0));
  18. break;
  19. case AxisDirection.down:
  20. _tween = Tween(begin: Offset(0, -1), end: Offset(0, 0));
  21. break;
  22. case AxisDirection.left:
  23. _tween = Tween(begin: Offset(1, 0), end: Offset(0, 0));
  24. break;
  25. }
  26. }
  27. Animation<double> get position => listenable;
  28. final bool transformHitTests;
  29. final Widget child;
  30. //退场(出)方向
  31. final AxisDirection direction;
  32. Tween<Offset> _tween;
  33. @override
  34. Widget build(BuildContext context) {
  35. Offset offset = _tween.evaluate(position);
  36. if (position.status == AnimationStatus.reverse) {
  37. switch (direction) {
  38. case AxisDirection.up:
  39. offset = Offset(offset.dx, -offset.dy);
  40. break;
  41. case AxisDirection.right:
  42. offset = Offset(-offset.dx, offset.dy);
  43. break;
  44. case AxisDirection.down:
  45. offset = Offset(offset.dx, -offset.dy);
  46. break;
  47. case AxisDirection.left:
  48. offset = Offset(-offset.dx, offset.dy);
  49. break;
  50. }
  51. }
  52. return FractionalTranslation(
  53. translation: offset,
  54. transformHitTests: transformHitTests,
  55. child: child,
  56. );
  57. }
  58. }

现在如果我们想实现各种“滑动出入动画”便非常容易,只需给direction传递不同的方向值即可,比如要实现“上入下出”,则:

  1. import 'package:flutter/material.dart';
  2. void main() => runApp(new MyApp());
  3. class MyApp extends StatelessWidget {
  4. @override
  5. Widget build(BuildContext context) {
  6. return MaterialApp(
  7. debugShowCheckedModeBanner: true,
  8. title: 'AnimatedSwitcher',
  9. theme: ThemeData(
  10. primarySwatch: Colors.blue,
  11. ),
  12. home: Scaffold(
  13. appBar: AppBar(title: Text("AnimatedSwitcher")),
  14. body: AnimatedSwitcherCounterRoute(),
  15. ));
  16. }
  17. }
  18. class AnimatedSwitcherCounterRoute extends StatefulWidget {
  19. const AnimatedSwitcherCounterRoute({Key key}) : super(key: key);
  20. @override
  21. _AnimatedSwitcherCounterRouteState createState() =>
  22. _AnimatedSwitcherCounterRouteState();
  23. }
  24. class _AnimatedSwitcherCounterRouteState
  25. extends State<AnimatedSwitcherCounterRoute> {
  26. int _count = 0;
  27. @override
  28. Widget build(BuildContext context) {
  29. return Center(
  30. child: Column(
  31. mainAxisAlignment: MainAxisAlignment.center,
  32. children: <Widget>[
  33. AnimatedSwitcher(
  34. duration: Duration(milliseconds: 200),
  35. transitionBuilder: (Widget child, Animation<double> animation) {
  36. return SlideTransitionX(
  37. child: child,
  38. direction: AxisDirection.down, //上入下出
  39. position: animation,
  40. );
  41. },
  42. child: Text(
  43. '$_count',
  44. //显示指定key,不同的key会被认为是不同的Text,这样才能执行动画
  45. key: ValueKey<int>(_count),
  46. style: Theme.of(context).textTheme.display1,
  47. ),
  48. ),
  49. AnimatedSwitcher(
  50. duration: const Duration(milliseconds: 300),
  51. transitionBuilder: (Widget child, Animation<double> animation) {
  52. //执行缩放动画
  53. return ScaleTransition(child: child, scale: animation);
  54. },
  55. child: Text(
  56. '$_count',
  57. //显示指定key,不同的key会被认为是不同的Text,这样才能执行动画
  58. key: ValueKey<int>(_count),
  59. style: Theme.of(context).textTheme.display1,
  60. ),
  61. ),
  62. RaisedButton(
  63. child: const Text(
  64. '+1',
  65. ),
  66. onPressed: () {
  67. setState(() {
  68. _count += 1;
  69. });
  70. },
  71. ),
  72. ],
  73. ),
  74. );
  75. }
  76. }
  77. class SlideTransitionX extends AnimatedWidget {
  78. SlideTransitionX({
  79. Key key,
  80. @required Animation<double> position,
  81. this.transformHitTests = true,
  82. this.direction = AxisDirection.down,
  83. this.child,
  84. }) : assert(position != null),
  85. super(key: key, listenable: position) {
  86. // 偏移在内部处理
  87. switch (direction) {
  88. case AxisDirection.up:
  89. _tween = Tween(begin: Offset(0, 1), end: Offset(0, 0));
  90. break;
  91. case AxisDirection.right:
  92. _tween = Tween(begin: Offset(-1, 0), end: Offset(0, 0));
  93. break;
  94. case AxisDirection.down:
  95. _tween = Tween(begin: Offset(0, -1), end: Offset(0, 0));
  96. break;
  97. case AxisDirection.left:
  98. _tween = Tween(begin: Offset(1, 0), end: Offset(0, 0));
  99. break;
  100. }
  101. }
  102. Animation<double> get position => listenable;
  103. final bool transformHitTests;
  104. final Widget child;
  105. //退场(出)方向
  106. final AxisDirection direction;
  107. Tween<Offset> _tween;
  108. @override
  109. Widget build(BuildContext context) {
  110. Offset offset = _tween.evaluate(position); //给定[动画]的此对象的当前值,相当于下面注释的两行
  111. // Animation<Offset> _tweenanimate = _tween.animate(position);
  112. // Offset offset = _tweenanimate.value;
  113. if (position.status == AnimationStatus.reverse) {
  114. switch (direction) {
  115. case AxisDirection.up:
  116. offset = Offset(offset.dx, -offset.dy);
  117. break;
  118. case AxisDirection.right:
  119. offset = Offset(-offset.dx, offset.dy);
  120. break;
  121. case AxisDirection.down:
  122. offset = Offset(offset.dx, -offset.dy);
  123. break;
  124. case AxisDirection.left:
  125. offset = Offset(-offset.dx, offset.dy);
  126. break;
  127. }
  128. }
  129. return FractionalTranslation(
  130. translation: offset,
  131. transformHitTests: transformHitTests,
  132. child: child,
  133. );
  134. }
  135. }

运行后,我截取动画执行过程中的一帧,如图所示:
通用“动画切换”组件(AnimatedSwitcher) - 图3
上图中“1”从底部滑出,而“2”从顶部滑入。读者可以尝试给SlideTransitionX的direction取不同的值来查看运行效果。

知识点: Offset offset = _tween.evaluate(position); //给定[动画]的此对象的当前值,相当于下面注释的两行
Animation _tweenanimate = _tween.animate(position);

Offset offset = _tweenanimate.value;