Flutter动画:用Flutter来实现一个拍手动画_java

文章地址:http://mrw.so/4VPxov 译者:依然范特稀西

在本文中,我们将通过在Flutter中创建一个拍手动画的模型,来学习一些有关动画的核心概念。

就像标题中所说的那样,本文将更多地关注动画,而不会关注Flutter的基础知识。

正文开始

我们将从创建一个新的Flutter项目生成的代码开始,创建一个新的Flutter项目,你就会得到下面的代码:

import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return new MaterialApp(
     title: 'Flutter Demo',
     theme: new ThemeData(
       primarySwatch: Colors.blue,
     ),
     home: new MyHomePage(title: 'Flutter Demo Home Page'),
   );
 }
}

class MyHomePage extends StatefulWidget {
 MyHomePage({Key key, this.title}) : super(key: key);
 final String title;

 @override
 _MyHomePageState createState() => new _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
 int _counter = 0;

 void _incrementCounter() {
   setState(() {
     _counter++;
   });
 }

 @override
 Widget build(BuildContext context) {
   return new Scaffold(
     appBar: new AppBar(
       title: new Text(widget.title),
     ),
     body: new Center(
       child: new Column(
         mainAxisAlignment: MainAxisAlignment.center,
         children: <Widget>[
           new Text(
             'You have pushed the button this many times:',
           ),
           new Text(
             '$_counter',
             style: Theme.of(context).textTheme.display1,
           ),
         ],
       ),
     ),
     floatingActionButton: new FloatingActionButton(
       onPressed: _incrementCounter,
       tooltip: 'Increment',
       child: new Icon(Icons.add),
     ),
   );
 }
}

Flutter为我们提供了一些免费的入门代码,它为我们创建了一个浮动操作按钮,并且自动帮我们管理计数的状态。


在添加动画之前,让我们快速解决一些简单的问题:

  • 更改按钮图标和背景。

  • 按住按钮时,按钮应继续增加计数。

让我们快速解决上面2个问题,然后开始实现动画:

class _MyHomePageState extends State<MyHomePage> {
 int _counter = 0;
 final duration = new Duration(milliseconds: 300);
 Timer timer;


 initState() {
   super.initState();
 }

 dispose() {
  super.dispose();
 }

 void increment(Timer t) {
   setState(() {
     _counter++;
   });
 }

 void onTapDown(TapDownDetails tap) {
   // User pressed the button. This can be a tap or a hold.
   increment(null); // Take care of tap
   timer = new Timer.periodic(duration, increment); // Takes care of hold
 }

 void onTapUp(TapUpDetails tap) {
   // User removed his finger from button.
   timer.cancel();
 }

 Widget getScoreButton() {

   return new Positioned(
       child: new Opacity(opacity: 1.0, child: new Container(
           height: 50.0 ,
           width: 50.0 ,
           decoration: new ShapeDecoration(
             shape: new CircleBorder(
                 side: BorderSide.none
             ),
             color: Colors.pink,
           ),
           child: new Center(child:
           new Text("+" + _counter.toString(),
             style: new TextStyle(color: Colors.white,
                 fontWeight: FontWeight.bold,
                 fontSize: 15.0),))
       )),
       bottom: 100.0
   );
 }

 Widget getClapButton() {
   // Using custom gesture detector because we want to keep increasing the claps
   // when user holds the button.
   return new GestureDetector(
       onTapUp: onTapUp,
       onTapDown: onTapDown,
       child: new Container(
         height: 60.0 ,
         width: 60.0 ,
         padding: new EdgeInsets.all(10.0),
         decoration: new BoxDecoration(
             border: new Border.all(color: Colors.pink, width: 1.0),
             borderRadius: new BorderRadius.circular(50.0),
             color: Colors.white,
             boxShadow: [
               new BoxShadow(color: Colors.pink, blurRadius: 8.0)
             ]
         ),
         child: new ImageIcon(
             new AssetImage("images/clap.png"), color: Colors.pink,
             size: 40.0),
       )
   );
 }

 @override
 Widget build(BuildContext context) {
   return new Scaffold(
     appBar: new AppBar(
       title: new Text(widget.title),
     ),
     body: new Center(
       child: new Column(
         mainAxisAlignment: MainAxisAlignment.center,
         children: <Widget>[
           new Text(
             'You have pushed the button this many times:',
           ),
           new Text(
             '$_counter',
             style: Theme
                 .of(context)
                 .textTheme
                 .display1,
           ),
         ],
       ),
     ),
     floatingActionButton: new Padding(
         padding: new EdgeInsets.only(right: 20.0),
         child: new Stack(
           alignment: FractionalOffset.center,
           overflow: Overflow.visible,
           children: <Widget>[
             getScoreButton(),
             getClapButton(),
           ],
         )
     ),
   );
 }
}

