如何玩转 Flutter 动画

  • 2020-11-29
  • 本文字数:13043 字

    阅读完需:约 43 分钟

在使用手机的过程中动画效果随处可见,动画设计可以很好地提升用户体验,在 iOS、Android 平台都有各自实现动画效果的方式。在 Flutter 中,动画也是必不可少的内容,并且体验可以达到接近原生的效果。

1 Flutter 动画基本概念

1.1 Animation

  • Flutter 中的动画基于 Animation 对象,它是一个抽象类,保存了当前动画的值和状态;

  • Animation 对象在一段时间内依次生成一个区间之间值,比较常见的类型是 Animation,其他的还有 Animation 或 Animation

  • Animation 对象和 UI 渲染是无关的,UI 对象通过读取 Animation 对象的值和监听状态变化运行 build 函数,然后渲染到屏幕上形成动画效果。

1.2 AnimationController

AnimationController 是 Animation 的子类,具有控制动画的启动、暂停、结束等方法:

  • AnimationController 会生成一系列的值,数字的产生与屏幕刷新有关,因此每秒钟通常会产生 60 个数字,(默认情况下,AnimationController 在给定的时间段内会线性的生成从 0.0 到 1.0 之间的数字);

  • 在生成每个数字后,每个 Animation 对象调用添加的 Listener 对象。

AnimationController 需要一个 vsync 必传参数,vsync 对象会绑定动画的定时器到一个可视的 widget,它的作用是避免动画相关 UI 不在当前屏幕时消耗资源:

  • 当 widget 不显示时,动画定时器将会暂停;

  • 当 widget 再次显示时,动画定时器重新恢复执行;

  • 如果要使用自定义的 State 对象作为 vsync 时,需要包含 TickerProviderStateMixin;

AnimationController 控制动画的方法:

  • forward:向前执行动画;

  • reverse:反向执行动画;

  • stop:停止动画;

代码示例:

AnimationController controller = AnimationController(    duration: const Duration(seconds: 5),    vsync: this);
复制代码

1.3 CurvedAnimation

CurvedAnimation 是 Animation< double > 的子类,它可以将 AnimationController 定义为一个非线性曲线动画。

curve 参数对象的有一些常量 Curves(和 Color 类型有一些 Colors 是一样的)可以供我们直接使用,例如:linear、easeIn、easeOut、easeInToLinear 等等。

代码示例:

CurvedAnimation curvedAnimation = CurvedAnimation(    parent: controller,    curve: Curves.linear);
复制代码

1.4 Tween

Tween 继承自 Animatable,可以映射不同类型和范围的动画取值。

代码示例:

Tween tweenAnim = Tween(    begin: 50.0,    end: 150.0).animate(curvedAnimation);
复制代码

1.5 Listeners 和 StatusListeners

一个 Animation 对象可以拥有 Listeners 和 StatusListeners 监听器,可以用 addListener() 和 addStatusListener() 来添加:

  • addListener:

  • 只要动画的值发生变化,就会调用所有通过 addListener 添加的监听器;

  • Listener 最常见的行为是调用 setState() 来触发 UI 重建。

  • addStatusListener:

  • 当动画的状态发生变化时,例如:开始、结束、向前移动或向后移动,都会通知所有通过 addStatusListener 添加的监听器;

  • 通常情况下,动画会从 dismissed 状态开始,表示它处于变化区间的开始点;

  • 举例来说,从 0.0 到 1.0 的动画在 dismissed 状态时的值应该是 0.0;

  • 动画进行的下一状态可能是 forward(比如从 0.0 到 1.0)或者 reverse(比如从 1.0 到 0.0);

  • 最终,如果动画到达其区间的结束点,则动画会变成 completed 状态。

2 动画应用

  • 基本动画使用

  • 动画组合使用

2.1 基础动画使用

主要代码如下:

