很是感谢 Didier Boelens 赞成我将它的一些文章翻译为中文发表,这是其中一篇。git
本文经过一个实例详细讲解了 Flutter 中动画的原理。github
原文的代码块有行号,对修改的代码有黄色背景。在这里不能对代码添加行号和背景颜色,因此,为方便阅读有些代码块用了截图。安全
原文 连接bash
Flutter中的动画功能强大并且使用起来很是简单。 经过一个具体的例子,您将学习如何构建本身的动画所需的一切。markdown
难度:中等session
今天咱们没法想象没有任何动画的移动应用。 当您从一个页面移动到另外一个页面时,点击一个按钮(或InkWell)… 就有一个动画。 动画无处不在。app
Flutter使动画效果很是容易实现。 用很是简单的话来讲,这篇文章讨论了这个主题,早些时候这些事只能留给专家,为了使这篇论文具备吸引力,我采起了用 Flutter 逐步实现如下断头台效果菜单,这个动画是由 Vitaly Rubtsov 在 Dribble 上发布的。less
本文的第一部分讲解了动画的理论和主要概念。 第二部分专门用于动画的实现,就如上面的动图所显示的那样。ide
要有 动画 效果,须要存在如下3个元素:布局
一个 Ticker (断续器)
一个 动做(Animation)
一个 动做控制器 (AnimationController)
简单来讲,Ticker 是一个几乎定时发送信号的类(大约每秒60次)。 想一想你的手表每秒钟都会嘀嗒一声。 在每一个滴答处,Ticker 调用回调方法,该方法具备自第一个滴答开始后的持续时间。
重要
即便在不一样时间启动,全部的 ticher 也将 始终同步。 这对于同步动画动做很是有用
动画只不过是一个能够在动画的生命周期内改变的值(特定类型)。 这个值在动画时间内的变化方式能够是线性的(如1,2,3,4,5 …),也能够更复杂(参见后面的曲线)。
AnimationController 是一个控制(启动,中止,重复......)动画(或几个动画)的类。 换句话说,它使用速度(=每秒的值变化率)使动画值在特定持续时间内从一个低的边界值(lowerBound) 变为 一个高的边界值(upperBound)。
此类可控制动画。 为了更精确,我更愿说“在一个场景”,由于咱们稍后会看到,几个不一样的动画能够由同一个控制器控制......
所以,使用此 AnimationController 类,咱们能够:
向前播放一个场景,反转
中止一个场景
将场景设置为某个值
定义场景的边界值(lowerBound,upperBound)
如下伪代码显示了此类的各类不一样的初始化参数:
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,连接到 Stateful Widget的一个实例。
第2行
告诉 Flutter 你想要一个 新的 单个 Ticker,连接到 MyStateWidget 的这个实例
第8-10行
控制器初始化。 场景 的总持续时间设置为 1000 毫秒并绑定到 Ticker(vsync:this)。 默认的参数是:lowerBound = 0.0和upperBound = 1.0
第16行
很是重要,您须要在销毁 MyStateWidget 实例时释放控制器。
TickerProviderStateMixin 或 SingleTickerProviderStateMixin?
若是您有多个 AnimationController 实例而且想要各自具备不一样的 Ticker,请将 TickerProviderStateMixin 替换为 SingleTickerProviderStateMixin。
多亏了每秒约60次的tick,AnimationController在给定的持续时间内线性生成从 lowerBound 到 upperBound 的值。
在这1000毫秒内生成的值的示例:
咱们看到值在1000毫秒内从0.0(lowerBound)变到1.0(upperBound)。 生成了51个不一样的值。
让咱们打开代码以了解如何使用它。
第12行
这行告诉控制器每次它的值改变时,咱们须要从新构建 Widget(经过 setState() )
第15行
Widget 初始化完成后,咱们告诉控制器开始计数(forward() -> 从 lowerBound 到 upperBound )
第26行
咱们恢复控制器的值( _controller.value ),而且,在这个例子中,这个值的范围是0.0到1.0(0%到100%),咱们获得这个百分比的整数表达式,显示在屏幕中心。
正如咱们刚刚看到的那样,控制器返回一系列 十进制值,这些值以 线性 方式变化。 有时,咱们但愿:
使用其余 类型 的值,例如 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 的默认方式是 线性 的,这是控制器的工做方式。
若是要使角度在 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 提供了一组预约义的变化曲线,列表显示以下:
使用这些曲线:
Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2).animate(
new CurvedAnimation(
parent: _controller,
curve: Curves.ease,
reverseCurve: Curves.easeOut
));
复制代码
这建立了一个值的变化[0; π/2],这个变化使用一下曲线:
Curves.ease 当动画从 0.0 -> π/2 (向前)
Curves.easeOut 当动画从 π/2 -> 0.0 (反转)
AnimationController 是容许您经过 API 控制动画的类。(这是最经常使用的 API):
_controller.forward({ double from })
要求控制器开始 lowerBound - > upperBound 的值的变化。可选参数 from 可用于强制控制器从另外一个值开始“计数”而不是 lowerBound
_controller.reverse({ double from })
要求控制器开始 upperBound - > lowerBound 的值变化。可选参数 from 可用于强制控制器从 upperBound 之外的另外一个值开始“计数”
_controller.stop({ bool canceled: true })
中止运行动画
_controller.reset()
将动画重置为 lowerBound
_controller.animateTo(double target, { Duration duration, Curve curve: Curves.linear })
将动画从其当前值驱动到目标值
_controller.repeat({ double min, double max, Duration period })
开始向前执行动画,并在完成时重复执行动画。 若是定义了 min 和 max 值,则 min 和 max 限制重复发生的次数。
因为动画可能会意外中止(例如屏幕被退出),所以在使用这些 API 时,添加 “.orCancel” 会更安全:
__controller.forward().orCancel;
复制代码
因为这个小技巧,若是在销毁 _controller 以前取消 Ticker,不会抛出任何异常。
官方文档中不存在 “场景(scene)” 这个词,但就我的而言,我发现它更接近现实。 让我解释。
正如我所说,一个 AnimationController 管理动画。 可是,咱们可能会将 “动画(Animation)” 这个词理解为一系列须要按顺序或重叠播放的子动画。关于 如何将子动画连接在一块儿的定义,我就称之为“场景”。
考虑如下状况,其中动画的整个持续时间为10秒,咱们但愿:
前2秒,球从左侧移动到屏幕中间
而后,同一个球须要3秒钟从屏幕的中心移动到顶部中心
最后,球须要5秒淡出。
正如您可能已经想象的那样,咱们必须考虑 3 种不一样的动画:
///
/// _controller 定义,整个持续时间为10秒
///
AnimationController _controller = new AnimationController(
duration: const Duration(seconds: 10),
vsync: this
);
///
/// 第一个动画,将球从左侧移动到中心
///
Animation<Offset> moveLeftToCenter = new Tween(
begin: new Offset(0.0, screenHeight /2),
end: new Offset(screenWidth /2, screenHeight /2)
).animate(_controller);
///
/// 第二个动画,将球从中心移动到顶部
///
Animation<Offset> moveCenterToTop = new Tween(
begin: new Offset(screenWidth /2, screenHeight /2),
end: new Offset(screenWidth /2, 0.0)
).animate(_controller);
///
/// 第三个动画,改变球的不透明度,使其消失
///
Animation<double> disappear = new Tween(
begin: 1.0,
end: 0.0
).animate(_controller);
复制代码
如今问题是,咱们如何连接(或编排)子动画?
答案是经过使用 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]
如今咱们有这些百分比,咱们能够更新每一个动画的定义,以下所示:
///
/// _controller 定义,整个持续时间为10秒
///
AnimationController _controller = new AnimationController(
duration: const Duration(seconds: 10),
vsync: this
);
///
/// 第一个动画,将球从左侧移动到中心
///
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,
),
),
);
///
/// 第二个动画,将球从中心移动到顶部
///
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,
),
),
);
///
/// 第三个动画,改变球的不透明度,使其消失
///
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){
///
/// 当动画开始时,咱们强制播放动画
///
case AnimationStatus.dismissed:
_controller.forward();
break;
///
/// 当动画结束时,咱们强制动画反转执行
///
case AnimationStatus.completed:
_controller.reverse();
break;
}
});
复制代码
既然已经介绍了理论,那么如今是时候实践了……
正如我在本文开头所提到的,我如今将经过实现一个名为 “断头台” 的动画来实现动画的概念。
为了得到这种 断头台 效应,咱们首先须要考虑:
页面内容自己
菜单栏,当咱们点击 菜单(或汉堡包)图标时旋转
旋转 进来 时,菜单会重叠页面内容并填充整个屏幕窗口
一旦菜单彻底可见,咱们再次点击菜单图标,菜单就会旋转 出去,以便回到原来的位置和尺寸
从这些观察中,咱们能够当即推断出咱们不能使用带有 AppBar 的普通 Scaffold (由于后者是固定的)。
咱们相反地会使用两层的 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,覆盖了整个视口。
若是你仔细看视频,能够看到当菜单彻底打开时,它彻底覆盖了屏幕。 当它刚刚打开时,只能看到像 AppBar 这样的东西。
没有什么能阻止咱们以不一样的方式看待事物……若是 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(),
),
),
);
}
}
复制代码
第10-13行
这几行定义断头台菜单围绕一个旋转中心旋转,(菜单图标的位置)
如今这段代码的结果给出了一个未旋转的菜单屏幕(由于 rotationAngle = 0.0),它显示了垂直显示的标题。
若是更新 rotationAngle 的值(在 -π/2 和 0 之间),您将看到按相应角度旋转的菜单。
让咱们作一些动画
如前所述,咱们须要
一个 SingleTickerProviderStateMixin,由于咱们只有一个场景
一个 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,
),
);
}
...
}
复制代码
OK,当咱们点击菜单按钮时,菜单会打开,可是当咱们再次按下按钮时不会关闭。 这就是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;
}
});
...
}
...
}
复制代码
菜单如今按预期打开或关闭,但视频向咱们显示了一个开放/关闭动做,它不是线性的,看起来像一个弹跳效果。 让咱们添加这个效果。
为此,我将选择如下两种效果:
bounceOut 菜单打开时
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 动画的神秘面纱。
请继续关注下一篇文章,顺祝编码愉快。