看了上面最终的效果图,我们需要做2件事:

  • 更改widgets的大小。

  • 按下按钮时显示分数widget,释放按钮时将其隐藏。

  • 添加这些小巧的火花widget并为其设置动画。

让我们一个接一个地慢慢增加学习曲线。首先,我们需要了解有关Flutter动画的一些基本知识。

了解Flutter中基本动画的组件

动画不过是随着时间变化的一些值,例如,当我们点击按钮时,我们希望用动画来让显示分数widget 从底部升起,而当手指离开按钮时,继续上升然后隐藏。

如果仅看分数Widget,我们需要在一段时间内更改Widget的位置和不透明度值。

new Positioned(
       child: new Opacity(opacity: 1.0,
         child: new Container(
           ...
         )),
       bottom: 100.0
   );

假设我们希望分数widget需要150毫秒才能从底部显示出来。在以下时间轴上考虑一下:

这是一个简单的2D图形。position将随着时间而改变。请注意,对角线是直线。如果你喜欢,它也可以是曲线。

你可以使position随时间缓慢增加,然后变得越来越快。或者,你也可以让它以超高速进入,然后在最后放慢速度。

下面是我们介绍的第一个组件:Animation Controller

scoreInAnimationController = new AnimationController(duration: new Duration(milliseconds: 150), vsync: this);

在这里,我们为动画创建了一个简单的控制器(Controller)。我们已经指定希望动画运行150ms。但是,vsync是什么东西?

移动设备每隔几毫秒刷新一次屏幕。这就是我们将一组图像视为连续流或电影的方式。

屏幕刷新的速率因设备而异。假设移动设备每秒刷新屏幕60次(每秒60帧)。那就是每16.67毫秒之后,我们就会向大脑提供新的图像。有时,图像就会错位(在屏幕刷新时发出不同的图像),并且看到屏幕撕裂。VSync就是解决这个问题的。

我们给控制器设置一个监听器,然后开始动画:

scoreInAnimationController.addListener(() {
     print(scoreInAnimationController.value);
   });
scoreInAnimationController.forward(from: 0.0);
/* OUTPUT
I/flutter ( 1913): 0.0
I/flutter ( 1913): 0.0
I/flutter ( 1913): 0.22297333333333333
I/flutter ( 1913): 0.3344533333333333
I/flutter ( 1913): 0.4459333333333334
I/flutter ( 1913): 0.5574133333333334
I/flutter ( 1913): 0.6688933333333335
I/flutter ( 1913): 0.7803666666666668
I/flutter ( 1913): 0.8918466666666668
I/flutter ( 1913): 1.0
*/

控制器在150ms内生成了0.01.0的数字。请注意,生成的值几乎是线性的。0.20.30.4…我们如何改变这种行为?这将在第二部分完成:曲线动画

曲线动画
bounceInAnimation = new CurvedAnimation(parent: scoreInAnimationController, curve: Curves.bounceIn);
   bounceInAnimation.addListener(() {
     print(bounceInAnimation.value);
   });

/*OUTPUT
I/flutter ( 5221): 0.0
I/flutter ( 5221): 0.0
I/flutter ( 5221): 0.24945376519722218
I/flutter ( 5221): 0.16975716286388898
I/flutter ( 5221): 0.17177866222222238
I/flutter ( 5221): 0.6359024059750003
I/flutter ( 5221): 0.9119433941222221
I/flutter ( 5221): 1.0
*/

通过将parent属性设置为我们的控制器,并提供动画遵循曲线,就可以创建一个CurvedAnimation,Flutter曲线文档页面上提供了多种曲线供我们选择:https://api.flutter.dev/flutter/animation/Curves-class.html

控制器在150ms的时间内为曲线动画Widget提供从0.01.0的值。曲线动画Widget根据我们设置的曲线对这些值进行插值。

尽管我们得到了0.01.0之间的一系列值,但是我们希望显示分数的Widget显示的值为0-100,我们可以简单地乘以100来得到结果,或者我们可以使用第三个组件:Tween类。

tweenAnimation = new Tween(begin: 0.0, end: 100.0).animate(scoreInAnimationController);
   tweenAnimation.addListener(() {
     print(tweenAnimation.value);
   });

/* Output
I/flutter ( 2639): 0.0
I/flutter ( 2639): 0.0
I/flutter ( 2639): 33.452000000000005
I/flutter ( 2639): 44.602000000000004
I/flutter ( 2639): 55.75133333333334
I/flutter ( 2639): 66.90133333333334
I/flutter ( 2639): 78.05133333333333
I/flutter ( 2639): 89.20066666666668
I/flutter ( 2639): 100.0
*/

