美文网首页Android开发Android开发经验谈Android技术知识
让动画实现更简单,Flutter 动画简易教程!

让动画实现更简单,Flutter 动画简易教程!

作者: 程序员的Vere | 来源:发表于2019-12-26 22:12 被阅读0次

    本文作者:Didier Boelens
    原文链接:www.didierboelens.com/2018/06/ani…
    翻译: hcc

    Flutter中的动画功能强大且易于使用。接下来通过一个具体的实例,您将学到关于 Flutter 动画的一切。

    难度:中级

    今天,我们无法想象移动应用程序里面没有任何动画,当您从一页跳转到另一页时,或者点击一个按钮(如 InkWell)... 都会有一个动画。动画无处不在。

    Flutter 使动画非常易于实现。

    简而言之,这篇文章就是讨论这个话题的,尽管之前只有专家才能谈论,为了让这篇文章显得更有吸引力,我将挑战一下,仿照 Vitaly Rubtsov 在 Dribble 上传的一个 "Guillotine Menu (斩头菜单)"的一个动画效果,用 Flutter 一步步的实现这个效果。

    image.png

    本文的第一部分将介绍一下主要的理论知识和概念,第二部分将要实现上面的那个动画效果。

    动画中的三大核心

    为了能够实现动画效果,必须提供下面的三个元素:

    • Ticker
    • Animation
    • AnimationController

    下面对这几个元素进行一下简单的介绍,更详细的在后面说明。

    Ticker

    简单来说,Ticker 这个类会在常规的一个时间区间里(大约每秒 60 次),发送一个信号,把这想象成你得手表,每秒都会滴答滴答的转。

    当 Ticker 启动之后,自从第一个 tick 到来开始,每个到的 tick 都会回调 Ticker 的 callback 方法。

    重要提示
    尽管所有的 ticker 可能是在不同的时间里启动的,但是它们总是以同步的方式执行,这对于一些同步动画是很有用的。

    Animation

    Animation 其实没有什么特别的,只不过是一个可以随着动画的生命周期改变的一个值(有特定的类型),值随着动画时间的变化而变化的方式可以是线性的(例如1、2、3、4、5...),也可以更为复杂(参考后面的“Curves 曲线”)。

    AnimationController

    AnimationController 是一个可以控制一个或多个动画(开始,结束,重复)的控制器。换句话说,它让上面说的 Animation 值在一个指定的时间内,根据一个速度从一个最小值变化到最大。

    AnimationController 类介绍

    此类可控制动画。为了更加精确,我宁愿说“ 控制一个场景”,因为稍后我们将看到,几个不同的动画可以由同一个控制器来控制……

    因此,使用这个AnimationController类,我们可以:

    • 开始一个子动画,正向或者反向播放
    • 停止一个子动画
    • 为子动画设置一个具体的值
    • 定义动画值的边界

    以下伪代码可以展示这个类里面的不同的初始化参数

    AnimationController controller = new AnimationController(
        value:      // the current value of the animation, usually 0.0 (= default)
        lowerBound: // the lowest value of the animation, usually 0.0 (= default)
        upperBound: // the highest value of the animation, usually 1.0 (= default)
        duration:   // the total duration of the whole animation (scene)
        vsync:      // the ticker provider
        debugLabel: // a label to be used to identify the controller
                // during debug session
    );
    复制代码
    

    在大多数情况下,初始化 AnimationController 时不会设计到 value,lowerBound,upperBound和debugLabel。

    如何将 AnimationController 绑定到 Ticker 上

    为了让动画正常工作,必须将 AnimationController 绑定到 Ticker 上。

    通常情况下,你可以生成一个 Ticker 绑定到一个 StatefulWidget 实例上。

    class _MyStateWidget extends State<MyStateWidget>
            with SingleTickerProviderStateMixin {
        AnimationController _controller;
    
        @override
        void initState(){
          super.initState();
          _controller = new AnimationController(
            duration: const Duration(milliseconds: 1000), 
            vsync: this,
          );
        }
    
        @override
        void dispose(){
          _controller.dispose();
          super.dispose();
        }
    
        ...
    }
    复制代码
    
    • 第 2 行 这行代码告诉 Flutter ,你想要一个单 Ticker,这个 Ticker 链接到了 MyStateWidget 实例上。

    • 8-10行

    控制器的初始化。场景(子动画)的总持续时间设置为1000毫秒,并绑定到了 Ticker(vsync:this)。

    隐式参数为:lowerBound = 0.0 和 upperBound = 1.0

    • 16行

    非常重要,当 MyStateWidget 这个页面的实例销毁时,您需要释放 controller。

    TickerProviderStateMixin 还是 SingleTickerProviderStateMixin?

    如果你有几个Animation Controller情况下,你想有不同的 Ticker, 只需要将 SingleTickerProviderStateMixin 替换为 TickerProviderStateMixin。

    好的,我已经将控制器绑定到了 Ticker 上,但是它是工作的?

    正是由于 ticker,每秒钟将会产生大约 60 个 tick,AnimationController 将根据 tick 在给定的时间里,线性的产生在最小值和最大值之间的值。

    在这1000毫秒内产生的值的示例如下:

    image.png

    我们看到值在1000毫秒内从0.0(lowerBound)到1.0(upperBound)变化。生成了51个不同的值。

    让我们扩展代码以查看如何使用它。

    class _MyStateWidget extends State<MyStateWidget>
            with SingleTickerProviderStateMixin {
        AnimationController _controller;
    
        @override
        void initState(){
          super.initState();
          _controller = new AnimationController(
            duration: const Duration(milliseconds: 1000), 
            vsync: this,
          );
          _controller.addListener((){
              setState((){});
          });
          _controller.forward();
        }
    
        @override
        void dispose(){
          _controller.dispose();
          super.dispose();
        }
    
        @override
        Widget build(BuildContext context){
            final int percent = (_controller.value * 100.0).round();
            return new Scaffold(
                body: new Container(
                    child: new Center(
                        child: new Text('$percent%'),
                    ),
                ),
            );
        }
    }
    
    复制代码
    
    • 12 行 此行告诉控制器,每次其值更改时,我们都需要重建Widget(通过setState())

    • 第15行

    Widget初始化完成后,我们告诉控制器开始计数(forward() -> 从lowerBound到upperBound)

    • 26行

    我们检索控制器的值(_controller.value),并且在此示例中,此值的范围是0.0到1.0(也就是 0% 到 100%),我们得到此百分比的整数表达式,将其显示在页面的中心。

    动画的概念

    如我们所见, controller 可以以线性的方式返回彼此不同的小数值。

    有的时候我们可能还有其他的需求如:

    • 使用其他类型的值,例如Offset,int …
    • 使用范围不是从0.0到1.0
    • 考虑线性变化以外的其他变化类型以产生一些效果

    使用其他值类型

    为了能够使用其他值类型,Animation 类使用模板。

    换句话说,您可以定义:

    Animation<int> integerVariation;
    Animation<double> decimalVariation;
    Animation<Offset> offsetVariation;
    复制代码
    

    使用不同的数值范围

    有时,我们希望使用一个不同的范围,而不是0.0和1.0。

    为了定义这样的范围,我们将使用 Tween 类。

    为了说明这一点,让我们考虑一个情况,您希望角度从0到π/ 2 变化的情况。

    Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2);
    
    复制代码
    

    变化类型

    如前所述,将默认值从 lowerBound 变化到 upperBound 的默认方式是线性的,controller 就是这么控制的。

    如果要使角度从0到π/ 2 弧度线性变化,请将 Animation 绑定到AnimationController:

    Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2).animate(_controller);
    复制代码
    

    当您开始动画(通过_controller.forward())时,angleAnimation.value 将使用 _controller.value 来获取 范围[0.0; π/ 2] 中的值。

    下图显示了这种线性变化(π/ 2 = 1.57)

    image.png

    使用Flutter预定义的曲线变化

    Flutter 提供了一组预定义的 Curved 变化,如下:

    image.png

    要使用这些曲线效果:

    Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2).animate(
        new CurvedAnimation(
            parent: _controller,
            curve:  Curves.ease,
            reverseCurve: Curves.easeOut
        ));
    复制代码
    

    这将产生值[0; π/ 2] 之间的值:

    • 当正向播放动画,数值从 0 到 π/2 ,会使用 Curves.ease 效果
    • 当反向播放动画,数值从 π/2 到 0,会使用 Curves.easeOut 效果

    控制动画

    该AnimationController 类可以让你通过 API 来控制动画。(以下是最常用的API):

    • _controller.forward({两个区间的值})

    要求控制器开始生成 lowerBound- > upperBound中的值

    from 的可选参数可用于强制控制器从lowerBound之外的另一个值开始“ 计数 ”

    • _controller.reverse({两个区间的值})

    要求控制器开始生成 upperBound- > lowerBound中的值

    from的可选参数可用于强制控制器从“ upperBound ”之外的另一个值开始“ 计数 ”

    • _controller.stop({bool cancelled:true})

    停止运行动画

    • _controller.reset()

    将动画重置为从 LowerBound 开始

    • _controller.animateTo(double target, { Duration duration, Curve curve: Curves.linear })

    将动画的当前值改变到目标值。

    • _controller.repeat({double min,double max,Duration period})

    开始以正向运行动画,并在动画完成后重新启动动画。如果定义了 min 或者 max ,将限制动画的重复执行次数。

    安全起见

    由于动画可能会意外停止(例如关闭屏幕),因此在使用以下API之一时,添加“ .orCancel ” 更为安全:

    __controller.forward().orCancel;
    复制代码
    

    这个小技巧,可以保证,在 _controller 释放之前,如果 Ticker 取消了,将不会导致异常。

    场景的概念

    官方文档中不存在“ 场景 ”一词,但就我个人而言,我发现它更接近现实。我来解释一下。

    如我所说,一个 AnimationController 管理一个Animation。但是,我们可能将“ 动画 ” 一词理解为一系列需要依次播放或重叠播放的子动画。将子动画组合在一起,这就是我所说的“ 场景 ”。

    考虑以下情况,其中动画的整个持续时间为10秒,我们希望达到的效果是:

    • 在开始的2秒内,有一个球从屏幕的左侧移动到屏幕的中间
    • 然后,同一个球需要3秒钟才能从屏幕中心移动到屏幕顶部中心
    • 最终,球需要5秒钟才能消失。 正如您最可能已经想到的那样,我们必须考虑3种不同的动画:
    
    ///
    /// Definition of the _controller with a whole duration of 10 seconds
    ///
    AnimationController _controller = new AnimationController(
        duration: const Duration(seconds: 10), 
        vsync: this
    );
    
    ///
    /// First animation that moves the ball from the left to the center
    ///
    Animation<Offset> moveLeftToCenter = new Tween(
        begin: new Offset(0.0, screenHeight /2), 
        end: new Offset(screenWidth /2, screenHeight /2)
    ).animate(_controller);
    
    ///
    /// Second animation that moves the ball from the center to the top
    ///
    Animation<Offset> moveCenterToTop = new Tween(
        begin: new Offset(screenWidth /2, screenHeight /2), 
        end: new Offset(screenWidth /2, 0.0)
    ).animate(_controller);
    
    ///
    /// Third animation that will be used to change the opacity of the ball to make it disappear
    ///
    Animation<double> disappear = new Tween(
        begin: 1.0, 
        end: 0.0
    ).animate(_controller);
    复制代码
    

    现在的问题是,我们如何链接(或编排)子动画?

    Interval

    组合动画可以通过 Interval 这个类来实现。但是,那什么是 Interval?

    可能和我们脑子里首先想到的不一样, Interval 和时间没有关系,而是一组值的范围。

    如果考虑使用 _controller,则必须记住,它会使值从 lowerBound 到 upperBound 变化。

    通常,这两个值基本定义为 lowerBound = 0.0 和 upperBound = 1.0,这使动画计算更容易,因为[0.0-> 1.0]只是从0%到100%的变化。因此,如果一个场景的总持续时间为10秒,则最有可能在5秒后,相应的_controller.value将非常接近0.5(= 50%)。

    如果将3个不同的动画放在一个时间轴上,则可以获得如下示意图:

    image.png

    如果现在考虑值的间隔,则对于3个动画中的每个动画,我们将得到:

    • moveLeftToCenter

    持续时间:2秒,从0秒开始,以2秒结束=>范围= [0; 2] =>百分比:从整个场景的0%到20%=> [0.0; 0.20]

    • moveCenterToTop

    持续时间:3秒,开始于2秒,结束于5秒=>范围= [2; 5] =>百分比:从整个场景的20%到50%=> [0.20; 0.50]

    • disappear

    持续时间:5秒,开始于5秒,结束于10秒=>范围= [5; 10] =>百分比:从整个场景的50%到100%=> [0.50; 1.0]

    现在我们有了这些百分比,我们得到每个动画的定义,如下:

    
    ///
    /// Definition of the _controller with a whole duration of 10 seconds
    ///
    AnimationController _controller = new AnimationController(
        duration: const Duration(seconds: 10), 
        vsync: this
    );
    
    ///
    /// First animation that moves the ball from the left to the center
    ///
    Animation<Offset> moveLeftToCenter = new Tween(
        begin: new Offset(0.0, screenHeight /2), 
        end: new Offset(screenWidth /2, screenHeight /2)
        ).animate(
                new CurvedAnimation(
                    parent: _controller,
                    curve:  new Interval(
                        0.0,
                        0.20,
                        curve: Curves.linear,
                    ),
                ),
            );
    
    ///
    /// Second animation that moves the ball from the center to the top
    ///
    Animation<Offset> moveCenterToTop = new Tween(
        begin: new Offset(screenWidth /2, screenHeight /2), 
        end: new Offset(screenWidth /2, 0.0)
        ).animate(
                new CurvedAnimation(
                    parent: _controller,
                    curve:  new Interval(
                        0.20,
                        0.50,
                        curve: Curves.linear,
                    ),
                ),
            );
    
    ///
    /// Third animation that will be used to change the opacity of the ball to make it disappear
    ///
    Animation<double> disappear = new Tween(begin: 1.0, end: 0.0)
            .animate(
                new CurvedAnimation(
                    parent: _controller,
                    curve:  new Interval(
                        0.50,
                        1.0,
                        curve: Curves.linear,
                    ),
                ),
            );
    复制代码
    

    这就是定义场景(或一系列动画)所需的全部设置。当然,没有什么可以阻止您重叠子动画…

    响应动画状态

    有时,获取动画(或场景)的状态很方便。

    动画可能具有4种不同的状态:

    • dismissed:动画在开始后停止(或尚未开始)
    • forward:动画从头到尾运行
    • reverse:动画反向播放
    • completed:动画在播放后停止

    要获得此状态,我们需要通过以下方式监听动画状态的变化:

       myAnimation.addStatusListener((AnimationStatus status){
           switch(status){
               case AnimationStatus.dismissed:
                   ...
                   break;
    
               case AnimationStatus.forward:
                   ...
                   break;
    
               case AnimationStatus.reverse:
                   ...
                   break;
    
               case AnimationStatus.completed:
                   ...
                   break;
           }
       });
    
    复制代码
    

    状态应用的典型示例就是状态的切换。例如,动画完成后,我们要反转它,如:

      myAnimation.addStatusListener((AnimationStatus status){
          switch(status){
              ///
              /// When the animation is at the beginning, we force the animation to play
              ///
              case AnimationStatus.dismissed:
                  _controller.forward();
                  break;
    
              ///
              /// When the animation is at the end, we force the animation to reverse
              ///
              case AnimationStatus.completed:
                  _controller.reverse();
                  break;
          }
      });
    复制代码
    

    理论已经足够了,现在我们开始实战

    我在文章开头提到了一个动画,现在我准备开始实现它,名字就叫“guillotine(断头台)”

    动画分析及程序初始化

    未来能够实现“斩头台”效果,我们需要考虑一下几个方面:

    • 页面内容本身
    • 当我们点击菜单图标时,菜单栏会旋转
    • 旋转时,菜单会覆盖页面内容并填充整个视口
    • 一旦菜单是完全可见,我们再次点击图标,菜单旋转出来,以便回到原来的位置和尺寸

    从这些观察中,我们可以立即得出结论,我们没有使用带有AppBar的普通Scaffold(因为后者是固定的)。

    我们需要使用 2 层 Stack:

    • 页面内容(下层)
    • 菜单(上层)

    程序的基本框架基本出来了:

    class MyPage extends StatefulWidget {
        @override
        _MyPageState createState() => new _MyPageState();
    }
    
    class _MyPageState extends State<MyPage>{
      @override
      Widget build(BuildContext context){
          return SafeArea(
            top: false,
            bottom: false,
            child: new Container(
              child: new Stack(
                alignment: Alignment.topLeft,
                children: <Widget>[
                  new Page(),
                  new GuillotineMenu(),
                ],
              ),
            ),
          );
      }
    }
    
    class Page extends StatelessWidget {
        @override
        Widget build(BuildContext context){
            return new Container(
                padding: const EdgeInsets.only(top: 90.0),
                color: Color(0xff222222),
            );
        }
    }
    
    class GuillotineMenu extends StatefulWidget {
        @override
        _GuillotineMenuState createState() => new _GuillotineMenuState();
    }
    
    class _GuillotineMenuState extends State<GuillotineMenu> {
    
        @overrride
        Widget build(BuildContext context){
            return new Container(
                color: Color(0xff333333),
            );
        }
    }
    
    复制代码
    

    这些代码的运行结果为黑屏,仅显示覆盖整个视口的GuillotineMenu。

    菜单效果分析

    如果你看了上面的示例,可以看到菜单完全打开时,它完全覆盖了视口。打开后,只有可见的AppBa。

    而如果最初旋转 GuillotineMenu 并在按下菜单按钮时将其旋转π/ 2,将会怎样呢,如下图所示这样吗?

    image.png

    然后,我们可以按以下方式重写_GuillotineMenuState类:(这里不在解释如何布局,这不是重点)

    
    class _GuillotineMenuState extends State<GuillotineMenu> {
       double rotationAngle = 0.0;
    
        @override
        Widget build(BuildContext context){
            MediaQueryData mediaQueryData = MediaQuery.of(context);
            double screenWidth = mediaQueryData.size.width;
            double screenHeight = mediaQueryData.size.height;
    
            return new Transform.rotate(
                    angle: rotationAngle,
                    origin: new Offset(24.0, 56.0),
                    alignment: Alignment.topLeft,
                    child: Material(
                        color: Colors.transparent,
                        child: Container(
                        width: screenWidth,
                        height: screenHeight,
                        color: Color(0xFF333333),
                        child: new Stack(
                            children: <Widget>[
                                _buildMenuTitle(),
                                _buildMenuIcon(),
                                _buildMenuContent(),
                            ],
                        ),
                    ),
                ),
            );
        }
    
        ///
        /// Menu Title
        ///
        Widget _buildMenuTitle(){
            return new Positioned(
                top: 32.0,
                left: 40.0,
                width: screenWidth,
                height: 24.0,
                child: new Transform.rotate(
                    alignment: Alignment.topLeft,
                    origin: Offset.zero,
                    angle: pi / 2.0,
                    child: new Center(
                    child: new Container(
                        width: double.infinity,
                        height: double.infinity,
                        child: new Opacity(
                        opacity: 1.0,
                        child: new Text('ACTIVITY',
                            textAlign: TextAlign.center,
                            style: new TextStyle(
                                color: Colors.white,
                                fontSize: 20.0,
                                fontWeight: FontWeight.bold,
                                letterSpacing: 2.0,
                            )),
                        ),
                    ),
                )),
            );
        }
    
        ///
        /// Menu Icon
        /// 
        Widget _buildMenuIcon(){
            return new Positioned(
                top: 32.0,
                left: 4.0,
                child: new IconButton(
                    icon: const Icon(
                        Icons.menu,
                        color: Colors.white,
                    ),
                    onPressed: (){},
                ),
            );
        }
    
        ///
        /// Menu content
        ///
        Widget _buildMenuContent(){
            final List<Map> _menus = <Map>[
                {
                "icon": Icons.person,
                "title": "profile",
                "color": Colors.white,
                },
                {
                "icon": Icons.view_agenda,
                "title": "feed",
                "color": Colors.white,
                },
                {
                "icon": Icons.swap_calls,
                "title": "activity",
                "color": Colors.cyan,
                },
                {
                "icon": Icons.settings,
                "title": "settings",
                "color": Colors.white,
                },
            ];
    
            return new Padding(
                padding: const EdgeInsets.only(left: 64.0, top: 96.0),
                child: new Container(
                    width: double.infinity,
                    height: double.infinity,
                    child: new Column(
                        mainAxisAlignment: MainAxisAlignment.start,
                        children: _menus.map((menuItem) {
                            return new ListTile(
                                leading: new Icon(
                                menuItem["icon"],
                                color: menuItem["color"],
                                ),
                                title: new Text(
                                menuItem["title"],
                                style: new TextStyle(
                                    color: menuItem["color"],
                                    fontSize: 24.0),
                                ),
                            );
                        }).toList(),
                    ),
                ),
            );
        }
    }
    复制代码
    
    • 10-13行

    这些线定义了断头台菜单围绕旋转中心(菜单图标的位置)的旋转

    现在,此代码的结果将显示一个未旋转的菜单屏幕(因为rotationAngle = 0.0),该屏幕显示了垂直的标题。

    接下来使 menu 显示动画

    如果更新 rotationAngle 的值(在-π/ 2和0之间),您将看到菜单旋转了相应的角度。

    如前所述,我们需要

    • 一个SingleTickerProviderStateMixin,因为我们只有1个场景
    • 一个AnimationController
    • 一个动画 有一个角度变化

    代码如下所示:

    class _GuillotineMenuState extends State<GuillotineMenu>
        with SingleTickerProviderStateMixin {
    
        AnimationController animationControllerMenu;
        Animation<double> animationMenu;
    
        ///
        /// Menu Icon, onPress() handling
        ///
        _handleMenuOpenClose(){
            animationControllerMenu.forward();
        }
    
        @override
        void initState(){
            super.initState();
    
        ///
            /// Initialization of the animation controller
            ///
            animationControllerMenu = new AnimationController(
                duration: const Duration(milliseconds: 1000), 
                vsync: this
            )..addListener((){
                setState((){});
            });
    
        ///
            /// Initialization of the menu appearance animation
            ///
            _rotationAnimation = new Tween(
                begin: -pi/2.0, 
                end: 0.0
            ).animate(animationControllerMenu);
        }
    
        @override
        void dispose(){
            animationControllerMenu.dispose();
            super.dispose();
        }
    
        @override
        Widget build(BuildContext context){
            MediaQueryData mediaQueryData = MediaQuery.of(context);
            double screenWidth = mediaQueryData.size.width;
            double screenHeight = mediaQueryData.size.height;
            double angle = animationMenu.value;
    
            return new Transform.rotate(
                angle: angle,
                origin: new Offset(24.0, 56.0),
                alignment: Alignment.topLeft,
                child: Material(
                    color: Colors.transparent,
                    child: Container(
                        width: screenWidth,
                        height: screenHeight,
                        color: Color(0xFF333333),
                        child: new Stack(
                            children: <Widget>[
                                _buildMenuTitle(),
                                _buildMenuIcon(),
                                _buildMenuContent(),
                            ],
                        ),
                    ),
                ),
            );
        }
    
        ...
        ///
        /// Menu Icon
        /// 
        Widget _buildMenuIcon(){
            return new Positioned(
                top: 32.0,
                left: 4.0,
                child: new IconButton(
                    icon: const Icon(
                        Icons.menu,
                        color: Colors.white,
                    ),
                    onPressed: _handleMenuOpenClose,
                ),
            );
        }
        ...
    }
    复制代码
    

    现在,当我们按下菜单按钮时,菜单会打开,但再次按下按钮时菜单不会关闭。这是 AnimationStatus 要完成的事情。

    让我们添加一个监听器,并基于 AnimationStatus 决定是向前还是向后运行动画。

    
    ///
    /// Menu animation status
    ///
    enum _GuillotineAnimationStatus { closed, open, animating }
    
    class _GuillotineMenuState extends State<GuillotineMenu>
        with SingleTickerProviderStateMixin {
        AnimationController animationControllerMenu;
        Animation<double> animationMenu;
        _GuillotineAnimationStatus menuAnimationStatus = _GuillotineAnimationStatus.closed;
    
        _handleMenuOpenClose(){
            if (menuAnimationStatus == _GuillotineAnimationStatus.closed){
                animationControllerMenu.forward().orCancel;
            } else if (menuAnimationStatus == _GuillotineAnimationStatus.open) {
                animationControllerMenu.reverse().orCancel;
            }
        }
    
        @override
        void initState(){
            super.initState();
    
        ///
            /// Initialization of the animation controller
            ///
            animationControllerMenu = new AnimationController(
                duration: const Duration(milliseconds: 1000), 
                vsync: this
            )..addListener((){
                setState((){});
            })..addStatusListener((AnimationStatus status) {
                if (status == AnimationStatus.completed) {
            ///
            /// When the animation is at the end, the menu is open
            ///
                  menuAnimationStatus = _GuillotineAnimationStatus.open;
                } else if (status == AnimationStatus.dismissed) {
            ///
            /// When the animation is at the beginning, the menu is closed
            ///
                  menuAnimationStatus = _GuillotineAnimationStatus.closed;
                } else {
            ///
            /// Otherwise the animation is running
            ///
                  menuAnimationStatus = _GuillotineAnimationStatus.animating;
                }
              });
    
        ...
        }
    ...
    }
    复制代码
    

    现在菜单可以按预期方式打开或关闭,但是前面的演示向我们展示了一个打开/关闭的动画,该懂哈不是线性的,看起来有一个反复的回弹效果。接下来让我们添加此效果。

    为此,我将选择以下2种效果:

    • 菜单打开时用 bounceOut
    • 菜单关闭时用 bouncIn
    image.png image.png
    
    class _GuillotineMenuState extends State<GuillotineMenu>
        with SingleTickerProviderStateMixin {
    ...
        @override
        void initState(){
        ...
        ///
        /// Initialization of the menu appearance animation
        /// 
        animationMenu = new Tween(
            begin: -pi / 2.0, 
            end: 0.0
        ).animate(new CurvedAnimation(
            parent: animationControllerMenu,
            curve: Curves.bounceOut,
            reverseCurve: Curves.bounceIn,
        ));
        }
    ...
    }
    复制代码
    

    在此实现中仍有一些细节没有实现:打开菜单时标题消失,而关闭菜单时显示标题。这是一个面朝上/朝外的效果,也要作为动画处理。让我们添加它。

    
    class _GuillotineMenuState extends State<GuillotineMenu>
        with SingleTickerProviderStateMixin {
      AnimationController animationControllerMenu;
      Animation<double> animationMenu;
      Animation<double> animationTitleFadeInOut;
      _GuillotineAnimationStatus menuAnimationStatus;
    
    ...
      @override
      void initState(){
        ...
        ///
        /// Initialization of the menu title fade out/in animation
        /// 
        animationTitleFadeInOut = new Tween(
            begin: 1.0, 
            end: 0.0
        ).animate(new CurvedAnimation(
            parent: animationControllerMenu,
            curve: new Interval(
                0.0,
                0.5,
                curve: Curves.ease,
            ),
        ));
      }
    ...
      ///
      /// Menu Title
      ///
      Widget _buildMenuTitle(){
        return new Positioned(
          top: 32.0,
          left: 40.0,
          width: screenWidth,
          height: 24.0,
          child: new Transform.rotate(
            alignment: Alignment.topLeft,
            origin: Offset.zero,
            angle: pi / 2.0,
            child: new Center(
              child: new Container(
                width: double.infinity,
                height: double.infinity,
                  child: new Opacity(
                    opacity: animationTitleFadeInOut.value,
                    child: new Text('ACTIVITY',
                        textAlign: TextAlign.center,
                        style: new TextStyle(
                            color: Colors.white,
                            fontSize: 20.0,
                            fontWeight: FontWeight.bold,
                            letterSpacing: 2.0,
                        )),
                    ),
                ),
            )),
        );
      }
    ...
    }
    复制代码
    

    最终的效果基本如下:

    image.png

    本文的完整源代码可在GitHub上找到。

    结论

    如您所见,构建动画非常简单,甚至复杂的动画也是如此。

    我希望这篇较长的文章能够成功的解释 Flutter 中的动画。

    当然,你想多多学习flutter的话,可以关注我,简信我 flutter
    送你学习视频

    image.png

    相关文章

      网友评论

        本文标题:让动画实现更简单,Flutter 动画简易教程!

        本文链接:https://www.haomeiwen.com/subject/nvoloctx.html