文章

07.Flutter Dialog

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);
            },
          ),
        ],
      );
    },
  );
}

ktkx2

注意:如果 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 ? "中文简体" : "美国英语"}");
  }
}

iasvw

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");
  }
}

k3t3j

对话框打开动画及遮罩

  • 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'))

ojmvz

其他的对话框

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,
        );
      });
}

ubyb4

半屏

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,
        );
      });
}

3nr3d

自定义

自定义的效果:

  • 弹窗的高度指定为屏幕高度的一半
  • 增加了标题栏,且标题栏有关闭按钮:标题在整个标题栏是居中的,而关闭按钮是在标题栏右侧顶部。这可以通过 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 的内容区滚动,否则会报布局溢出警告

51ovi

多选弹窗

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();
            }),
      ],
    ),
  );
}

nah5y

非列表弹窗

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('非列表组件返回');
              }),
        ),
      );
    },
  );
}

ifc3w

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("正在加载,请稍后..."),
            )
          ],
        ),
      );
    },
  );
}

hwjqh
如果我们嫌 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("正在加载,请稍后..."),
          )
        ],
      ),
    ),
  ),
);

qrn1t

日历选择器

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),
    ),
  );
}

8aps9
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);
          },
        ),
      );
    },
  );
}

nea4b

Dialog 状态管理

https://book.flutterchina.club/chapter7/dailog.html#_7-7-4-%E5%AF%B9%E8%AF%9D%E6%A1%86%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86

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 中

本文由作者按照 CC BY 4.0 进行授权