class LookPage extends StatefulWidget {  @override  _CJAnimationWidgetState createState() => _CJAnimationWidgetState();}class _CJAnimationWidgetState extends Statewith SingleTickerProviderStateMixin {        AnimationController _controller;    Animation_curvedAnimation;    Animation_tweenAnimation;    @override  void initState() {        super.initState();         // 1. 创建 controller        _controller = AnimationController(        duration: Duration(milliseconds: 1000),        vsync: this,        );        // 2. 创建 curvedAnimation        _curvedAnimation = CurvedAnimation(            parent: _controller,             curve: Curves.linear        );         // 3. 创建 tween 配置动画值的范围        _tweenAnimation = Tween(             begin: 1.0,              end: 2.0).animate(_curvedAnimation);         // 4. 添加值监听        _controller.addListener(() {            setState(() {});        });         // 5. 监听状态         _controller.addStatusListener((status) {            print(status);             if (status == AnimationStatus.completed) {                 _controller.reverse();            }             else if (status == AnimationStatus.dismissed) {                 _controller.forward();            }        });    }    @override  Widget build(BuildContext context) {         return Scaffold(             appBar: AppBar(              title: Text("动画"),             ),             body: Container(                 child: Column(                    crossAxisAlignment: CrossAxisAlignment.start,                    mainAxisAlignment: MainAxisAlignment.start,                    children:[                          SizedBox(height: 200,),                         // 长度变化                         Container(                              color: Colors.blueAccent,                              width: 100 * _tweenAnimation.value,                             height: 60,                         ),                         SizedBox(height: 20,),                        // 透明度变化                          Opacity(                             opacity: 2.0 - _tweenAnimation.value,                             child: Container(                                 color: Colors.blueAccent,                                 width: 100,                                 height: 100,                             ),                         ),                           SizedBox(height: 20,),                          // 字体大小变化                        Text("窗外风好大",                             style: TextStyle(                                 fontSize: 20 * _tweenAnimation.value,                             ),                          ),                      ],                  ),            ),             floatingActionButton: FloatingActionButton(                 child: Icon(Icons.done_outline),                  onPressed: (){                     _controller.forward();                  },             ),         );     }      @override  void dispose() {    _controller.dispose();    super.dispose();  }}
复制代码

根据动画值的变化修改 widget 宽度、透明度和字体大小,虽然动画效果做到了,但是我们必须监听动画值的改变,并且改变后需要调用 setState(),这会带来两个问题:

  • 复用性差,冗余代码多;

  • 每次调用 setState() 都会重新执行 build 方法,浪费性能。

2.1.1 AnimatedWidget

为了解决上面的问题,我们可以使用 AnimatedWidget (而不是 addListener() 和 setState() )来给 widget 添加动画:

  • AnimatedWidget 从 setState() 调用中的动画代码中分离出 widget 代码,创建一个可重用动画的 widget;

  • AnimatedWidget 不需要维护一个 State 对象来保存动画;

  • AnimatedWidget 中会自动调用 addListener() 和 setState()。

所以上面的代码可以优化成下面这样:

// 使用处...body:CJAnimatedWidget(_tweenAnimation),...class CJAnimatedWidget extends AnimatedWidget {  final Animation_tweenAnimation;  CJAnimatedWidget(this._tweenAnimation):super(listenable:_tweenAnimation);  @override  Widget build(BuildContext context) {    return Container(        child:Column(        crossAxisAlignment:CrossAxisAlignment.start,        mainAxisAlignment:MainAxisAlignment.start,        children:[            SizedBox(height:200,),            // 长度变化              Container(                color:Colors.blueAccent,                width:100 * _tweenAnimation.value,                height:60,              ),            SizedBox(height:20,),            // 透明度变化            Opacity(                opacity:2.0 - _tweenAnimation.value,                child:Container(                    color:Colors.blueAccent,                    width:100,                    height:100,                  ),                ),            SizedBox(height:20,),            // 字体大小变化             Text("窗外风好大",                style:TextStyle(                    fontSize:20 * _tweenAnimation.value,                 ),            ),           ],         ),    );  }}
复制代码

Flutter 提供了很多封装完成的 AnimatedWidget 子类给我们使用:

  • FadeTransition

  • ScaleTransition

  • RotationTransition

  • SizeTransition

  • SlideTransition

  • RelativePositionedTransition

  • DecoratedBoxTransition

  • AlignTransition

  • DefaultTextStyleTransition

  • PositionedTransition

AnimatedWidget 虽然解决了一些问题,但是它也有一些弊端:

