Flutter入门:功能组件和动画

Posted by alonealice on 2021-01-05

功能型组件

导航返回拦截

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)) {
//两次点击间隔超过1秒则重新计时
_lastPressedAt = DateTime.now();
return false;
}
return true;
},
child: Container(
alignment: Alignment.center,
child: Text("1秒内连续按两次返回键退出"),
)
);
}

数据共享(InheritedWidget)

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; //需要在子树中共享的数据,保存点击次数

//定义一个便捷方法,方便子树中的widget获取共享数据
static ShareDataWidget of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ShareDataWidget>();
}

//该回调决定当data发生变化时,是否通知子树中依赖data的Widget
@override
bool updateShouldNotify(ShareDataWidget old) {
//如果返回true,则子树中依赖(build函数中有调用)本widget
//的子widget的`state.didChangeDependencies`会被调用
return old.data != data;
}
}

一般来说,子widget很少会重写此方法,因为在依赖改变后framework也都会调用build()方法。但是,如果你需要在依赖改变后执行一些昂贵的操作,比如网络请求,这时最好的方式就是在此方法中执行,这样可以避免每次build()都执行这些昂贵操作。

跨组件状态共享(Provider)

组件私有的状态管理方式就比较多,如使用全局事件总线EventBus,还有就是InheritedWidget。Flutter社区著名的Provider包正是基于这个思想实现的一套跨组件状态共享解决方案。

现在Flutter社区已经有很多专门用于状态管理的包了,在此我们列出几个相对评分比较高的:

包名 介绍
Provider & Scoped Model 这两个包都是基于InheritedWidget的,原理相似
Redux 是Web开发中React生态链中Redux包的Flutter实现
MobX 是Web开发中React生态链中MobX包的Flutter实现
BLoC 是BLoC模式的Flutter实现

颜色和主题

颜色

1
2
3
4
5
Color(0xffdc380d); //如果颜色固定可以直接使用整数值
//颜色是一个字符串变量
var c = "dc380d";
Color(int.parse(c,radix:16)|0xFF000000) //通过位运算符将Alpha设置为FF
Color(int.parse(c,radix:16)).withAlpha(255) //通过方法将Alpha设置为FF

颜色亮度: 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, //次级色,决定大多数Widget的颜色,如进度条、开关等。
Color cardColor, //卡片颜色
Color dividerColor, //分割线颜色
ButtonThemeData buttonTheme, //按钮主题
Color cursorColor, //输入框光标颜色
Color dialogBackgroundColor,//对话框背景颜色
String fontFamily, //文字字体
TextTheme textTheme,// 字体主题,包括标题、body等文字样式
IconThemeData iconTheme, // Icon的默认样式
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, //用于导航栏、FloatingActionButton的背景色等
iconTheme: IconThemeData(color: _themeColor) //用于Icon颜色
),
child: Scaffold(
appBar: AppBar(title: Text("主题测试")),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
//第一行Icon使用主题中的iconTheme
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.favorite),
Icon(Icons.airport_shuttle),
Text(" 颜色跟随主题")
]
),
//为第二行Icon自定义颜色(固定为黑色)
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

  • 本示例是对单个路由换肤,如果想要对整个应用换肤,则可以去修改MaterialApptheme属性。

异步UI更新

很多时候我们会依赖一些异步数据来动态更新UI,因此Flutter专门提供了FutureBuilderStreamBuilder两个组件来快速实现这种功能。

FutureBuilder

FutureBuilder构造函数:

1
2
3
4
5
FutureBuilder({
this.future,
this.initialData,
@required this.builder,
})
  • futureFutureBuilder依赖的Future,通常是一个异步耗时任务。

  • initialData:初始数据,用户设置默认数据。

  • builder:Widget构建器;该构建器会在Future执行的不同阶段被多次调用,构建器签名如下:

    1
    Function (BuildContext context, AsyncSnapshot snapshot)

    snapshot会包含当前异步任务的状态信息及结果信息 ,比如我们可以通过snapshot.connectionState获取异步任务的状态信息、通过snapshot.hasError判断异步任务是否有错误等等,完整的定义读者可以查看AsyncSnapshot类定义。

    另外,FutureBuilderbuilder函数签名和StreamBuilderbuilder是相同的。