Tween类生成beginend之间的值,前面我们已经使用过线性的scoreInAnimationController,与之相反,我们也可以使用反弹曲线来获得不同的值。Tween的优点远不止这些,你还可以补间其他东西,比如你可以补间color(颜色)offset(偏移量)position(位置)、和其他Widget属性,从而进一步扩展了基础补间类。

Score Widget 位置动画

至此,我们已经掌握了足够的知识,现在可以使我们的得分Widget在按下按钮时从底部弹出,而在离开时隐藏。

initState() {
   super.initState();
   scoreInAnimationController = new AnimationController(duration: new Duration(milliseconds: 150), vsync: this);
   scoreInAnimationController.addListener((){
     setState(() {}); // Calls render function
   });
 }

void onTapDown(TapDownDetails tap) {
   scoreInAnimationController.forward(from: 0.0);
   ...
}
Widget getScoreButton() {
   var scorePosition = scoreInAnimationController.value * 100;
   var scoreOpacity = scoreInAnimationController.value;
   return new Positioned(
       child: new Opacity(opacity: scoreOpacity,
                          child: new Container(...)
                         ),
       bottom: scorePosition
   );
 }

Flutter动画:用Flutter来实现一个拍手动画_java_02

如上图所示,点击按钮,Score Widget 从底部弹出了,但是这儿还有一个小问题:当多次点击按钮的时候,score widget 一次又一次的弹出,这是由于上述代码中的一个小错误。每次点击按钮时,我们都告诉控制器从0开始,即forward(from: 0.0)

score widget 退出动画

现在,我们为score Widget 添加退出动画,首先,我们添加一个枚举来更轻松地管理score Widget的状态。

enum ScoreWidgetStatus {
 HIDDEN,
 BECOMING_VISIBLE,
 BECOMING_INVISIBLE
}

然后,创建一个退出动画的控制器,动画控制器将使score widget的位置从100非线性变化到150。我们还为动画添加了状态监听器。动画结束后,我们将得分组件的状态设置为隐藏。

scoreOutAnimationController = new AnimationController(vsync: this, duration: duration);
   scoreOutPositionAnimation = new Tween(begin: 100.0, end: 150.0).animate(
     new CurvedAnimation(parent: scoreOutAnimationController, curve: Curves.easeOut)
   );
   scoreOutPositionAnimation.addListener((){
     setState(() {});
   });
   scoreOutAnimationController.addStatusListener((status) {
     if (status == AnimationStatus.completed) {
       _scoreWidgetStatus = ScoreWidgetStatus.HIDDEN;
     }
   });

当用户手指离开组件的时候,我们将相应地设置状态,并启动300毫秒的计时器。300毫秒后,我们将为得分组件添加位置和不透明度动画。

void onTapUp(TapUpDetails tap) {
   // User removed his finger from button.
   scoreOutETA = new Timer(duration, () {
     scoreOutAnimationController.forward(from: 0.0);
     _scoreWidgetStatus = ScoreWidgetStatus.BECOMING_INVISIBLE;
   });
   holdTimer.cancel();
 }

我们还修改了onTapDown事件以处理某些边角情况。

void onTapDown(TapDownDetails tap) {
   // User pressed the button. This can be a tap or a hold.
   if (scoreOutETA != null) scoreOutETA.cancel(); // We do not want the score to vanish!
   if (_scoreWidgetStatus == ScoreWidgetStatus.HIDDEN) {
     scoreInAnimationController.forward(from: 0.0);
     _scoreWidgetStatus = ScoreWidgetStatus.BECOMING_VISIBLE;
   }
   increment(null); // Take care of tap
   holdTimer = new Timer.periodic(duration, increment); // Takes care of hold
 }

最后,我们需要选择用于score widget的位置和不透明度的控制器值。一个简单的开关就完成了。

Widget getScoreButton() {
   var scorePosition = 0.0;
   var scoreOpacity = 0.0;
   switch(_scoreWidgetStatus) {
     case ScoreWidgetStatus.HIDDEN:
       break;
     case ScoreWidgetStatus.BECOMING_VISIBLE :
       scorePosition = scoreInAnimationController.value * 100;
       scoreOpacity = scoreInAnimationController.value;
       break;
     case ScoreWidgetStatus.BECOMING_INVISIBLE:
       scorePosition = scoreOutPositionAnimation.value;
       scoreOpacity = 1.0 - scoreOutAnimationController.value;
   }
 return ...
}

Flutter动画:用Flutter来实现一个拍手动画_java_03

score widget的运行效果很棒,先弹出然后逐渐消失。

Score Widget 尺寸动画

到这一步,我们几乎知道如何在分数增加时也改变大小。让我们快速添加大小动画,然后继续搞火花闪烁效果