  • 创建 AnimatedWidget 的子类增加维护成本;

  • 如果 AnimatedWidget 有子 Widget,那么它的子 Widget 也会重新 build。

2.1.2 AnimatedBuilder

为了优化上述问题,我们可以使用 AnimatedBuilder,它可以从 widget 中分离出动画过渡:

AnimatedBuilder 是渲染树中的一个独立的类。与 AnimatedWidget 类似, AnimatedBuilder 自动监听来自 Animation 对象的通知,并根据需要将该控件树标记为脏(dirty),因此不需要手动调用 addListener()。

优化后的代码如下:

...body: Container(    child: AnimatedBuilder(      animation: _tweenAnimation,      builder: (ctx, child) {        return Column(          crossAxisAlignment: CrossAxisAlignment.start,          mainAxisAlignment: MainAxisAlignment.start,          children: [            SizedBox(height: 200,),            // 长度变化            Container(              color: Colors.blueAccent,              width: 100 * _tweenAnimation.value,              height: 60,            ),            SizedBox(height: 20,),            // 透明度变化            Opacity(              opacity: 2.0 - _tweenAnimation.value,              child: Container(                color: Colors.blueAccent,                width: 100,                height: 100,              ),            ),            SizedBox(height: 20,),            // 字体大小变化            Text("窗外风好大",              style: TextStyle(                fontSize: 20 * _tweenAnimation.value,              ),            ),          ],        );      },    ),  ),...
复制代码

2.2 动画组合使用

主要代码如下:

class LookPage extends StatefulWidget {  @override  _CJAnimationWidgetState createState() => _CJAnimationWidgetState();}class _CJAnimationWidgetState extends Statewith SingleTickerProviderStateMixin {    AnimationController _controller;  Animation_curvedAnimation;  Animation_glassLocationAnim;   Animation_glassRotationAnim;   Animation_necklaceOpacityAnim;  Animation_necklaceLocationAnim;  @override  void initState() {    super.initState();    // 1. 创建 controller    _controller = AnimationController(      duration: Duration(milliseconds: 2000),      vsync: this,    );    // 2. 创建 curvedAnimation    _curvedAnimation = CurvedAnimation(        parent: _controller,        curve: Curves.linear    );    // 3. 创建 tween 配置动画值的范围    _glassLocationAnim = Tween(        begin: 0.0,        end: 252.0    ).animate(_curvedAnimation);    _glassRotationAnim = Tween(        begin: 0.0,        end: 2.1*pi    ).animate(_curvedAnimation);    _necklaceLocationAnim = Tween(        begin: 500.0,        end: 370.0    ).animate(_curvedAnimation);    _necklaceOpacityAnim = Tween(        begin: 0.0,        end: 1.0    ).animate(_curvedAnimation);  }  @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text("动画"),      ),      body: AnimatedBuilder(        animation: _controller,        builder: (ctx, child) {          return Stack(            overflow: Overflow.clip,            children:[              Positioned(                  left: 0,                  top: 200,                  width: 414,                  child: Image.asset(                    "assets/images/dog.jpg",                    width: 414,                  )              ),              Positioned(                  left: _glassLocationAnim.value - 130,                  top: 207,                  child: Transform(                    alignment: Alignment.center,                    transform: Matrix4.rotationZ(_glassRotationAnim.value),                    child: Image.asset(                      "assets/images/glasses.png",                      width: 130,                      height: 130,                    ),                  )              ),              Positioned(                  left: 130,                  top: _necklaceLocationAnim.value,                  child: Opacity(                    opacity: 1 * _necklaceOpacityAnim.value,                    child: Image.asset(                      "assets/images/necklace.png",                    width: 160,                     height: 110,                    ),                  )              ),            ],          );        },      ),      floatingActionButton: FloatingActionButton(        child: Icon(Icons.done_outline),        onPressed: (){          if (_controller.status == AnimationStatus.completed) {            _controller.reverse();          } else if (_controller.status == AnimationStatus.dismissed) {            _controller.forward();          }        },      ),    );  }  @override  void dispose() {    _controller.dispose();    super.dispose();  }}
复制代码

主要思路

  • 创建 Tween 配置动画取值范围,如墨镜的移动位置、旋转角度,金链子的移动位置、透明度;

  • 墨镜添加“位置变化和旋转”动画,给墨镜一个初始位置,开启动画之后,墨镜在围绕 Z 轴旋转的同时移动到目标位置;

  • 大金链子则添加“位置变化和透明度”动画,开启动画之后,改变透明度的同时移动到目标位置。

3 系统动画组件

3.1 AnimatedContainer

我们可以理解 AnimatedContainer 是带动画功能的 Container:

  • AnimatedContainer 只需要提供动画开始值和结束值,它就会动起来并不需要我们主动调用 setState() 方法;

  • 动画不仅可以作用在宽高上,还可以作用在颜色、边界、边界圆角半径、背景图片、形状等;

  • AnimatedContainer 有 2 个必须的参数,一个时长 duration,即动画执行的时长,另一个是动画曲线 curve,默认是线性,系统为我们提供了很多动画曲线(加速、减速等),例:

curve: Curves.bounceIn

AnimatedContainer 动画示例:

主要代码如下:

class LookPage extends StatefulWidget {  @override  _CJAnimationWidgetState createState() => _CJAnimationWidgetState();}class _CJAnimationWidgetState extends Statewith SingleTickerProviderStateMixin {  bool _click = false;  @override  Widget build(BuildContext context) {    return Center(      child: GestureDetector(        onTap: () {          setState(() {            _click = !_click;          });        },        child: AnimatedContainer(          height: _click ? 200 : 100,          width: _click ? 200 : 100,          duration: Duration(milliseconds: 2000),          curve: Curves.easeInOutCirc,          transform: Matrix4.rotationX(_click ? pi : 0),          decoration: BoxDecoration(              image: DecorationImage(                image: AssetImage("assets/images/girl.jpg"),                fit: BoxFit.cover,              ),              borderRadius: BorderRadius.all(Radius.circular(                _click ? 200 : 100,              ))          ),          onEnd: () {            setState(() {              _click = !_click;            });          },        ),      ),    );}}
复制代码

主要思路 :根据点击的状态变化,修改图片的尺寸以及图片围绕 X 轴旋转的角度。

3.2 AnimatedCrossFade

AnimatedCrossFade 组件让 2 个组件在切换时出现交叉渐入的效果,因此 AnimatedCrossFade 需要设置 2 个子控件、动画时间和显示第几个子控件。

AnimatedCrossFade 动画示例:

主要代码如下:

class LookPage extends StatefulWidget {  @override  _CJAnimationWidgetState createState() => _CJAnimationWidgetState();}class _CJAnimationWidgetState extends Statewith SingleTickerProviderStateMixin {  bool _click = false;  @override  Widget build(BuildContext context) {    return Scaffold(        appBar: AppBar(          title: Text("动画"),        ),        body: Center(          child: Column(            mainAxisAlignment: MainAxisAlignment.center,            children:[              AnimatedContainer(                duration: Duration(seconds: 2),                width: 200,                height: _click ? 200 : 100,                decoration: BoxDecoration(                    color: _click ? Colors.blueAccent : Colors.green,                    borderRadius: BorderRadius.all(                        Radius.circular(_click ? 0 : 50,)                    )                ),              ),              SizedBox(height: 20,),              AnimatedCrossFade(                duration: Duration(seconds: 2),                crossFadeState: _click ? CrossFadeState.showSecond : CrossFadeState.showFirst,                firstChild: Container(                  height: 100,                  width: 200,                  alignment: Alignment.center,                  decoration: BoxDecoration(                      borderRadius: BorderRadius.circular(50),                      color: Colors.green                  ),                  child: Text('1',                    style: TextStyle(                        color: Colors.white,                        fontSize: 40                    ),                  ),                ),                secondChild: Container(                  height: 200,                  width: 200,                  alignment: Alignment.center,                  decoration: BoxDecoration(                      color: Colors.blueAccent,                  ),                  child: Text('2',                    style: TextStyle(                        color: Colors.white,                        fontSize: 40                    ),                  ),                ),              ),              SizedBox(height: 20,),              AnimatedContainer(                duration: Duration(seconds: 2),                width: 200,                height: _click ? 200 : 100,                decoration: BoxDecoration(                    color: _click ? Colors.blueAccent : Colors.green,                    borderRadius: BorderRadius.all(                        Radius.circular(_click ? 0 : 50,)                    )                ),              ),            ],          ),        ),        floatingActionButton: FloatingActionButton(          child: Icon(Icons.done_outline),          onPressed: () {            setState(() {              _click = !_click;            });          },        )    );  }}
复制代码

主要思路: 根据点击的状态变化,改变 AnimatedCrossFade 显示的子控件,同时让上下的两个 AnimatedContainer 变化形状。

3.3 AnimatedIcon

Flutter 还提供了很多动画图标,想要使用这些动画图标需要使用 AnimatedIcon 控件:

  • 第一步设置图标;

  • 第二步设置 progress,progress 用于图标的动画。

AnimatedIcon 动画示例:

主要代码如下:

class LookPage extends StatefulWidget {  @override  _CJAnimationWidgetState createState() => _CJAnimationWidgetState();}class _CJAnimationWidgetState extends Statewith SingleTickerProviderStateMixin {  AnimationController _controller;  @override  void initState() {    super.initState();    // 1. 创建 controller    _controller = AnimationController(      duration: Duration(milliseconds: 2000),      vsync: this,    );    // 2. 监听状态    _controller.addStatusListener((status) {      print(status);   });  }  @override  Widget build(BuildContext context) {    return Scaffold(        appBar: AppBar(          title: Text("动画"),        ),        body: GridView(          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(            crossAxisCount: 3,          ),          children:[             createAnimatedIcon(AnimatedIcons.add_event),             createAnimatedIcon(AnimatedIcons.arrow_menu),               createAnimatedIcon(AnimatedIcons.close_menu),              createAnimatedIcon(AnimatedIcons.ellipsis_search),              createAnimatedIcon(AnimatedIcons.event_add),              createAnimatedIcon(AnimatedIcons.home_menu),               createAnimatedIcon(AnimatedIcons.list_view),             createAnimatedIcon(AnimatedIcons.menu_arrow),              createAnimatedIcon(AnimatedIcons.menu_close),              createAnimatedIcon(AnimatedIcons.menu_home),              createAnimatedIcon(AnimatedIcons.pause_play),            createAnimatedIcon(AnimatedIcons.play_pause),            createAnimatedIcon(AnimatedIcons.search_ellipsis),            createAnimatedIcon(AnimatedIcons.view_list),          ],        ),        floatingActionButton: FloatingActionButton(          child: Icon(Icons.done_outline),          onPressed: () {            if (_controller.status == AnimationStatus.completed) {                _controller.reverse();            } else if (_controller.status == AnimationStatus.dismissed) {               _controller.forward();            }          },        )    );  } Widget createAnimatedIcon (AnimatedIconData animIconData) {    return Container(      width: 138,      height: 138,      child: Center(        child: AnimatedIcon(          icon: animIconData,          progress: _controller,        ),      ),    );  }}
复制代码

系统动画组件还有很多,但是有些功能是有重叠的,在这里就不一一陈述了。

其他系统动画组件如下:

  • AnimatedAlign

  • AnimatedDefaultTextStyle

  • AnimatedModalBarrier

  • AnimatedOpacity

  • AnimatedPadding

  • AnimatedPhysicalModel

  • AnimatedPositioned

  • AnimatedPositionedDirectional

  • AnimatedSize

4 转场动画

4.1 PageRouteBuilder

如果我们要导航到一个新页面,一般会使用 MaterialPageRoute,在页面切换的时候,会有默认的自适应平台的过渡动画,如果想自定义页面的进场和出场动画,那么需要使用 PageRouteBuilder 来创建路由,PageRouteBuilder 主要的部分:

  • 一个是“pageBuilder”,用来创建所要跳转到的页面;

  • 另一个是“transitionsBuilder”,也就是我们可以自定义的转场效果。

PageRouteBuilder 转场动画示例:

主要代码如下:

class LookPage extends StatefulWidget {  @override _CJAnimationWidgetState createState() => _CJAnimationWidgetState();}class _CJAnimationWidgetState extends Statewith SingleTickerProviderStateMixin {  String _imageURL = "assets/images/cj3.png";  @override  Widget build(BuildContext context) {    return Scaffold(        appBar: AppBar(          title: Text("第一页"),         backgroundColor: Color.fromARGB(255, 24,45, 105),        ),        body: Center(          child: GestureDetector(            onTap: () {              Navigator.of(context).push(PageRouteBuilder(                  pageBuilder: (context, animation, secondaryAnimation) {                    return CJNextPage("assets/images/cj2.png");                  },                transitionsBuilder: (context, animation, secondaryAnimation, child){                   return CJRotationTransition(                      turns: Tween(                          begin: 1.0,                          end: 0.0                      ).animate(animation),                      child: child,                    );                  }                    )              );             },            child: Image.asset(_imageURL, height: 896, fit: BoxFit.fitHeight,)          ),        ),    );  }}class CJNextPage extends StatelessWidget {  final String imageURL;   CJNextPage(this.imageURL);   @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text("第二页"),        backgroundColor: Colors.lightBlueAccent,      ),      backgroundColor: Colors.white,      body: Center(        child: GestureDetector(           onTap: () {            Navigator.of(context).pop();          },            child: Image.asset(imageURL, height: 896, fit: BoxFit.fitHeight),        ),      ),    );}}class CJRotationTransition extends AnimatedWidget {  const CJRotationTransition({    Key key,    @required Animationturns,    this.alignment = Alignment.center,    this.child,  }) : assert(turns != null),        super(key: key, listenable: turns);  Animationget turns => listenable;  final Alignment alignment;  final Widget child;  @override  Widget build(BuildContext context) {    final double turnsValue = turns.value;     final Matrix4 transform = Matrix4.rotationY(turnsValue * pi/2.0);     return Transform(       transform: transform,      alignment: alignment,      child: child,    );  }}
复制代码

主要思路: 自定义 CJRotationTransition 类,使第二个页面出现的时候,围绕 Y 轴旋转 90 度。

4.2 Hero

Hero 是我们常用的过渡动画,当用户点击一张图片,切换到另一个页面时,这个页面也有此图,那么我们可以使用 Flutter 给我们提供的 Hero 组件来完成这个效果:

2 个页面都要有 Hero 控件,且保证 tag 参数一致。

Hero 动画示例:

主要代码如下:

class LookPage extends StatefulWidget {  @override  _CJAnimationWidgetState createState() => _CJAnimationWidgetState();}class _CJAnimationWidgetState extends Statewith SingleTickerProviderStateMixin {  String _imageURL = "assets/images/shoes.JPG";  @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text("第一页"),        backgroundColor: Color.fromARGB(255, 24, 45, 105),      ),      body: GridView(        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(          crossAxisCount: 3,        crossAxisSpacing: 10,         mainAxisSpacing: 10,        ),        children: List.generate(20, (index) {          return GestureDetector(              onTap: () {                Navigator.of(context).push(PageRouteBuilder(                    pageBuilder: (context, animation, secondaryAnimation) {                      return CJHeroPage(_imageURL, "$_imageURL-$index");                    },                    transitionsBuilder: (context, animation, secondaryAnimation,                        child) {                      return FadeTransition(                        opacity: animation,                      child: child,                      );                    }                )                );              },              child: Hero(                 tag: "$_imageURL-$index",                child: Image.asset(_imageURL, width: 125, height: 125,),              )          );        }),      ),    );  }}class CJHeroPage extends StatelessWidget {  final String imageURL;  final String heroTag;  CJHeroPage(this.imageURL, this.heroTag);  @override  Widget build(BuildContext context) {    return Scaffold(      backgroundColor: Colors.black,      body: Center(        child: GestureDetector(          onTap: () {            Navigator.of(context).pop();          },          child: Hero(            tag: heroTag,           child: Image.asset(imageURL, width: double.infinity, fit: BoxFit.cover, ),          ),        ),      ),    );  }}
复制代码

5 总结

本文主要介绍了 Flutter 动画的基本概念和应用。制作动画并不难,但是想要制作一个完整的动画效果就需要耐心的去调整每个细节。加油,奥利给!

本文转载自公众号贝壳产品技术(ID:beikeTC)。

原文链接

如何玩转Flutter动画