举例:

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 {
// 请求未结束,显示loading
return CircularProgressIndicator();
}
},
),
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
enum ConnectionState {
/// 当前没有异步任务,比如[FutureBuilder]的[future]为null时
none,

/// 异步任务处于等待状态
waiting,

/// Stream处于激活状态(流上已经有数据传递了),对于FutureBuilder没有该状态。
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(), //
//initialData: ,// a Stream<int> or null
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; // unreachable
},
);
}

对话框

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, //点击对话框barrier(遮罩)时是否关闭它
WidgetBuilder builder, // 对话框UI的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: () {
//关闭对话框并返回true
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: () {
// 返回1
Navigator.pop(context, 1);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: const Text('中文简体'),
),
),
SimpleDialogOption(
onPressed: () {
// 返回2
Navigator.pop(context, 2);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: const Text('美国英语'),
),
),
],
);
});

if (i != null) {
print("选择了:${i == 1 ? "中文简体" : "美国英语"}");
}
}

Dialog

AlertDialogSimpleDialog都使用了Dialog类。由于AlertDialogSimpleDialog中使用了IntrinsicWidth来尝试通过子组件的实际尺寸来调整自身尺寸,这就导致他们的子组件不能是延迟加载模型的组件(如ListViewGridViewCustomScrollView等。

如果需要嵌套一个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),
);
},
)),
],
);
//使用AlertDialog会报错
//return AlertDialog(content: child);
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, //构建对话框内部UI
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);
},
),
],
);
},
);

对话框实现原理

接调用Navigatorpush方法打开了一个新的对话框路由_DialogRoute,然后返回了push的返回值。可见对话框实际上正是通过路由的形式实现的,这也是为什么我们可以使用Navigatorpop 方法来退出对话框的原因。

对话框状态管理

