本文做者:Didier Boelens
原文连接:www.didierboelens.com/2018/06/ani…
翻译: hccgit
Flutter中的动画功能强大且易于使用。接下来经过一个具体的实例,您将学到关于 Flutter 动画的一切。github
今天,咱们没法想象移动应用程序里面没有任何动画,当您从一页跳转到另外一页时,或者点击一个按钮(如 InkWell)... 都会有一个动画。动画无处不在。安全
Flutter 使动画很是易于实现。session
简而言之,这篇文章就是讨论这个话题的,尽管以前只有专家才能谈论,为了让这篇文章显得更有吸引力,我将挑战一下,仿照 Vitaly Rubtsov 在 Dribble 上传的一个 "Guillotine Menu (斩头菜单)"的一个动画效果,用 Flutter 一步步的实现这个效果。app
本文的第一部分将介绍一下主要的理论知识和概念,第二部分将要实现上面的那个动画效果。框架
为了可以实现动画效果,必须提供下面的三个元素:less
下面对这几个元素进行一下简单的介绍,更详细的在后面说明。ide
简单来讲,Ticker 这个类会在常规的一个时间区间里(大约每秒 60 次),发送一个信号,把这想象成你得手表,每秒都会滴答滴答的转。布局
当 Ticker 启动以后,自从第一个 tick 到来开始,每一个到的 tick 都会回调 Ticker 的 callback 方法。学习
重要提示
尽管全部的 ticker 多是在不一样的时间里启动的,可是它们老是以同步的方式执行,这对于一些同步动画是颇有用的。
Animation 其实没有什么特别的,只不过是一个能够随着动画的生命周期改变的一个值(有特定的类型),值随着动画时间的变化而变化的方式能够是线性的(例如一、二、三、四、5...),也能够更为复杂(参考后面的“Curves 曲线”)。
AnimationController 是一个能够控制一个或多个动画(开始,结束,重复)的控制器。换句话说,它让上面说的 Animation 值在一个指定的时间内,根据一个速度从一个最小值变化到最大。
此类可控制动画。为了更加精确,我宁愿说“ 控制一个场景”,由于稍后咱们将看到,几个不一样的动画能够由同一个控制器来控制……
所以,使用这个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 上。
一般状况下,你能够生成一个 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 实例上。
控制器的初始化。场景(子动画)的总持续时间设置为1000毫秒,并绑定到了 Ticker(vsync:this)。
隐式参数为:lowerBound = 0.0 和 upperBound = 1.0
很是重要,当 MyStateWidget 这个页面的实例销毁时,您须要释放 controller。
TickerProviderStateMixin 仍是 SingleTickerProviderStateMixin?
若是你有几个Animation Controller状况下,你想有不一样的 Ticker, 只须要将 SingleTickerProviderStateMixin 替换为 TickerProviderStateMixin。
正是因为 ticker,每秒钟将会产生大约 60 个 tick,AnimationController 将根据 tick 在给定的时间里,线性的产生在最小值和最大值之间的值。
在这1000毫秒内产生的值的示例以下:
咱们看到值在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())
Widget初始化完成后,咱们告诉控制器开始计数(forward() -> 从lowerBound到upperBound)
咱们检索控制器的值(_controller.value),而且在此示例中,此值的范围是0.0到1.0(也就是 0% 到 100%),咱们获得此百分比的整数表达式,将其显示在页面的中心。
如咱们所见, controller 能够以线性的方式返回彼此不一样的小数值。
有的时候咱们可能还有其余的需求如:
为了可以使用其余值类型,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)
Flutter 提供了一组预约义的 Curved 变化,以下:
要使用这些曲线效果:
Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2).animate( new CurvedAnimation( parent: _controller, curve: Curves.ease, reverseCurve: Curves.easeOut )); 复制代码
这将产生值[0; π/ 2] 之间的值:
该AnimationController 类可让你经过 API 来控制动画。(如下是最经常使用的API):
要求控制器开始生成 lowerBound- > upperBound中的值
from 的可选参数可用于强制控制器从lowerBound以外的另外一个值开始“ 计数 ”
要求控制器开始生成 upperBound- > lowerBound中的值
from的可选参数可用于强制控制器从“ upperBound ”以外的另外一个值开始“ 计数 ”
中止运行动画
将动画重置为从 LowerBound 开始
将动画的当前值改变到目标值。
开始以正向运行动画,并在动画完成后从新启动动画。若是定义了 min 或者 max ,将限制动画的重复执行次数。
因为动画可能会意外中止(例如关闭屏幕),所以在使用如下API之一时,添加“ .orCancel ” 更为安全:
__controller.forward().orCancel; 复制代码
这个小技巧,能够保证,在 _controller 释放以前,若是 Ticker 取消了,将不会致使异常。
官方文档中不存在“ 场景 ”一词,但就我我的而言,我发现它更接近现实。我来解释一下。
如我所说,一个 AnimationController 管理一个Animation。可是,咱们可能将“ 动画 ” 一词理解为一系列须要依次播放或重叠播放的子动画。将子动画组合在一块儿,这就是我所说的“ 场景 ”。
考虑如下状况,其中动画的整个持续时间为10秒,咱们但愿达到的效果是:
/// /// 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 和时间没有关系,而是一组值的范围。
若是考虑使用 _controller,则必须记住,它会使值从 lowerBound 到 upperBound 变化。
一般,这两个值基本定义为 lowerBound = 0.0 和 upperBound = 1.0,这使动画计算更容易,由于[0.0-> 1.0]只是从0%到100%的变化。所以,若是一个场景的总持续时间为10秒,则最有可能在5秒后,相应的_controller.value将很是接近0.5(= 50%)。
若是将3个不一样的动画放在一个时间轴上,则能够得到以下示意图:
若是如今考虑值的间隔,则对于3个动画中的每一个动画,咱们将获得:
持续时间:2秒,从0秒开始,以2秒结束=>范围= [0; 2] =>百分比:从整个场景的0%到20%=> [0.0; 0.20]
持续时间:3秒,开始于2秒,结束于5秒=>范围= [2; 5] =>百分比:从整个场景的20%到50%=> [0.20; 0.50]
持续时间: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种不一样的状态:
要得到此状态,咱们须要经过如下方式监听动画状态的变化:
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,将会怎样呢,以下图所示这样吗?
而后,咱们能够按如下方式重写_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(), ), ), ); } } 复制代码
这些线定义了断头台菜单围绕旋转中心(菜单图标的位置)的旋转
如今,此代码的结果将显示一个未旋转的菜单屏幕(由于rotationAngle = 0.0),该屏幕显示了垂直的标题。
若是更新 rotationAngle 的值(在-π/ 2和0之间),您将看到菜单旋转了相应的角度。
如前所述,咱们须要
代码以下所示:
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种效果:
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, )), ), ), )), ); } ... } 复制代码
最终的效果基本以下:
本文的完整源代码可在GitHub上找到。
如您所见,构建动画很是简单,甚至复杂的动画也是如此。
我但愿这篇较长的文章可以成功的解释 Flutter 中的动画。
固然,你想多多学习flutter的话,能够关注我