我已经更新了ScoreWidgetStatus枚举来保留一个额外的VISIBLE值。现在,我们为size属性添加一个新的控制器。

    scoreSizeAnimationController = new AnimationController(vsync: this, duration: new Duration(milliseconds: 150));
   scoreSizeAnimationController.addStatusListener((status) {
     if(status == AnimationStatus.completed) {
       scoreSizeAnimationController.reverse();
     }
   });
   scoreSizeAnimationController.addListener((){
     setState(() {});
   });

控制器在150ms的时间内生成从01的值,完成之后((status == AnimationStatus.completed),又会生成从10的值。这会产生很好的增长和收缩效果。

void increment(Timer t) {
   scoreSizeAnimationController.forward(from: 0.0);
   setState(() {
     _counter++;
   });

我们需要注意处理枚举的visible属性情况。为此,我们需要在 Touch down事件中添加一些基本条件。

void onTapDown(TapDownDetails tap) {
   // User pressed the button. This can be a tap or a hold.
   if (scoreOutETA != null) {
     scoreOutETA.cancel(); // We do not want the score to vanish!
   }
   if(_scoreWidgetStatus == ScoreWidgetStatus.BECOMING_INVISIBLE) {
     // We tapped down while the widget was flying up. Need to cancel that animation.
     scoreOutAnimationController.stop(canceled: true);
     _scoreWidgetStatus = ScoreWidgetStatus.VISIBLE;
   }
   else if (_scoreWidgetStatus == ScoreWidgetStatus.HIDDEN ) {
       _scoreWidgetStatus = ScoreWidgetStatus.BECOMING_VISIBLE;
       scoreInAnimationController.forward(from: 0.0);
   }
   increment(null); // Take care of tap
   holdTimer = new Timer.periodic(duration, increment); // Takes care of hold
 }

最后,我们使用Widget中控制器的值


extraSize = scoreSizeAnimationController.value * 10;
...
height: 50.0 + extraSize,
width: 50.0  + extraSize,
...

完整的代码,可以在github(https://gist.github.com/Kartik1607/52c882194e3119e0d176fb15e6c6b913) 中找到。我们同时使用大小和位置动画。大小动画需要一些调整,我们最后会介绍。


最后,火花闪烁动画

在进行火花闪烁动画之前,我们需要对尺寸动画进行一些调整。目前,该按钮已增长太多。解决方法很简单,我们将额外的乘数从10更改为一个较小的数字。

现在来看看火花闪烁动画,我们可以看到到火花其实就是位置在变化的5张图片

我在MS Paint中制作了一个三角形和一个圆形的图片,并将其保存为flutter资源。然后,我们就可以将该图片用作Image asset

在实现动画之前,让我们考虑一下定位以及需要完成的一些任务:

  • 1、我们需要定位5个图片,每张图片以不同的角度形成一个完整的圆。

  • 2、我们需要根据角度旋转图片

  • 3、随着时间增加圆的半径

  • 4、需要根据角度和半径找到坐标。

简单的三角函数给了我们根据角度的正弦和余弦来获得xy坐标的公式。

var sparklesWidget =
       new Positioned(child: new Transform.rotate(
           angle: currentAngle - pi/2,
           child: new Opacity(opacity: sparklesOpacity,
               child : new Image.asset("images/sparkles.png", width: 14.0, height: 14.0, ))
         ),
         left:(sparkleRadius*cos(currentAngle)) + 20,
         top: (sparkleRadius* sin(currentAngle)) + 20 ,
     );

现在,我们需要创建5widget。每个widget具有不同的角度。一个简单的for循环就ok了。

for(int i = 0;i < 5; ++i) {
     var currentAngle = (firstAngle + ((2*pi)/5)*(i));
     var sparklesWidget = ...
     stackChildren.add(sparklesWidget);
   }

2 * pi(360度)分成5个部分,并相应地创建一个widget。然后,我们将widget添加到stackChildren数组中。

好了,到这一步,大多数的准备工作都做完了,我们只需要设置sparkleRadius的动画并生成一个新的firstAngle即可。

sparklesAnimationController = new AnimationController(vsync: this, duration: duration);
   sparklesAnimation = new CurvedAnimation(parent: sparklesAnimationController, curve: Curves.easeIn);
   sparklesAnimation.addListener((){
     setState(() { });
   });

void increment(Timer t) {
   sparklesAnimationController.forward(from: 0.0);
    ...
   setState(() {
   ...
     _sparklesAngle = random.nextDouble() * (2*pi);
   });
   
Widget getScoreButton() {
   ...
   var firstAngle = _sparklesAngle;
   var sparkleRadius = (sparklesAnimationController.value * 50) ;
   var sparklesOpacity = (1 - sparklesAnimation.value);
   ...
}


这就是我们对flutter中的基本动画介绍。我将继续探索flutter,学习创建高级UI。

完整代码访问:https://github.com/Kartik1607/FlutterUI/tree/master/MediumClapAnimation/medium_clap