我们知道在操作对话框里的组件来setState方法只会针对当前context的子树重新build,但是我们的对话框并不是在_DialogRouteStatebuild 方法中构建的,而是通过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来构建StatefulWidget上下文
StatefulBuilder(
builder: (context, _setState) {
return Checkbox(
value: _withTree, //默认不选中
onChanged: (bool value) {
//_setState方法实际就是该StatefulWidget的setState方法,
//调用后builder方法会重新被调用
_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来获得构建Checkbox的`context`,
// 这是一种常用的缩小`context`范围的方式
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
// 返回的是一个controller
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: (){
// do something
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( //未来30天可选
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有如下两个方法:

  1. addListener();它可以用于给Animation添加帧监听器,在每一帧都会被调用。帧监听器中最常见的行为是改变状态后调用setState()来触发UI重建。
  2. addStatusListener();它可以给Animation添加“动画状态改变”监听器;动画开始、结束、正向或反向(见AnimationStatus定义)时会调用状态改变的监听器。

Curve

动画过程可以是匀速的、匀加速的或者先加速后减速等。Flutter中通过Curve(曲线)来描述动画过程,我们把匀速动画称为线性的(Curves.linear),而非匀速动画称为非线性的。

我们可以通过CurvedAnimation来指定动画的曲线,如:

1
2
final CurvedAnimation curve =
new CurvedAnimation(parent: controller, curve: Curves.easeIn);

urvedAnimationAnimationController(下面介绍)都是Animation<double>类型。CurvedAnimation可以通过包装AnimationControllerCurve生成一个新的动画对象 ,我们正是通过这种方式来将动画和动画执行的曲线关联起来的。我们指定动画的曲线为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生成数字的区间可以通过lowerBoundupperBound来指定,如:

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
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构造函数需要beginend两个参数。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();
}

//需要继承TickerProvider,如果有多个AnimationController,则应该使用TickerProviderStateMixin。
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);
//图片宽高从0变到300
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);
//图片宽高从0变到300
animation = new Tween(begin: 0.0, end: 300.0).animate(animation)
..addListener(() {
setState(() {
});
});
//启动动画
controller.forward();
}

使用AnimatedWidget简化

细心的读者可能已经发现上面示例中通过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);
//图片宽高从0变到300
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 AnimatedImage(animation: animation,);
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传递给AnimatedBuilderAnimatedBuilder再将其传递给匿名构造器, 然后将该对象用作其子对象。最终的结果是AnimatedBuilder返回的对象插入到widget树中。

也许你会说这和我们刚开始的示例差不了多少,其实它会带来三个好处:

  1. 不用显式的去添加帧监听器,然后再调用setState() 了,这个好处和AnimatedWidget是一样的。
  2. 动画构建的范围缩小了,如果没有buildersetState()将会在父组件上下文中调用,这将会导致父组件的build方法重新调用;而有了builder之后,只会导致动画widget自身的build重新调用,避免不必要的rebuild。
  3. 通过AnimatedBuilder可以封装常见的过渡效果来复用动画。下面我们通过封装一个GrowTransition来说明,它可以对子widget实现放大动画

也许你会说这和我们刚开始的示例差不了多少,其实它会带来三个好处:

  1. 不用显式的去添加帧监听器,然后再调用setState() 了,这个好处和AnimatedWidget是一样的。

  2. 动画构建的范围缩小了,如果没有buildersetState()将会在父组件上下文中调用,这将会导致父组件的build方法重新调用;而有了builder之后,只会导致动画widget自身的build重新调用,避免不必要的rebuild。

  3. 通过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等,很多时候都可以复用这些预置的过渡类。

动画状态监听

可以通过AnimationaddStatusListener()方法来添加动画状态改变监听器。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);
//图片宽高从0变到300
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), //动画时间为500毫秒
pageBuilder: (BuildContext context, Animation animation,
Animation secondaryAnimation) {
return new FadeTransition(
//使用渐隐渐入过渡,
opacity: animation,
child: PageB(), //路由B
);
},
),
);

,我们可以直接继承PageRoute类来实现自定义路由,上面的例子可以通过如下方式实现:

  1. 定义一个路由类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),
    );
    }
    }
  2. 使用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
// 路由A
class HeroAnimationRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.topCenter,
child: InkWell(
child: Hero(
tag: "avatar", //唯一标记,前后两个路由页Hero的tag必须相同
child: ClipOval(
child: Image.asset("images/avatar.png",
width: 50.0,
),
),
),
onTap: () {
//打开B路由
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", //唯一标记,前后两个路由页Hero的tag必须相同
child: Image.asset("images/avatar.png"),
),
);
}
}

我们可以看到,实现Hero动画只需要用Hero组件将要共享的widget包装起来,并提供一个相同的tag即可,中间的过渡帧都是Flutter Framework自动完成的。必须要注意, 前后路由页的共享Hero的tag必须是相同的,Flutter Framework内部正是通过tag来确定新旧路由页widget的对应关系的。

交织动画

有些时候我们可能会需要一些复杂的动画,这些动画可能由一个动画序列或重叠的动画组成,比如:有一个柱状图,需要在高度增长的同时改变颜色,等到增长到最大高度后,我们需要在X轴上平移一段距离。可以发现上述场景在不同阶段包含了多种动画,要实现这种效果,使用交织动画(Stagger Animation)会非常简单。交织动画需要注意以下几点:

  1. 要创建交织动画,需要使用多个动画对象(Animation)。
  2. 一个AnimationController控制所有的动画对象。
  3. 给每一个动画对象指定时间间隔(Interval)

示例

下面我们看一个例子,实现一个柱状图增长的动画:

  1. 开始时高度从0增长到300像素,同时颜色由绿色渐变为红色;这个过程占据整个动画时间的60%。
  2. 高度增长到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, //间隔,前60%的动画时间
curve: Curves.ease,
),
),
);

