功能型组件
导航返回拦截
Flutter中可以通过WillPopScope
来实现返回按钮拦截
1 2 3 4 5
| const WillPopScope({ ... @required WillPopCallback onWillPop, @required Widget child })
|
onWillPop
是一个回调函数,当用户点击返回按钮时被调用(包括导航返回按钮及Android物理返回按钮)。该回调需要返回一个Future
对象,如果返回的Future
最终值为false
时,则当前路由不出栈(不会返回);最终值为true
时,当前路由出栈退出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @override Widget build(BuildContext context) { return new WillPopScope( onWillPop: () async { if (_lastPressedAt == null || DateTime.now().difference(_lastPressedAt) > Duration(seconds: 1)) { _lastPressedAt = DateTime.now(); return false; } return true; }, child: Container( alignment: Alignment.center, child: Text("1秒内连续按两次返回键退出"), ) ); }
|
InheritedWidget
是Flutter中非常重要的一个功能型组件,它提供了一种数据在widget树中从上到下传递、共享的方式,比如我们在应用的根widget中通过InheritedWidget
共享了一个数据,那么我们便可以在任意子widget中来获取该共享的数据!Flutter SDK中正是通过InheritedWidget来共享应用主题(Theme
)和Locale (当前语言环境)信息的。
didChangeDependencies
在之前介绍StatefulWidget
时,我们提到State
对象有一个didChangeDependencies
回调,它会在“依赖”发生变化时被Flutter Framework调用。这个“依赖”指的就是子widget是否使用了父widget中InheritedWidget
的数据!这种机制可以使子组件在所依赖的InheritedWidget
变化时来更新自身!比如当主题、locale(语言)等发生变化时,依赖其的子widget的didChangeDependencies
方法将会被调用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| class ShareDataWidget extends InheritedWidget { ShareDataWidget({ @required this.data, Widget child }) :super(child: child);
final int data;
static ShareDataWidget of(BuildContext context) { return context.dependOnInheritedWidgetOfExactType<ShareDataWidget>(); }
@override bool updateShouldNotify(ShareDataWidget old) { return old.data != data; } }
|
一般来说,子widget很少会重写此方法,因为在依赖改变后framework也都会调用build()
方法。但是,如果你需要在依赖改变后执行一些昂贵的操作,比如网络请求,这时最好的方式就是在此方法中执行,这样可以避免每次build()
都执行这些昂贵操作。
跨组件状态共享(Provider)
组件私有的状态管理方式就比较多,如使用全局事件总线EventBus,还有就是InheritedWidget。Flutter社区著名的Provider包正是基于这个思想实现的一套跨组件状态共享解决方案。
现在Flutter社区已经有很多专门用于状态管理的包了,在此我们列出几个相对评分比较高的:
颜色和主题
颜色
1 2 3 4 5
| Color(0xffdc380d);
var c = "dc380d"; Color(int.parse(c,radix:16)|0xFF000000) Color(int.parse(c,radix:16)).withAlpha(255)
|
颜色亮度: Color类中提供了一个computeLuminance()
方法,它可以返回一个[0-1]的一个值,数字越大颜色就越浅,我们可以根据它来动态确定Title的颜色。
1
| color: color.computeLuminance() < 0.5 ? Colors.white : Colors.black,
|
MaterialColor
MaterialColor
是实现Material Design中的颜色的类,它包含一种颜色的10个级别的渐变色。MaterialColor
通过"[]"运算符的索引值来代表颜色的深度,有效的索引有:50,100,200,…,900,数字越大,颜色越深。
Colors.blue
是预定义的一个MaterialColor
类对象,定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| static const MaterialColor blue = MaterialColor( _bluePrimaryValue, <int, Color>{ 50: Color(0xFFE3F2FD), 100: Color(0xFFBBDEFB), 200: Color(0xFF90CAF9), 300: Color(0xFF64B5F6), 400: Color(0xFF42A5F5), 500: Color(_bluePrimaryValue), 600: Color(0xFF1E88E5), 700: Color(0xFF1976D2), 800: Color(0xFF1565C0), 900: Color(0xFF0D47A1), }, ); static const int _bluePrimaryValue = 0xFF2196F3;
|
Colors.blue[50]
到Colors.blue[900]
的色值从浅蓝到深蓝渐变。
Theme
Theme
组件可以为Material APP定义主题数据(ThemeData)。Material组件库里很多组件都使用了主题数据,如导航栏颜色、标题字体、Icon样式等。Theme
内会使用InheritedWidget
来为其子树共享样式数据。
ThemeData
用于保存是Material 组件库的主题数据,我们可以通过ThemeData来自定义应用主题。在子组件中,我们可以通过Theme.of
方法来获取当前的ThemeData
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| ThemeData({ Brightness brightness, MaterialColor primarySwatch, Color primaryColor, Color accentColor, Color cardColor, Color dividerColor, ButtonThemeData buttonTheme, Color cursorColor, Color dialogBackgroundColor, String fontFamily, TextTheme textTheme, IconThemeData iconTheme, TargetPlatform platform, ... })
|
举例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| @override Widget build(BuildContext context) { ThemeData themeData = Theme.of(context); return Theme( data: ThemeData( primarySwatch: _themeColor, iconTheme: IconThemeData(color: _themeColor) ), child: Scaffold( appBar: AppBar(title: Text("主题测试")), body: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Icon(Icons.favorite), Icon(Icons.airport_shuttle), Text(" 颜色跟随主题") ] ), Theme( data: themeData.copyWith( iconTheme: themeData.iconTheme.copyWith( color: Colors.black ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Icon(Icons.favorite), Icon(Icons.airport_shuttle), Text(" 颜色固定黑色") ] ), ), ], ), floatingActionButton: FloatingActionButton( onPressed: () => setState(() => _themeColor = _themeColor == Colors.teal ? Colors.blue : Colors.teal ), child: Icon(Icons.palette) ), ), ); }
|
需要注意的有三点:
-
可以通过局部主题覆盖全局主题,正如代码中通过Theme为第二行图标指定固定颜色(黑色)一样,这是一种常用的技巧,Flutter中会经常使用这种方法来自定义子树主题。那么为什么局部主题可以覆盖全局主题?这主要是因为widget中使用主题样式时是通过Theme.of(BuildContext context)
来获取的,我们看看其简化后的代码:
-
1 2 3 4
| static ThemeData of(BuildContext context, { bool shadowThemeOnly = false }) { return context.dependOnInheritedWidgetOfExactType<_InheritedTheme>().theme.data }
|
context.dependOnInheritedWidgetOfExactType
会在widget树中从当前位置向上查找第一个类型为_InheritedTheme
的widget。所以当局部指定Theme
后,其子树中通过Theme.of()
向上查找到的第一个_InheritedTheme
便是我们指定的Theme
。
-
本示例是对单个路由换肤,如果想要对整个应用换肤,则可以去修改MaterialApp
的theme
属性。
异步UI更新
很多时候我们会依赖一些异步数据来动态更新UI,因此Flutter专门提供了FutureBuilder
和StreamBuilder
两个组件来快速实现这种功能。
FutureBuilder
下FutureBuilder
构造函数:
1 2 3 4 5
| FutureBuilder({ this.future, this.initialData, @required this.builder, })
|
-
future
:FutureBuilder
依赖的Future
,通常是一个异步耗时任务。
-
initialData
:初始数据,用户设置默认数据。
-
builder
:Widget构建器;该构建器会在Future
执行的不同阶段被多次调用,构建器签名如下:
1
| Function (BuildContext context, AsyncSnapshot snapshot)
|
snapshot
会包含当前异步任务的状态信息及结果信息 ,比如我们可以通过snapshot.connectionState
获取异步任务的状态信息、通过snapshot.hasError
判断异步任务是否有错误等等,完整的定义读者可以查看AsyncSnapshot
类定义。
另外,FutureBuilder
的builder
函数签名和StreamBuilder
的builder
是相同的。
举例:
1 2 3
| Future<String> mockNetworkData() async { return Future.delayed(Duration(seconds: 2), () => "我是从互联网上获取的数据"); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| Widget build(BuildContext context) { return Center( child: FutureBuilder<String>( future: mockNetworkData(), builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.hasError) { return Text("Error: ${snapshot.error}"); } else { return Text("Contents: ${snapshot.data}"); } } else { return CircularProgressIndicator(); } }, ), ); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| enum ConnectionState { none,
waiting,
active,
done, }
|
StreamBuilder
treamBuilder
正是用于配合Stream
来展示流上事件(数据)变化的UI组件。下面看一下StreamBuilder
的默认构造函数:
1 2 3 4 5 6
| StreamBuilder({ Key key, this.initialData, Stream<T> stream, @required this.builder, })
|
举例:
1 2 3 4 5
| Stream<int> counter() { return Stream.periodic(Duration(seconds: 1), (i) { return i; }); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| Widget build(BuildContext context) { return StreamBuilder<int>( stream: counter(), builder: (BuildContext context, AsyncSnapshot<int> snapshot) { if (snapshot.hasError) return Text('Error: ${snapshot.error}'); switch (snapshot.connectionState) { case ConnectionState.none: return Text('没有Stream'); case ConnectionState.waiting: return Text('等待数据...'); case ConnectionState.active: return Text('active: ${snapshot.data}'); case ConnectionState.done: return Text('Stream已关闭'); } return null; }, ); }
|
对话框
AlertDialog
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const AlertDialog({ Key key, this.title, this.titlePadding, this.titleTextStyle, this.content, this.contentPadding = const EdgeInsets.fromLTRB(24.0, 20.0, 24.0, 24.0), this.contentTextStyle, this.actions, this.backgroundColor, this.elevation, this.semanticLabel, this.shape, })
|
举例:
对话框样式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| AlertDialog( title: Text("提示"), content: Text("您确定要删除当前文件吗?"), actions: <Widget>[ FlatButton( child: Text("取消"), onPressed: () => Navigator.of(context).pop(), ), FlatButton( child: Text("删除"), onPressed: () { Navigator.of(context).pop(true); }, ), ], );
|
通过Navigator.of(context).pop(…)
方法来关闭对话框的,这和路由返回的方式是一致的,并且都可以返回一个结果数据。
showDialog()
是Material组件库提供的一个用于弹出Material风格对话框的方法
1 2 3 4 5
| Future<T> showDialog<T>({ @required BuildContext context, bool barrierDismissible = true, WidgetBuilder builder, })
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| RaisedButton( child: Text("对话框1"), onPressed: () async { bool delete = await showDeleteConfirmDialog1(); if (delete == null) { print("取消删除"); } else { print("已确认删除"); } }, ),
Future<bool> showDeleteConfirmDialog1() { return showDialog<bool>( context: context, builder: (context) { return AlertDialog( title: Text("提示"), content: Text("您确定要删除当前文件吗?"), actions: <Widget>[ FlatButton( child: Text("取消"), onPressed: () => Navigator.of(context).pop(), ), FlatButton( child: Text("删除"), onPressed: () { Navigator.of(context).pop(true); }, ), ], ); }, ); }
|
SimpleDialog
SimpleDialog
也是Material组件库提供的对话框,它会展示一个列表,用于列表选择的场景。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| Future<void> changeLanguage() async { int i = await showDialog<int>( context: context, builder: (BuildContext context) { return SimpleDialog( title: const Text('请选择语言'), children: <Widget>[ SimpleDialogOption( onPressed: () { Navigator.pop(context, 1); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: const Text('中文简体'), ), ), SimpleDialogOption( onPressed: () { Navigator.pop(context, 2); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: const Text('美国英语'), ), ), ], ); });
if (i != null) { print("选择了:${i == 1 ? "中文简体" : "美国英语"}"); } }
|
Dialog
AlertDialog
和SimpleDialog
都使用了Dialog
类。由于AlertDialog
和SimpleDialog
中使用了IntrinsicWidth
来尝试通过子组件的实际尺寸来调整自身尺寸,这就导致他们的子组件不能是延迟加载模型的组件(如ListView
、GridView
、 CustomScrollView
等。
如果需要嵌套一个ListView,则可以直接使用
Dialog`类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| Future<void> showListDialog() async { int index = await showDialog<int>( context: context, builder: (BuildContext context) { var child = Column( children: <Widget>[ ListTile(title: Text("请选择")), Expanded( child: ListView.builder( itemCount: 30, itemBuilder: (BuildContext context, int index) { return ListTile( title: Text("$index"), onTap: () => Navigator.of(context).pop(index), ); }, )), ], ); return Dialog(child: child); }, ); if (index != null) { print("点击了:$index"); } }
|
弹窗只是一个普通的widget,可以自己定义view,相当于就是自定义的弹框。
1 2 3 4 5 6 7 8 9 10
| return UnconstrainedBox( constrainedAxis: Axis.vertical, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: 280), child: Material( child: child, type: MaterialType.card, ), ), );
|
对话框动画及遮罩
showDialog
方法,它是Material组件库中提供的一个打开Material风格对话框的方法。那如何打开一个普通风格的对话框呢(非Material风格)? Flutter 提供了一个showGeneralDialog
方法,showDialog
方法正是showGeneralDialog
的一个封装。
1 2 3 4 5 6 7 8 9
| Future<T> showGeneralDialog<T>({ @required BuildContext context, @required RoutePageBuilder pageBuilder, bool barrierDismissible, String barrierLabel, Color barrierColor, Duration transitionDuration, RouteTransitionsBuilder transitionBuilder, })
|
举例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| Future<T> showCustomDialog<T>({ @required BuildContext context, bool barrierDismissible = true, WidgetBuilder builder, }) { final ThemeData theme = Theme.of(context, shadowThemeOnly: true); return showGeneralDialog( context: context, pageBuilder: (BuildContext buildContext, Animation<double> animation, Animation<double> secondaryAnimation) { final Widget pageChild = Builder(builder: builder); return SafeArea( child: Builder(builder: (BuildContext context) { return theme != null ? Theme(data: theme, child: pageChild) : pageChild; }), ); }, barrierDismissible: barrierDismissible, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, barrierColor: Colors.black87, transitionDuration: const Duration(milliseconds: 150), transitionBuilder: _buildMaterialDialogTransitions, ); }
Widget _buildMaterialDialogTransitions( BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) { return ScaleTransition( scale: CurvedAnimation( parent: animation, curve: Curves.easeOut, ), child: child, ); }
|
现在,我们使用showCustomDialog
打开文件删除确认对话框
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| showCustomDialog<bool>( context: context, builder: (context) { return AlertDialog( title: Text("提示"), content: Text("您确定要删除当前文件吗?"), actions: <Widget>[ FlatButton( child: Text("取消"), onPressed: () => Navigator.of(context).pop(), ), FlatButton( child: Text("删除"), onPressed: () { Navigator.of(context).pop(true); }, ), ], ); }, );
|
对话框实现原理
接调用Navigator
的push
方法打开了一个新的对话框路由_DialogRoute
,然后返回了push
的返回值。可见对话框实际上正是通过路由的形式实现的,这也是为什么我们可以使用Navigator
的pop
方法来退出对话框的原因。
对话框状态管理
我们知道在操作对话框里的组件来setState
方法只会针对当前context的子树重新build,但是我们的对话框并不是在_DialogRouteState
的build
方法中构建的,而是通过showDialog
单独构建的,所以在_DialogRouteState
的context中调用setState
是无法影响通过showDialog
构建的UI的,。另外,我们可以从另外一个角度来理解这个现象,前面说过对话框也是通过路由的方式来实现的,那么上面的代码实际上就等同于企图在父路由中调用setState
来让子路由更新,这显然是不行的!也就是说其实对话框是另一个页面,setState影响不到UI页面。简尔言之,根本原因就是context不对。
方案一:单独抽离出StatefulWidget
单独抽离对话框到新的StatefulWidget,但是同时使用回调将值返回给Ui。
方案二:使用StatefulBuilder方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| class StatefulBuilder extends StatefulWidget { const StatefulBuilder({ Key key, @required this.builder, }) : assert(builder != null), super(key: key);
final StatefulWidgetBuilder builder;
@override _StatefulBuilderState createState() => _StatefulBuilderState(); }
class _StatefulBuilderState extends State<StatefulBuilder> { @override Widget build(BuildContext context) => widget.builder(context, setState); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| Row( children: <Widget>[ Text("同时删除子目录?"), StatefulBuilder( builder: (context, _setState) { return Checkbox( value: _withTree, onChanged: (bool value) { _setState(() { _withTree = !_withTree; }); }, ); }, ), ], ),
|
方案三:使用markNeedsBuild
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| ... Row( children: <Widget>[ Text("同时删除子目录?"), Builder( builder: (BuildContext context) { return Checkbox( value: _withTree, onChanged: (bool value) { (context as Element).markNeedsBuild(); _withTree = !_withTree; }, ); }, ), ], ),
|
其它类型的对话框
底部菜单列表
howModalBottomSheet
方法可以弹出一个Material风格的底部菜单列表模态对话框,示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| Future<int> _showModalBottomSheet() { return showModalBottomSheet<int>( context: context, builder: (BuildContext context) { return ListView.builder( itemCount: 30, itemBuilder: (BuildContext context, int index) { return ListTile( title: Text("$index"), onTap: () => Navigator.of(context).pop(index), ); }, ); }, ); }
|
还有一个showBottomSheet
方法,该方法会从设备底部向上弹出一个全屏的菜单列表,示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| PersistentBottomSheetController<int> _showBottomSheet() { return showBottomSheet<int>( context: context, builder: (BuildContext context) { return ListView.builder( itemCount: 30, itemBuilder: (BuildContext context, int index) { return ListTile( title: Text("$index"), onTap: (){ print("$index"); Navigator.of(context).pop(); }, ); }, ); }, ); }
|
Loading框
Loading框可以直接通过showDialog
+AlertDialog
来自定义
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| showLoadingDialog() { showDialog( context: context, barrierDismissible: false, builder: (context) { return AlertDialog( content: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ CircularProgressIndicator(), Padding( padding: const EdgeInsets.only(top: 26.0), child: Text("正在加载,请稍后..."), ) ], ), ); }, ); }
|
日历选择
1 2 3 4 5 6 7 8 9 10 11
| Future<DateTime> _showDatePicker1() { var date = DateTime.now(); return showDatePicker( context: context, initialDate: date, firstDate: date, lastDate: date.add( Duration(days: 30), ), ); }
|
iOS风格的日历选择器需要使用showCupertinoModalPopup
方法和CupertinoDatePicker
组件来实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| Future<DateTime> _showDatePicker2() { var date = DateTime.now(); return showCupertinoModalPopup( context: context, builder: (ctx) { return SizedBox( height: 200, child: CupertinoDatePicker( mode: CupertinoDatePickerMode.dateAndTime, minimumDate: date, maximumDate: date.add( Duration(days: 30), ), maximumYear: date.year + 1, onDateTimeChanged: (DateTime value) { print(value); }, ), ); }, ); }
|
动画
Flutter中也对动画进行了抽象,主要涉及Animation、Curve、Controller、Tween这四个角色,它们一起配合来完成一个完整动画。
Animation
Animation
是一个抽象类,它本身和UI渲染没有任何关系,而它主要的功能是保存动画的插值和状态;其中一个比较常用的Animation
类是Animation<double>
。
Animation
对象是一个在一段时间内依次生成一个区间(Tween)之间值的类。Animation
对象在整个动画执行过程中输出的值可以是线性的、曲线的、一个步进函数或者任何其他曲线函数等等,这由Curve
来决定
根据Animation
对象的控制方式,动画可以正向运行(从起始状态开始,到终止状态结束),也可以反向运行,甚至可以在中间切换方向。Animation
还可以生成除double
之外的其他类型值,如:Animation<Color>
或Animation<Size>
。在动画的每一帧中,我们可以通过Animation
对象的value
属性获取动画的当前状态值。
动画通知
我们可以通过Animation
来监听动画每一帧以及执行状态的变化,Animation
有如下两个方法:
addListener()
;它可以用于给Animation
添加帧监听器,在每一帧都会被调用。帧监听器中最常见的行为是改变状态后调用setState()
来触发UI重建。
addStatusListener()
;它可以给Animation
添加“动画状态改变”监听器;动画开始、结束、正向或反向(见AnimationStatus
定义)时会调用状态改变的监听器。
Curve
动画过程可以是匀速的、匀加速的或者先加速后减速等。Flutter中通过Curve
(曲线)来描述动画过程,我们把匀速动画称为线性的(Curves.linear),而非匀速动画称为非线性的。
我们可以通过CurvedAnimation
来指定动画的曲线,如:
1 2
| final CurvedAnimation curve = new CurvedAnimation(parent: controller, curve: Curves.easeIn);
|
urvedAnimation
和AnimationController
(下面介绍)都是Animation<double>
类型。CurvedAnimation
可以通过包装AnimationController
和Curve
生成一个新的动画对象 ,我们正是通过这种方式来将动画和动画执行的曲线关联起来的。我们指定动画的曲线为Curves.easeIn
,它表示动画开始时比较慢,结束时比较快。 Curves 类是一个预置的枚举类,定义了许多常用的曲线,下面列几种常用的:
Curves曲线 |
动画过程 |
linear |
匀速的 |
decelerate |
匀减速 |
ease |
开始加速,后面减速 |
easeIn |
开始慢,后面快 |
easeOut |
开始快,后面慢 |
easeInOut |
开始慢,然后加速,最后再减速 |
自定义Curve:
1 2 3 4 5 6
| class ShakeCurve extends Curve { @override double transform(double t) { return math.sin(t * math.PI * 2); } }
|
AnimationController
AnimationController
用于控制动画,它包含动画的启动forward()
、停止stop()
、反向播放 reverse()
等方法。AnimationController
会在动画的每一帧,就会生成一个新的值。默认情况下,AnimationController
在给定的时间段内线性的生成从0.0到1.0(默认区间)的数字。
1 2
| inal AnimationController controller = new AnimationController( duration: const Duration(milliseconds: 2000), vsync: this);
|
AnimationController
生成数字的区间可以通过lowerBound
和upperBound
来指定,如:
1 2 3 4 5 6
| final AnimationController controller = new AnimationController( duration: const Duration(milliseconds: 2000), lowerBound: 10.0, upperBound: 20.0, vsync: this );
|
AnimationController
派生自Animation<double>
,因此可以在需要Animation
对象的任何地方使用。 但是,AnimationController
具有控制动画的其他方法,例如forward()
方法可以启动正向动画,reverse()
可以启动反向动画。在动画开始执行后开始生成动画帧,屏幕每刷新一次就是一个动画帧,在动画的每一帧,会随着根据动画的曲线来生成当前的动画值(Animation.value
),然后根据当前的动画值去构建UI,当所有动画帧依次触发时,动画值会依次改变,所以构建的UI也会依次变化,所以最终我们可以看到一个完成的动画。 另外在动画的每一帧,Animation
对象会调用其帧监听器,等动画状态发生改变时(如动画结束)会调用状态改变监听器。
Ticker
当创建一个AnimationController
时,需要传递一个vsync
参数,它接收一个TickerProvider
类型的对象,它的主要职责是创建Ticker
,定义如下:
1 2 3 4
| abstract class TickerProvider { Ticker createTicker(TickerCallback onTick); }
|
Flutter应用在启动时都会绑定一个SchedulerBinding
,通过SchedulerBinding
可以给每一次屏幕刷新添加回调,而Ticker
就是通过SchedulerBinding
来添加屏幕刷新回调,这样一来,每次屏幕刷新都会调用TickerCallback
。使用Ticker
(而不是Timer
)来驱动动画会防止屏幕外动画(动画的UI不在当前屏幕时,如锁屏时)消耗不必要的资源,因为Flutter中屏幕刷新时会通知到绑定的SchedulerBinding
,而Ticker
是受SchedulerBinding
驱动的,由于锁屏后屏幕会停止刷新,所以Ticker
就不会再触发。
Tween
默认情况下,AnimationController
对象值的范围是[0.0,1.0]。如果我们需要构建UI的动画值在不同的范围或不同的数据类型,则可以使用Tween
来添加映射以生成不同的范围或数据类型的值。例如,像下面示例,Tween
生成[-200.0,0.0]的值:
1
| final Tween doubleTween = new Tween<double>(begin: -200.0, end: 0.0);
|
Tween
构造函数需要begin
和end
两个参数。Tween
的唯一职责就是定义从输入范围到输出范围的映射。输入范围通常为[0.0,1.0],但这不是必须的,我们可以自定义需要的范围。
Tween
继承自Animatable<T>
,而不是继承自Animation<T>
,Animatable
中主要定义动画值的映射规则。
下面我们看一个ColorTween将动画输入范围映射为两种颜色值之间过渡输出的例子:
1 2
| final Tween colorTween = new ColorTween(begin: Colors.transparent, end: Colors.black54);
|
Tween
对象不存储任何状态,相反,它提供了evaluate(Animation<double> animation)
方法,它可以获取动画当前映射值。 Animation
对象的当前值可以通过value()
方法取到。evaluate
函数还执行一些其它处理,例如分别确保在动画值为0.0和1.0时返回开始和结束状态。
Tween.animate
要使用Tween对象,需要调用其animate()
方法,然后传入一个控制器对象。例如,以下代码在500毫秒内生成从0到255的整数值。
1 2 3
| final AnimationController controller = new AnimationController( duration: const Duration(milliseconds: 500), vsync: this); Animation<int> alpha = new IntTween(begin: 0, end: 255).animate(controller);
|
注意animate()
返回的是一个Animation
,而不是一个Animatable
。
以下示例构建了一个控制器、一条曲线和一个Tween:
1 2 3 4 5
| final AnimationController controller = new AnimationController( duration: const Duration(milliseconds: 500), vsync: this); final Animation curve = new CurvedAnimation(parent: controller, curve: Curves.easeOut); Animation<int> alpha = new IntTween(begin: 0, end: 255).animate(curve);
|
使用例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| class ScaleAnimationRoute extends StatefulWidget { @override _ScaleAnimationRouteState createState() => new _ScaleAnimationRouteState(); }
class _ScaleAnimationRouteState extends State<ScaleAnimationRoute> with SingleTickerProviderStateMixin{
Animation<double> animation; AnimationController controller;
initState() { super.initState(); controller = new AnimationController( duration: const Duration(seconds: 3), vsync: this); animation = new Tween(begin: 0.0, end: 300.0).animate(controller) ..addListener(() { setState(()=>{}); }); controller.forward(); }
@override Widget build(BuildContext context) { return new Center( child: Image.asset("imgs/avatar.png", width: animation.value, height: animation.value ), ); }
dispose() { controller.dispose(); super.dispose(); } }
|
上面代码中addListener()
函数调用了setState()
,所以每次动画生成一个新的数字时,当前帧被标记为脏(dirty),这会导致widget的build()
方法再次被调用,而在build()
中,改变Image的宽高,因为它的高度和宽度现在使用的是animation.value
,所以就会逐渐放大。
指定Curve:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| initState() { super.initState(); controller = new AnimationController( duration: const Duration(seconds: 3), vsync: this); animation=CurvedAnimation(parent: controller, curve: Curves.bounceIn); animation = new Tween(begin: 0.0, end: 300.0).animate(animation) ..addListener(() { setState(() { }); }); controller.forward(); }
|
细心的读者可能已经发现上面示例中通过addListener()
和setState()
来更新UI这一步其实是通用的,如果每个动画中都加这么一句是比较繁琐的。AnimatedWidget
类封装了调用setState()
的细节,并允许我们将widget分离出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| class AnimatedImage extends AnimatedWidget { AnimatedImage({Key key, Animation<double> animation}) : super(key: key, listenable: animation);
Widget build(BuildContext context) { final Animation<double> animation = listenable; return new Center( child: Image.asset("imgs/avatar.png", width: animation.value, height: animation.value ), ); } }
class ScaleAnimationRoute1 extends StatefulWidget { @override _ScaleAnimationRouteState createState() => new _ScaleAnimationRouteState(); }
class _ScaleAnimationRouteState extends State<ScaleAnimationRoute1> with SingleTickerProviderStateMixin {
Animation<double> animation; AnimationController controller;
initState() { super.initState(); controller = new AnimationController( duration: const Duration(seconds: 3), vsync: this); animation = new Tween(begin: 0.0, end: 300.0).animate(controller); controller.forward(); }
@override Widget build(BuildContext context) { return AnimatedImage(animation: animation,); }
dispose() { controller.dispose(); super.dispose(); } }
|
用AnimatedBuilder重构
用AnimatedWidget可以从动画中分离出widget,而动画的渲染过程(即设置宽高)仍然在AnimatedWidget中,假设如果我们再添加一个widget透明度变化的动画,那么我们需要再实现一个AnimatedWidget,这样不是很优雅,如果我们能把渲染过程也抽象出来,那就会好很多,而AnimatedBuilder正是将渲染逻辑分离出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @override Widget build(BuildContext context) { return AnimatedBuilder( animation: animation, child: Image.asset("images/avatar.png"), builder: (BuildContext ctx, Widget child) { return new Center( child: Container( height: animation.value, width: animation.value, child: child, ), ); }, ); }
|
上面的代码中有一个迷惑的问题是,child
看起来像被指定了两次。但实际发生的事情是:将外部引用child
传递给AnimatedBuilder
后AnimatedBuilder
再将其传递给匿名构造器, 然后将该对象用作其子对象。最终的结果是AnimatedBuilder
返回的对象插入到widget树中。
也许你会说这和我们刚开始的示例差不了多少,其实它会带来三个好处:
- 不用显式的去添加帧监听器,然后再调用
setState()
了,这个好处和AnimatedWidget
是一样的。
- 动画构建的范围缩小了,如果没有
builder
,setState()
将会在父组件上下文中调用,这将会导致父组件的build
方法重新调用;而有了builder
之后,只会导致动画widget自身的build
重新调用,避免不必要的rebuild。
- 通过
AnimatedBuilder
可以封装常见的过渡效果来复用动画。下面我们通过封装一个GrowTransition
来说明,它可以对子widget实现放大动画
也许你会说这和我们刚开始的示例差不了多少,其实它会带来三个好处:
-
不用显式的去添加帧监听器,然后再调用setState()
了,这个好处和AnimatedWidget
是一样的。
-
动画构建的范围缩小了,如果没有builder
,setState()
将会在父组件上下文中调用,这将会导致父组件的build
方法重新调用;而有了builder
之后,只会导致动画widget自身的build
重新调用,避免不必要的rebuild。
-
通过AnimatedBuilder
可以封装常见的过渡效果来复用动画。下面我们通过封装一个GrowTransition
来说明,它可以对子widget实现放大动画:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class GrowTransition extends StatelessWidget { GrowTransition({this.child, this.animation});
final Widget child; final Animation<double> animation;
Widget build(BuildContext context) { return new Center( child: new AnimatedBuilder( animation: animation, builder: (BuildContext context, Widget child) { return new Container( height: animation.value, width: animation.value, child: child ); }, child: child ), ); } }
|
这样,最初的示例就可以改为:
1 2 3 4 5 6 7
| ... Widget build(BuildContext context) { return GrowTransition( child: Image.asset("images/avatar.png"), animation: animation, ); }
|
Flutter中正是通过这种方式封装了很多动画,如:FadeTransition、ScaleTransition、SizeTransition等,很多时候都可以复用这些预置的过渡类。
动画状态监听
可以通过Animation
的addStatusListener()
方法来添加动画状态改变监听器。Flutter中,有四种动画状态,在AnimationStatus
枚举类中定义,下面我们逐个说明:
枚举值 |
含义 |
dismissed |
动画在起始点停止 |
forward |
动画正在正向执行 |
reverse |
动画正在反向执行 |
completed |
动画在终点停止 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| initState() { super.initState(); controller = new AnimationController( duration: const Duration(seconds: 1), vsync: this); animation = new Tween(begin: 0.0, end: 300.0).animate(controller); animation.addStatusListener((status) { if (status == AnimationStatus.completed) { controller.reverse(); } else if (status == AnimationStatus.dismissed) { controller.forward(); } });
controller.forward(); }
|
路由切换动画
Material组件库中提供了一个MaterialPageRoute
组件,它可以使用和平台风格一致的路由切换动画,如在iOS上会左右滑动切换,而在Android上会上下滑动切换。现在,我们如果在Android上也想使用左右切换风格,该怎么做?一个简单的作法是可以直接使用CupertinoPageRoute
1 2 3
| Navigator.push(context, CupertinoPageRoute( builder: (context)=>PageB(), ));
|
CupertinoPageRoute是Cupertino组件库提供的iOS风格的路由切换组件,它实现的就是左右滑动切换。那么我们如何来自定义路由切换动画呢?答案就是
PageRouteBuilder。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| Navigator.push( context, PageRouteBuilder( transitionDuration: Duration(milliseconds: 500), pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { return new FadeTransition( opacity: animation, child: PageB(), ); }, ), );
|
,我们可以直接继承PageRoute
类来实现自定义路由,上面的例子可以通过如下方式实现:
-
定义一个路由类FadeRoute
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| class FadeRoute extends PageRoute { FadeRoute({ @required this.builder, this.transitionDuration = const Duration(milliseconds: 300), this.opaque = true, this.barrierDismissible = false, this.barrierColor, this.barrierLabel, this.maintainState = true, });
final WidgetBuilder builder;
@override final Duration transitionDuration;
@override final bool opaque;
@override final bool barrierDismissible;
@override final Color barrierColor;
@override final String barrierLabel;
@override final bool maintainState;
@override Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) => builder(context);
@override Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) { return FadeTransition( opacity: animation, child: builder(context), ); } }
|
-
使用FadeRoute
1 2 3
| Navigator.push(context, FadeRoute(builder: (context) { return PageB(); }));
|
虽然上面的两种方法都可以实现自定义切换动画,但实际使用时应优先考虑使用PageRouteBuilder,这样无需定义一个新的路由类,使用起来会比较方便。但是有些时候PageRouteBuilder
是不能满足需求的,例如在应用过渡动画时我们需要读取当前路由的一些属性,这时就只能通过继承PageRoute
的方式了,举个例子,假如我们只想在打开新路由时应用动画,而在返回时不使用动画,那么我们在构建过渡动画时就必须判断当前路由isActive
属性是否为true
,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @override Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) { if(isActive) { return FadeTransition( opacity: animation, child: builder(context), ); }else{ return Padding(padding: EdgeInsets.zero); } }
|
Hero动画
Hero指的是可以在路由(页面)之间“飞行”的widget,简单来说Hero动画就是在路由切换时,有一个共享的widget可以在新旧路由间切换。由于共享的widget在新旧路由页面上的位置、外观可能有所差异,所以在路由切换时会从旧路逐渐过渡到新路由中的指定位置,这样就会产生一个Hero动画。
示例
假设有两个路由A和B,他们的内容交互如下:
A:包含一个用户头像,圆形,点击后跳到B路由,可以查看大图。
B:显示用户头像原图,矩形;
在AB两个路由之间跳转的时候,用户头像会逐渐过渡到目标路由页的头像上,接下来我们先看看代码,然后再解析:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| class HeroAnimationRoute extends StatelessWidget { @override Widget build(BuildContext context) { return Container( alignment: Alignment.topCenter, child: InkWell( child: Hero( tag: "avatar", child: ClipOval( child: Image.asset("images/avatar.png", width: 50.0, ), ), ), onTap: () { Navigator.push(context, PageRouteBuilder( pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { return new FadeTransition( opacity: animation, child: Scaffold( appBar: AppBar( title: Text("原图"), ), body: HeroAnimationRouteB(), ), ); }) ); }, ), ); } }
|
路由B:
1 2 3 4 5 6 7 8 9 10 11
| class HeroAnimationRouteB extends StatelessWidget { @override Widget build(BuildContext context) { return Center( child: Hero( tag: "avatar", child: Image.asset("images/avatar.png"), ), ); } }
|
我们可以看到,实现Hero动画只需要用Hero
组件将要共享的widget包装起来,并提供一个相同的tag即可,中间的过渡帧都是Flutter Framework自动完成的。必须要注意, 前后路由页的共享Hero
的tag必须是相同的,Flutter Framework内部正是通过tag来确定新旧路由页widget的对应关系的。
交织动画
有些时候我们可能会需要一些复杂的动画,这些动画可能由一个动画序列或重叠的动画组成,比如:有一个柱状图,需要在高度增长的同时改变颜色,等到增长到最大高度后,我们需要在X轴上平移一段距离。可以发现上述场景在不同阶段包含了多种动画,要实现这种效果,使用交织动画(Stagger Animation)会非常简单。交织动画需要注意以下几点:
- 要创建交织动画,需要使用多个动画对象(
Animation
)。
- 一个
AnimationController
控制所有的动画对象。
- 给每一个动画对象指定时间间隔(Interval)
示例
下面我们看一个例子,实现一个柱状图增长的动画:
- 开始时高度从0增长到300像素,同时颜色由绿色渐变为红色;这个过程占据整个动画时间的60%。
- 高度增长到300后,开始沿X轴向右平移100像素;这个过程占用整个动画时间的40%。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| class StaggerAnimation extends StatelessWidget { StaggerAnimation({ Key key, this.controller }): super(key: key){ height = Tween<double>( begin:.0 , end: 300.0, ).animate( CurvedAnimation( parent: controller, curve: Interval( 0.0, 0.6, curve: Curves.ease, ), ), );
color = ColorTween( begin:Colors.green , end:Colors.red, ).animate( CurvedAnimation( parent: controller, curve: Interval( 0.0, 0.6, curve: Curves.ease, ), ), );
padding = Tween<EdgeInsets>( begin:EdgeInsets.only(left: .0), end:EdgeInsets.only(left: 100.0), ).animate( CurvedAnimation( parent: controller, curve: Interval( 0.6, 1.0, curve: Curves.ease, ), ), ); }
final Animation<double> controller; Animation<double> height; Animation<EdgeInsets> padding; Animation<Color> color;
Widget _buildAnimation(BuildContext context, Widget child) { return Container( alignment: Alignment.bottomCenter, padding:padding.value , child: Container( color: color.value, width: 50.0, height: height.value, ), ); }
@override Widget build(BuildContext context) { return AnimatedBuilder( builder: _buildAnimation, animation: controller, ); } }
|
StaggerAnimation
中定义了三个动画,分别是对Container
的height
、color
、padding
属性设置的动画,然后通过Interval
来为每个动画指定在整个动画过程中的起始点和终点。下面我们来实现启动动画的路由:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| class StaggerRoute extends StatefulWidget { @override _StaggerRouteState createState() => _StaggerRouteState(); }
class _StaggerRouteState extends State<StaggerRoute> with TickerProviderStateMixin { AnimationController _controller;
@override void initState() { super.initState();
_controller = AnimationController( duration: const Duration(milliseconds: 2000), vsync: this ); }
Future<Null> _playAnimation() async { try { await _controller.forward().orCancel; await _controller.reverse().orCancel; } on TickerCanceled { } }
@override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { _playAnimation(); }, child: Center( child: Container( width: 300.0, height: 300.0, decoration: BoxDecoration( color: Colors.black.withOpacity(0.1), border: Border.all( color: Colors.black.withOpacity(0.5), ), ), child: StaggerAnimation( controller: _controller ), ), ), ); } }
|
AnimatedSwitcher
Flutter SDK中提供了一个AnimatedSwitcher
组件,它定义了一种通用的UI切换抽象。
AnimatedSwitcher
可以同时对其新、旧子元素添加显示、隐藏动画。也就是说在AnimatedSwitcher
的子元素发生变化时,会对其旧元素和新元素,我们先看看AnimatedSwitcher
的定义:
1 2 3 4 5 6 7 8 9 10
| const AnimatedSwitcher({ Key key, this.child, @required this.duration, this.reverseDuration, this.switchInCurve = Curves.linear, this.switchOutCurve = Curves.linear, this.transitionBuilder = AnimatedSwitcher.defaultTransitionBuilder, this.layoutBuilder = AnimatedSwitcher.defaultLayoutBuilder, })
|
当AnimatedSwitcher
的child发生变化时(类型或Key不同),旧child会执行隐藏动画,新child会执行执行显示动画。究竟执行何种动画效果则由transitionBuilder
参数决定,该参数接受一个AnimatedSwitcherTransitionBuilder
类型的builder,定义如下:
1 2
| typedef AnimatedSwitcherTransitionBuilder = Widget Function(Widget child, Animation<double> animation);
|
该builder
在AnimatedSwitcher
的child切换时会分别对新、旧child绑定动画:
- 对旧child,绑定的动画会反向执行(reverse)
- 对新child,绑定的动画会正向指向(forward)
这样一下,便实现了对新、旧child的动画绑定。AnimatedSwitcher
的默认值是AnimatedSwitcher.defaultTransitionBuilder
:
1 2 3 4 5 6
| Widget defaultTransitionBuilder(Widget child, Animation<double> animation) { return FadeTransition( opacity: animation, child: child, ); }
|
可以看到,返回了FadeTransition
对象,也就是说默认情况,AnimatedSwitcher
会对新旧child执行“渐隐”和“渐显”动画。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| import 'package:flutter/material.dart';
class AnimatedSwitcherCounterRoute extends StatefulWidget { const AnimatedSwitcherCounterRoute({Key key}) : super(key: key);
@override _AnimatedSwitcherCounterRouteState createState() => _AnimatedSwitcherCounterRouteState(); }
class _AnimatedSwitcherCounterRouteState extends State<AnimatedSwitcherCounterRoute> { int _count = 0;
@override Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ AnimatedSwitcher( duration: const Duration(milliseconds: 500), transitionBuilder: (Widget child, Animation<double> animation) { return ScaleTransition(child: child, scale: animation); }, child: Text( '$_count', key: ValueKey<int>(_count), style: Theme.of(context).textTheme.headline4, ), ), RaisedButton( child: const Text('+1',), onPressed: () { setState(() { _count += 1; }); }, ), ], ), ); } }
|
AnimatedSwitcher实现原理
实际上,AnimatedSwitcher
的实现原理是比较简单的,我们根据AnimatedSwitcher
的使用方式也可以猜个大概。要想实现新旧child切换动画,只需要明确两个问题:动画执行的时机是和如何对新旧child执行动画。从AnimatedSwitcher
的使用方式我们可以看到,当child发生变化时(子widget的key和类型不同时相等则认为发生变化),则重新会重新执行build
,然后动画开始执行。我们可以通过继承StatefulWidget来实现AnimatedSwitcher
,具体做法是在didUpdateWidget
回调中判断其新旧child是否发生变化,如果发生变化,则对旧child执行反向退场(reverse)动画,对新child执行正向(forward)入场动画即可。
Flutter SDK中还提供了一个AnimatedCrossFade
组件,它也可以切换两个子元素,切换过程执行渐隐渐显的动画,和AnimatedSwitcher
不同的是AnimatedCrossFade
是针对两个子元素,而AnimatedSwitcher
是在一个子元素的新旧值之间切换。AnimatedCrossFade
实现原理比较简单,也有和AnimatedSwitcher
类似的地方。
AnimatedSwitcher高级用法
AnimatedSwitcher两个view的动画相同,方向相反的, 如果要打破这个的话需要怎么做呢?实例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| class MySlideTransition extends AnimatedWidget { MySlideTransition({ Key key, @required Animation<Offset> position, this.transformHitTests = true, this.child, }) : assert(position != null), super(key: key, listenable: position) ;
Animation<Offset> get position => listenable; final bool transformHitTests; final Widget child;
@override Widget build(BuildContext context) { Offset offset=position.value; if (position.status == AnimationStatus.reverse) { offset = Offset(-offset.dx, offset.dy); } return FractionalTranslation( translation: offset, transformHitTests: transformHitTests, child: child, ); } }
|
调用时,将SlideTransition
替换成MySlideTransition
即可:
1 2 3 4 5 6 7 8 9 10 11
| AnimatedSwitcher( duration: Duration(milliseconds: 200), transitionBuilder: (Widget child, Animation<double> animation) { var tween=Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0)) return MySlideTransition( child: child, position: tween.animate(animation), ); }, ... )
|
SlideTransitionX
一个通用的SlideTransitionX
来实现这种“出入滑动动画”
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| class SlideTransitionX extends AnimatedWidget { SlideTransitionX({ Key key, @required Animation<double> position, this.transformHitTests = true, this.direction = AxisDirection.down, this.child, }) : assert(position != null), super(key: key, listenable: position) { switch (direction) { case AxisDirection.up: _tween = Tween(begin: Offset(0, 1), end: Offset(0, 0)); break; case AxisDirection.right: _tween = Tween(begin: Offset(-1, 0), end: Offset(0, 0)); break; case AxisDirection.down: _tween = Tween(begin: Offset(0, -1), end: Offset(0, 0)); break; case AxisDirection.left: _tween = Tween(begin: Offset(1, 0), end: Offset(0, 0)); break; } }
Animation<double> get position => listenable;
final bool transformHitTests;
final Widget child;
final AxisDirection direction;
Tween<Offset> _tween;
@override Widget build(BuildContext context) { Offset offset = _tween.evaluate(position); if (position.status == AnimationStatus.reverse) { switch (direction) { case AxisDirection.up: offset = Offset(offset.dx, -offset.dy); break; case AxisDirection.right: offset = Offset(-offset.dx, offset.dy); break; case AxisDirection.down: offset = Offset(offset.dx, -offset.dy); break; case AxisDirection.left: offset = Offset(-offset.dx, offset.dy); break; } } return FractionalTranslation( translation: offset, transformHitTests: transformHitTests, child: child, ); } }
|
现在如果我们想实现各种“滑动出入动画”便非常容易,只需给direction
传递不同的方向值即可,比如要实现“上入下出”,则:
1 2 3 4 5 6 7 8 9 10 11 12
| AnimatedSwitcher( duration: Duration(milliseconds: 200), transitionBuilder: (Widget child, Animation<double> animation) { var tween=Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0)) return SlideTransitionX( child: child, direction: AxisDirection.down, position: animation, ); }, ... )
|
动画过渡组件
将在Widget属性发生变化时会执行过渡动画的组件统称为”动画过渡组件“,而动画过渡组件最明显的一个特征就是它会在内部自管理AnimationController
。
自定义动画过渡组件
我们要实现一个AnimatedDecoratedBox
,它可以在decoration
属性发生变化时,从旧状态变成新状态的过程可以执行一个过渡动画。根据前面所学的知识,我们实现了一个AnimatedDecoratedBox1
组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88
| class AnimatedDecoratedBox1 extends StatefulWidget { AnimatedDecoratedBox1({ Key key, @required this.decoration, this.child, this.curve = Curves.linear, @required this.duration, this.reverseDuration, });
final BoxDecoration decoration; final Widget child; final Duration duration; final Curve curve; final Duration reverseDuration;
@override _AnimatedDecoratedBox1State createState() => _AnimatedDecoratedBox1State(); }
class _AnimatedDecoratedBox1State extends State<AnimatedDecoratedBox1> with SingleTickerProviderStateMixin { @protected AnimationController get controller => _controller; AnimationController _controller;
Animation<double> get animation => _animation; Animation<double> _animation;
DecorationTween _tween;
@override Widget build(BuildContext context) { return AnimatedBuilder( animation: _animation, builder: (context, child){ return DecoratedBox( decoration: _tween.animate(_animation).value, child: child, ); }, child: widget.child, ); }
@override void initState() { super.initState(); _controller = AnimationController( duration: widget.duration, reverseDuration: widget.reverseDuration, vsync: this, ); _tween = DecorationTween(begin: widget.decoration); _updateCurve(); }
void _updateCurve() { if (widget.curve != null) _animation = CurvedAnimation(parent: _controller, curve: widget.curve); else _animation = _controller; }
@override void didUpdateWidget(AnimatedDecoratedBox1 oldWidget) { super.didUpdateWidget(oldWidget); if (widget.curve != oldWidget.curve) _updateCurve(); _controller.duration = widget.duration; _controller.reverseDuration = widget.reverseDuration; if(widget.decoration!= (_tween.end ?? _tween.begin)){ _tween ..begin = _tween.evaluate(_animation) ..end = widget.decoration; _controller ..value = 0.0 ..forward(); } }
@override void dispose() { _controller.dispose(); super.dispose(); } }
|
下面我们来使用AnimatedDecoratedBox1
来实现按钮点击后背景色从蓝色过渡到红色的效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| Color _decorationColor = Colors.blue; var duration = Duration(seconds: 1); ... AnimatedDecoratedBox( duration: duration, decoration: BoxDecoration(color: _decorationColor), child: FlatButton( onPressed: () { setState(() { _decorationColor = Colors.red; }); }, child: Text( "AnimatedDecoratedBox", style: TextStyle(color: Colors.white), ), ), )
|
点击后,按钮背景色会从蓝色向红色过渡,图9-9是过渡过程中的一帧,有点偏紫色,整个过渡动画结束后背景会变为红色。
上面的代码虽然实现了我们期望的功能,但是代码却比较复杂。稍加思考后,我们就可以发现,AnimationController
的管理以及Tween更新部分的代码都是可以抽象出来的,如果我们这些通用逻辑封装成基类,那么要实现动画过渡组件只需要继承这些基类,然后定制自身不同的代码(比如动画每一帧的构建方法)即可,这样将会简化代码。
为了方便开发者来实现动画过渡组件的封装,Flutter提供了一个ImplicitlyAnimatedWidget
抽象类,它继承自StatefulWidget,同时提供了一个对应的ImplicitlyAnimatedWidgetState
类,AnimationController
的管理就在ImplicitlyAnimatedWidgetState
类中。开发者如果要封装动画,只需要分别继承ImplicitlyAnimatedWidget
和ImplicitlyAnimatedWidgetState
类即可,下面我们演示一下具体如何实现。
我们需要分两步实现:
-
继承ImplicitlyAnimatedWidget
类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class AnimatedDecoratedBox extends ImplicitlyAnimatedWidget { AnimatedDecoratedBox({ Key key, @required this.decoration, this.child, Curve curve = Curves.linear, @required Duration duration, Duration reverseDuration, }) : super( key: key, curve: curve, duration: duration, reverseDuration: reverseDuration, ); final BoxDecoration decoration; final Widget child;
@override _AnimatedDecoratedBoxState createState() { return _AnimatedDecoratedBoxState(); } }
|
其中curve
、duration
、reverseDuration
三个属性在ImplicitlyAnimatedWidget
中已定义。 可以看到AnimatedDecoratedBox
类和普通继承自StatefulWidget
的类没有什么不同。
-
State类继承自AnimatedWidgetBaseState
(该类继承自ImplicitlyAnimatedWidgetState
类)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class _AnimatedDecoratedBoxState extends AnimatedWidgetBaseState<AnimatedDecoratedBox> { DecorationTween _decoration;
@override Widget build(BuildContext context) { return DecoratedBox( decoration: _decoration.evaluate(animation), child: widget.child, ); }
@override void forEachTween(visitor) { _decoration = visitor(_decoration, widget.decoration, (value) => DecorationTween(begin: value)); } }
|
可以看到我们实现了build
和forEachTween
两个方法。在动画执行过程中,每一帧都会调用build
方法(调用逻辑在ImplicitlyAnimatedWidgetState
中),所以在build
方法中我们需要构建每一帧的DecoratedBox
状态,因此得算出每一帧的decoration
状态,这个我们可以通过_decoration.evaluate(animation)
来算出,其中animation
是ImplicitlyAnimatedWidgetState
基类中定义的对象,_decoration
是我们自定义的一个DecorationTween
类型的对象,那么现在的问题就是它是在什么时候被赋值的呢?要回答这个问题,我们就得搞清楚什么时候需要对_decoration
赋值。我们知道_decoration
是一个Tween,而Tween的主要职责就是定义动画的起始状态(begin)和终止状态(end)。对于AnimatedDecoratedBox
来说,decoration
的终止状态就是用户传给它的值,而起始状态是不确定的,有以下两种情况:
AnimatedDecoratedBox
首次build,此时直接将其decoration
值置为起始状态,即_decoration
值为DecorationTween(begin: decoration)
。
AnimatedDecoratedBox
的decoration
更新时,则起始状态为_decoration.animate(animation)
,即_decoration
值为DecorationTween(begin: _decoration.animate(animation),end:decoration)
。
现在forEachTween
的作用就很明显了,它正是用于来更新Tween的初始值的,在上述两种情况下会被调用,而开发者只需重写此方法,并在此方法中更新Tween的起始状态值即可。而一些更新的逻辑被屏蔽在了visitor
回调,我们只需要调用它并给它传递正确的参数即可,visitor
方法签名如下:
1 2 3 4 5
| Tween visitor( Tween<dynamic> tween, dynamic targetValue, TweenConstructor<dynamic> constructor, );
|
可以看到,通过继承ImplicitlyAnimatedWidget
和ImplicitlyAnimatedWidgetState
类可以快速的实现动画过渡组件的封装,这和我们纯手工实现相比,代码简化了很多。
动画过渡组件的反向动画
在使用动画过渡组件,我们只需要在改变一些属性值后重新build组件即可,所以要实现状态反向过渡,只需要将前后状态值互换即可实现,这本来是不需要再浪费笔墨的。但是ImplicitlyAnimatedWidget
构造函数中却有一个reverseDuration
属性用于设置反向动画的执行时长,这貌似在告诉读者ImplicitlyAnimatedWidget
本身也提供了执行反向动画的接口,于是笔者查看了ImplicitlyAnimatedWidgetState
源码并未发现有执行反向动画的接口,唯一有用的是它暴露了控制动画的controller
。所以如果要让reverseDuration
生效,我们只能先获取controller
,然后再通过controller.reverse()
来启动反向动画,比如我们在上面示例的基础上实现一个循环的点击背景颜色变换效果,要求从蓝色变为红色时动画执行时间为400ms,从红变蓝为2s,如果要使reverseDuration
生效,我们需要这么做:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| AnimatedDecoratedBox( duration: Duration( milliseconds: 400), decoration: BoxDecoration(color: _decorationColor), reverseDuration: Duration(seconds: 2), child: Builder(builder: (context) { return FlatButton( onPressed: () { if (_decorationColor == Colors.red) { ImplicitlyAnimatedWidgetState _state = context.findAncestorStateOfType<ImplicitlyAnimatedWidgetState>(); _state.controller.reverse().then((e) { setState(() { _decorationColor = Colors.blue; }); }); } else { setState(() { _decorationColor = Colors.red; }); } }, child: Text( "AnimatedDecoratedBox toggle", style: TextStyle(color: Colors.white), ), ); }), )
|
上面的代码实际上是非常糟糕且没必要的,它需要我们了解ImplicitlyAnimatedWidgetState
内部实现,并且要手动去启动反向动画。我们完全可以通过如下代码实现相同的效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| AnimatedDecoratedBox( duration: Duration( milliseconds: _decorationColor == Colors.red ? 400 : 2000), decoration: BoxDecoration(color: _decorationColor), child: Builder(builder: (context) { return FlatButton( onPressed: () { setState(() { _decorationColor = _decorationColor == Colors.blue ? Colors.red : Colors.blue; }); }, child: Text( "AnimatedDecoratedBox toggle", style: TextStyle(color: Colors.white), ), ); }), )
|
这样的代码是不是优雅的多!那么现在问题来了,为什么ImplicitlyAnimatedWidgetState
要提供一个reverseDuration
参数呢?笔者仔细研究了ImplicitlyAnimatedWidgetState
的实现,发现唯一的解释就是该参数并非是给ImplicitlyAnimatedWidgetState
用的,而是给子类用的!原因正如我们前面说的,要使reverseDuration
有用就必须得获取controller
属性来手动启动反向动画,ImplicitlyAnimatedWidgetState
中的controller
属性是一个保护属性,定义如下:
1 2
| @protected AnimationController get controller => _controller;
|
而保护属性原则上只应该在子类中使用,而不应该像上面示例代码一样在外部使用。综上,我们可以得出两条结论:
-
使用动画过渡组件时如果需要执行反向动画的场景,应尽量使用状态互换的方法,而不应该通过获取ImplicitlyAnimatedWidgetState
中controller
的方式。
-
如果我们自定义的动画过渡组件用不到reverseDuration
,那么最好就不要暴露此参数,比如我们上面自定义的AnimatedDecoratedBox
定义中就可以去除reverseDuration
可选参数,如:
1 2 3 4 5 6 7 8 9 10 11 12
| class AnimatedDecoratedBox extends ImplicitlyAnimatedWidget { AnimatedDecoratedBox({ Key key, @required this.decoration, this.child, Curve curve = Curves.linear, @required Duration duration, }) : super( key: key, curve: curve, duration: duration, );
|
Flutter预置的动画过渡组件
Flutter SDK中也预置了很多动画过渡组件,实现方式和大都和AnimatedDecoratedBox
差不多,如表9-1所示:
组件名 |
功能 |
AnimatedPadding |
在padding发生变化时会执行过渡动画到新状态 |
AnimatedPositioned |
配合Stack一起使用,当定位状态发生变化时会执行过渡动画到新的状态。 |
AnimatedOpacity |
在透明度opacity发生变化时执行过渡动画到新状态 |
AnimatedAlign |
当alignment 发生变化时会执行过渡动画到新的状态。 |
AnimatedContainer |
当Container属性发生变化时会执行过渡动画到新的状态。 |
AnimatedDefaultTextStyle |
当字体样式发生变化时,子组件中继承了该样式的文本组件会动态过渡到新样式。 |
下面我们通过一个示例来感受一下这些预置的动画过渡组件效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127
| import 'package:flutter/material.dart';
class AnimatedWidgetsTest extends StatefulWidget { @override _AnimatedWidgetsTestState createState() => _AnimatedWidgetsTestState(); }
class _AnimatedWidgetsTestState extends State<AnimatedWidgetsTest> { double _padding = 10; var _align = Alignment.topRight; double _height = 100; double _left = 0; Color _color = Colors.red; TextStyle _style = TextStyle(color: Colors.black); Color _decorationColor = Colors.blue;
@override Widget build(BuildContext context) { var duration = Duration(seconds: 5); return SingleChildScrollView( child: Column( children: <Widget>[ RaisedButton( onPressed: () { setState(() { _padding = 20; }); }, child: AnimatedPadding( duration: duration, padding: EdgeInsets.all(_padding), child: Text("AnimatedPadding"), ), ), SizedBox( height: 50, child: Stack( children: <Widget>[ AnimatedPositioned( duration: duration, left: _left, child: RaisedButton( onPressed: () { setState(() { _left = 100; }); }, child: Text("AnimatedPositioned"), ), ) ], ), ), Container( height: 100, color: Colors.grey, child: AnimatedAlign( duration: duration, alignment: _align, child: RaisedButton( onPressed: () { setState(() { _align = Alignment.center; }); }, child: Text("AnimatedAlign"), ), ), ), AnimatedContainer( duration: duration, height: _height, color: _color, child: FlatButton( onPressed: () { setState(() { _height = 150; _color = Colors.blue; }); }, child: Text( "AnimatedContainer", style: TextStyle(color: Colors.white), ), ), ), AnimatedDefaultTextStyle( child: GestureDetector( child: Text("hello world"), onTap: () { setState(() { _style = TextStyle( color: Colors.blue, decorationStyle: TextDecorationStyle.solid, decorationColor: Colors.blue, ); }); }, ), style: _style, duration: duration, ), AnimatedDecoratedBox( duration: duration, decoration: BoxDecoration(color: _decorationColor), child: FlatButton( onPressed: () { setState(() { _decorationColor = Colors.red; }); }, child: Text( "AnimatedDecoratedBox", style: TextStyle(color: Colors.white), ), ), ) ].map((e) { return Padding( padding: EdgeInsets.symmetric(vertical: 16), child: e, ); }).toList(), ), ); } }
|