07.Flutter Dialog
Dialog
AlertDialog
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, // 对话框外形
})
showDialog() 是 Material 组件库提供的一个用于弹出 Material 风格对话框的方法,签名如下:
1
2
3
4
5
Future<T?> showDialog<T>({
required BuildContext context,
required WidgetBuilder builder, // 对话框UI的builder
bool barrierDismissible = true, // 点击对话框barrier(遮罩)时是否关闭它
})
- 该方法返回一个 Future,它正是用于接收对话框的返回值:如果我们是通过点击对话框遮罩关闭的,则 Future 的值为 null,否则为我们通过 Navigator.of(context).pop(result) 返回的 result 值
示例:
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
//点击该按钮后弹出对话框
ElevatedButton(
child: Text("对话框1"),
onPressed: () async {
//弹出对话框并等待其关闭
bool? delete = await showDeleteConfirmDialog1(context);
if (delete == null) {
print("取消删除");
} else {
print("已确认删除");
//... 删除文件
}
},
),
// 弹出对话框
Future<bool?> showDeleteConfirmDialog1(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text("提示"),
content: Text("您确定要删除当前文件吗?"),
actions: <Widget>[
TextButton(
child: Text("取消"),
onPressed: () => Navigator.of(context).pop(), // 关闭对话框
),
TextButton(
child: Text("删除"),
onPressed: () {
//关闭对话框并返回true
Navigator.of(context).pop(true);
},
),
],
);
},
);
}
注意:如果 AlertDialog 的内容过长,内容将会溢出,这在很多时候可能不是我们期望的,所以如果对话框内容过长时,可以用 SingleChildScrollView 将内容包裹起来
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(BuildContext context) 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: const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Text('中文简体'),
),
),
SimpleDialogOption(
onPressed: () {
// 返回2
Navigator.pop(context, 2);
},
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Text('美国英语'),
),
),
],
);
});
if (i != null) {
print("选择了:${i == 1 ? "中文简体" : "美国英语"}");
}
}
Dialog
实际上 AlertDialog 和 SimpleDialog 都使用了 Dialog 类。由于 AlertDialog 和 SimpleDialog 中使用了 IntrinsicWidth 来尝试通过子组件的实际尺寸来调整自身尺寸,这就导致他们的子组件不能是延迟加载模型的组件(如 ListView、GridView 、 CustomScrollView 等)。
下面的代码运行后会报错:
1
2
3
4
5
AlertDialog(
content: ListView(
children: ...
),
);
示例:
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(BuildContext context) async {
int? index = await showDialog<int>(
context: context,
builder: (BuildContext context) {
var child = Column(
children: <Widget>[
const 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");
}
}
对话框打开动画及遮罩
showDialog
方法,它是 Material 组件库中提供的一个打开 Material 风格对话框的方法showGeneralDialog
方法用于打开一个普通风格的对话框呢(非 Material 风格)
1
2
3
4
5
6
7
8
9
10
Future<T?> showGeneralDialog<T>({
required BuildContext context,
required RoutePageBuilder pageBuilder, //构建对话框内部UI
bool barrierDismissible = false, //点击遮罩是否关闭对话框
String? barrierLabel, // 语义化标签(用于读屏软件)
Color barrierColor = const Color(0x80000000), // 遮罩颜色
Duration transitionDuration = const Duration(milliseconds: 200), // 对话框打开/关闭的动画时长
RouteTransitionsBuilder? transitionBuilder, // 对话框打开/关闭的动画
// ...
})
案例:定制的对话框动画为缩放动画,并同时制定遮罩颜色为 Colors.black87
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
Future<T?> showCustomDialog<T>({
required BuildContext context,
bool barrierDismissible = true,
required WidgetBuilder builder,
ThemeData? theme,
}) {
final ThemeData theme = Theme.of(context);
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,
);
}
// 使用
ElevatedButton(
onPressed: () {
showCustomDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text("提示"),
content: const Text("您确定要删除当前文件吗?"),
actions: <Widget>[
TextButton(
child: const Text("取消"),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: const Text("删除"),
onPressed: () {
// 执行删除操作
Navigator.of(context).pop(true);
},
),
],
);
},
);
},
child: const Text('CustomDialog'))
其他的对话框
showModalBottomSheet 底部菜单列表
showModalBottomSheet 方法可以弹出一个 Material 风格的底部菜单列表模态对话框
showModalBottomSheet 参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Future<T?> showModalBottomSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
Color? backgroundColor,
double? elevation,
ShapeBorder? shape,
Clip? clipBehavior,
BoxConstraints? constraints,
Color? barrierColor,
bool isScrollControlled = false,
bool useRootNavigator = false,
bool isDismissible = true,
bool enableDrag = true,
bool? showDragHandle,
bool useSafeArea = false,
RouteSettings? routeSettings,
AnimationController? transitionAnimationController,
Offset? anchorPoint,
})
- context 弹窗需要上下文的 context,这是因为实际页面展示是通过 Navigator 的 push 方法导航的新的页面完成的
- builder 可以返回自己自定义的组件
- isScrollControlled 为 true 时,则是全屏弹窗,默认是 false。
全屏
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Future<int?> _showBottomSheet(
BuildContext context, List<String> options) async {
return showModalBottomSheet<int>(
context: context,
isScrollControlled: true,
builder: (context) {
return ListView.builder(
itemBuilder: (context, index) {
return ListTile(
title: Text(options[index]),
onTap: () {
Navigator.of(context).pop(index);
},
);
},
itemCount: options.length,
);
});
}
半屏
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Future<int?> _showBottomSheet(
BuildContext context, List<String> options) async {
return showModalBottomSheet<int>(
context: context,
isScrollControlled: false,
builder: (context) {
return ListView.builder(
itemBuilder: (context, index) {
return ListTile(
title: Text(options[index]),
onTap: () {
Navigator.of(context).pop(index);
},
);
},
itemCount: options.length,
);
});
}
自定义
自定义的效果:
- 弹窗的高度指定为屏幕高度的一半
- 增加了标题栏,且标题栏有关闭按钮:标题在整个标题栏是居中的,而关闭按钮是在标题栏右侧顶部。这可以通过 Stack 堆栈布局组件实现不同的组件层叠及位置。
- 左上角和右上角做了圆角处理,这个可以通过 Container 的装饰完成,但需要注意的是,由于底部弹窗默认是有颜色的,因此要显示出圆角需要将底部弹窗的颜色设置为透明
代码:
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
Future<int?> _showCustomModalBottomSheet(
BuildContext context, List<String> options) async {
return showModalBottomSheet<int>(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) {
return Container(
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20.0),
topRight: Radius.circular(20.0))),
height: MediaQuery.of(context).size.height / 2.0, // 获取屏幕的尺寸
child: Column(
children: [
SizedBox(
height: 50,
child: Stack(
textDirection: TextDirection.rtl,
children: [
const Center(
child: Text(
'自定义底部弹层',
style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 16.0),
),
),
IconButton(
onPressed: () {
Navigator.of(context).pop();
},
icon: const Icon(Icons.close))
],
),
),
const Divider(height: 1.0),
Expanded(
child: ListView.builder(
itemBuilder: (context, index) {
return ListTile(
title: Text(options[index]),
onTap: () {
Navigator.of(context).pop(index);
},
);
},
itemCount: options.length,
),
)
],
),
);
});
}
- 获取屏幕的尺寸可以使用
MediaQuery.of(context).size
属性完成 - Stack 组件根据子元素的次序依次堆叠,最后面的在最顶层。textDirection 用于排布起始位置
- 由于 Column 下面嵌套了一个 ListView,因此需要使用 Expanded 将 ListView 包裹起来,以便有足够的空间供 ListView 的内容区滚动,否则会报布局溢出警告
多选弹窗
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
Future<List<int>?> _showMultiChoiceModalBottomSheet(
BuildContext context, List<String> options) async {
Set<int> selected = <int>{};
return showModalBottomSheet<List<int>?>(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (BuildContext context) {
return StatefulBuilder(builder: (context1, setState) {
return Container(
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20.0),
topRight: Radius.circular(20.0),
),
),
height: MediaQuery.of(context).size.height / 2.0,
child: Column(children: [
_getModalSheetHeaderWithConfirm('多选底部弹窗', onCancel: () {
Navigator.of(context).pop();
}, onConfirm: () {
Navigator.of(context).pop(selected.toList());
}),
const Divider(height: 1.0),
Expanded(
child: ListView.builder(
itemBuilder: (BuildContext context, int index) {
return ListTile(
trailing: Icon(
selected.contains(index)
? Icons.check_box
: Icons.check_box_outline_blank,
color: Theme.of(context).primaryColor),
title: Text(options[index]),
onTap: () {
setState(() {
if (selected.contains(index)) {
selected.remove(index);
} else {
selected.add(index);
}
});
},
);
},
itemCount: options.length,
),
),
]),
);
});
},
);
}
Widget _getModalSheetHeaderWithConfirm(String title,
{Function? onCancel, Function? onConfirm}) {
return SizedBox(
height: 50,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
onCancel?.call();
},
),
Expanded(
child: Center(
child: Text(
title,
style:
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0),
),
),
),
IconButton(
icon: const Icon(
Icons.check,
color: Colors.blue,
),
onPressed: () {
onConfirm?.call();
}),
],
),
);
}
非列表弹窗
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
Future<Object?> _showWidgetModalBottomSheet(context) {
return showModalBottomSheet<Object>(
isScrollControlled: false,
context: context,
builder: (BuildContext context) {
return Center(
child: Container(
height: 50,
width: 200,
margin: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.blue[400],
borderRadius: BorderRadius.circular(4.0),
),
child: TextButton(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
backgroundColor:
MaterialStateProperty.all<Color>(Colors.blue[400]!),
),
child: const Text('按钮'),
onPressed: () {
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("正在加载,请稍后..."),
)
],
),
);
},
);
}
如果我们嫌 Loading 框太宽,想自定义对话框宽度,这时只使用 SizedBox 或 ConstrainedBox 是不行的,原因是 showDialog 中已经给对话框设置了最小宽度约束,我们可以使用 UnconstrainedBox 先抵消 showDialog 对宽度的约束,然后再使用 SizedBox 指定宽度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
UnconstrainedBox(
constrainedAxis: Axis.vertical,
child: SizedBox(
width: 280,
child: AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
CircularProgressIndicator(value: .8,),
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);
},
),
);
},
);
}
Dialog 状态管理
Dialog 原理
以 showGeneralDialog 方法为例来看看它的具体实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Future<T?> showGeneralDialog<T extends Object?>({
required BuildContext context,
required RoutePageBuilder pageBuilder,
bool barrierDismissible = false,
String? barrierLabel,
Color barrierColor = const Color(0x80000000),
Duration transitionDuration = const Duration(milliseconds: 200),
RouteTransitionsBuilder? transitionBuilder,
bool useRootNavigator = true,
RouteSettings? routeSettings,
}) {
return Navigator.of(context, rootNavigator: useRootNavigator).push<T>(RawDialogRoute<T>(
pageBuilder: pageBuilder,
barrierDismissible: barrierDismissible,
barrierLabel: barrierLabel,
barrierColor: barrierColor,
transitionDuration: transitionDuration,
transitionBuilder: transitionBuilder,
settings: routeSettings,
));
}
实现很简单,直接调用 Navigator 的 push 方法打开了一个新的对话框路由 RawDialogRoute,然后返回了 push 的返回值。可见对话框实际上正是通过路由的形式实现的,这也是为什么我们可以使用 Navigator 的 pop 方法来退出对话框的原因。关于对话框的样式定制在 RawDialogRoute 中