color = ColorTween(
begin:Colors.green ,
end:Colors.red,
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.0, 0.6,//间隔,前60%的动画时间
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, //间隔,后40%的动画时间
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中定义了三个动画,分别是对Containerheightcolorpadding属性设置的动画,然后通过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 {
// the animation got canceled, probably because we were disposed
}
}

@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),
),
),
//调用我们定义的交织动画Widget
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, // 新child显示动画时长
this.reverseDuration,// 旧child隐藏的动画时长
this.switchInCurve = Curves.linear, // 新child显示的动画曲线
this.switchOutCurve = Curves.linear,// 旧child隐藏的动画曲线
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);

builderAnimatedSwitcher的child切换时会分别对新、旧child绑定动画:

  1. 对旧child,绑定的动画会反向执行(reverse)
  2. 对新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,不同的key会被认为是不同的Text,这样才能执行动画
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;
//动画反向执行时,调整x偏移,实现“从左边滑出隐藏”
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类中。开发者如果要封装动画,只需要分别继承ImplicitlyAnimatedWidgetImplicitlyAnimatedWidgetState类即可,下面我们演示一下具体如何实现。

我们需要分两步实现:

  1. 继承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();
    }
    }

    其中curvedurationreverseDuration三个属性在ImplicitlyAnimatedWidget中已定义。 可以看到AnimatedDecoratedBox类和普通继承自StatefulWidget的类没有什么不同。

  2. 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; //定义一个Tween

    @override
    Widget build(BuildContext context) {
    return DecoratedBox(
    decoration: _decoration.evaluate(animation),
    child: widget.child,
    );
    }

    @override
    void forEachTween(visitor) {
    // 在需要更新Tween时,基类会调用此方法
    _decoration = visitor(_decoration, widget.decoration,
    (value) => DecorationTween(begin: value));
    }
    }

    可以看到我们实现了buildforEachTween两个方法。在动画执行过程中,每一帧都会调用build方法(调用逻辑在ImplicitlyAnimatedWidgetState中),所以在build方法中我们需要构建每一帧的DecoratedBox状态,因此得算出每一帧的decoration 状态,这个我们可以通过_decoration.evaluate(animation) 来算出,其中animationImplicitlyAnimatedWidgetState基类中定义的对象,_decoration是我们自定义的一个DecorationTween类型的对象,那么现在的问题就是它是在什么时候被赋值的呢?要回答这个问题,我们就得搞清楚什么时候需要对_decoration赋值。我们知道_decoration是一个Tween,而Tween的主要职责就是定义动画的起始状态(begin)和终止状态(end)。对于AnimatedDecoratedBox来说,decoration的终止状态就是用户传给它的值,而起始状态是不确定的,有以下两种情况:

    1. AnimatedDecoratedBox首次build,此时直接将其decoration值置为起始状态,即_decoration值为DecorationTween(begin: decoration)
    2. AnimatedDecoratedBoxdecoration更新时,则起始状态为_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, //当前的tween,第一次调用为null
dynamic targetValue, // 终止状态
TweenConstructor<dynamic> constructor,//Tween构造器,在上述三种情况下会被调用以更新tween
);

可以看到,通过继承ImplicitlyAnimatedWidgetImplicitlyAnimatedWidgetState类可以快速的实现动画过渡组件的封装,这和我们纯手工实现相比,代码简化了很多。

动画过渡组件的反向动画

在使用动画过渡组件,我们只需要在改变一些属性值后重新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>();
// 通过controller来启动反向动画
_state.controller.reverse().then((e) {
// 经验证必须调用setState来触发rebuild,否则状态同步会有问题
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;

而保护属性原则上只应该在子类中使用,而不应该像上面示例代码一样在外部使用。综上,我们可以得出两条结论:

  1. 使用动画过渡组件时如果需要执行反向动画的场景,应尽量使用状态互换的方法,而不应该通过获取ImplicitlyAnimatedWidgetStatecontroller的方式。

  2. 如果我们自定义的动画过渡组件用不到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(),
),
);
}
}