Flutter UI - 弹窗系 Widget

6,716 阅读9分钟

Flutter 里面的谭庄不管是重量级的还是轻量级的都在这里了:

  • DropdownButton - 下拉菜单按钮
  • BottomSheet - 底部弹出弹窗
  • PopupMenuButton - pop 按钮
  • Dialog - 对话框
  • Toast - Flutter 没有内置 Toast,所有的 Toast 方案都是以外置插件的方式导进来使用的,这里多介绍几个
  • OKToast - 最好的 Toast 插件,功能非常完善,推荐使用
  • SnackBar - 喜闻乐见了啊,OKToast 其实已经可以取代 SnackBar 了,官方原生的 SnackBar 很死板

DropdownButton

DropdownButton 是一个简单的下拉选择框,构建特点:

  • 不管是 hint 的样式还是 可选择 的样式都是 widget
  • 每一项都是特定的 widget 类型:DropdownMenuItem,大家在其内部再写具体的 widget 样式,不过样式是自己写,但是 DropdownMenuItem 内部有个 value 对应该选项的值,这个必须要写的
  • 当前选项的变化需要我们自己维护,DropdownButton 是不会帮我们做的

图是随便找的:

主要属性如下:

  • hint - value == null 时显示的文字
  • disabledHint - 禁用的提示
  • items - 选项 item,注意是值是列表类型
  • style - 字体样式
  • iconSize - 右侧三角大小
  • isDense - true: item 高度是下拉框高度的一半,false: 下拉框高度和 item 一样
  • isExpanded - false:下拉框最小宽度 true: 充满父容器
  • value - 当前选中的那项
  • onChanged - 选中事件
class TestWidgetState extends State<TestWidget> {
  String selectValue;

  @override
  Widget build(BuildContext context) {
    return DropdownButton(
      hint: Text("请选择您要的号码:"),
      items: getItems(),
      value: selectValue,
      onChanged: (value) {
        print(value);
        setState(() {
          selectValue = value;
        });
      },
    );
  }

  getItems() {
    var items = List<DropdownMenuItem<String>>();
    items.add(DropdownMenuItem(child: Text("AA"), value: "11"));
    items.add(DropdownMenuItem(child: Text("BB"), value: "22",));
    items.add(DropdownMenuItem(child: Text("CC"), value: "33",));
    items.add(DropdownMenuItem(child: Text("DD"), value: "44",));
    items.add(DropdownMenuItem(child: Text("EE"), value: "55",));
    return items;
  }
}

这里有个泛型的点要注意,我们声明 DropdownMenuItem 选项列表时要指定数值类型 List<DropdownMenuItem<String>>(),要不 selectValue 那里就不能写具体的数值类型,只能写 var


BottomSheet

BottomSheet 很像 android 的 BottomSheetBehavior,不过不能向往拖拽成一个页面

RaisedButton(
  child: Text('点击'),
  onPressed: () {
    showModalBottomSheet(
      context: context,
      builder: (BuildContext context) {
        return Column(
          mainAxisSize: MainAxisSize.min, // 设置最小的弹出
          children: <Widget>[
            new ListTile(
              leading: new Icon(Icons.photo_camera),
              title: new Text("Camera"),
              onTap: () async {

              },
            ),
            new ListTile(
              leading: new Icon(Icons.photo_library),
              title: new Text("Gallery"),
              onTap: () async {
                
              },
            ),
          ],
        );
      }
    );
  },
)

PopupMenuButton

PopupMenuButton android 里的 PopupWindow 大家很熟悉吧,这个就是 Flutter 版的

样式图:

主要参数:

  • itemBuilder 构建弹出菜单样式,是 list 结构的数据,使用 PopupMenuItem 承载每个 item ,但是 list 外层的泛型要用 List<PopupMenuEntry<String>>,非常蛋疼,说实话 itemBuilder这里的 API 我是真不喜欢,太难用了,和DropdownButton的设计都不统一,对于itemBuilder内部的实现类谁关心呢,根本就不应该把PopupMenuEntry` 暴露出来。写法固定的,大家记住吧

  • elevation 阴影高度

  • padding 边距

  • child 按钮样式和 icon 互斥,只能用一个

  • icon 按钮样式和 child 互斥,只能用一个

  • offset 偏移量,offset 的值>100 比如:offset(100,100) 就是在 actionBar 的正下面

  • onSelected 用户选中的回调

  • onCanceled 用户取消的回调

  • 配合 actionBar 的写法:

class _MyHomePageState extends State<MyHomePage> {
  var items = <String>["AA", "BB", "CC", "DD", "FF"];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
        actions: <Widget>[
          PopupMenuButton<String>(
            itemBuilder: (BuildContext context) {
              return _getItemBuilder2();
            },
            icon: Icon(Icons.access_alarm),
            onSelected: (value) {
              print(value);
            },
            onCanceled: () {},
            offset: Offset(200, 100),
          )
        ],
      ),
      body: Center(
        child: TestWidget(),
      ),
    );
  }
}
  • 常规生成 item 的写法:
  List<PopupMenuEntry<String>> _getItemBuilder() {
    return items
        .map((item) => PopupMenuItem<String>(
              child: Text(item),
              value: item,
            ))
        .toList();
  }
  • 加上分割线,带 icon 的 item
  List<PopupMenuEntry<String>> _getItemBuilder2() {
    return <PopupMenuEntry<String>>[
      PopupMenuItem<String>(
        value: "1",
        child: ListTile(
          leading: Icon(Icons.share),
          title: Text('分享'),
        ),
      ),
      PopupMenuDivider(), //分割线
      PopupMenuItem<String>(
        value: "2",
        child: ListTile(
          leading: Icon(Icons.settings),
          title: Text('设置'),
        ),
      ),
    ];
  }

除了 actionBar 之外,其他地方也可以用啊, 置于显示位置,你要知道 item 的宽度,+- 代表左右方向,在下方按钮显示,Y 坐标写 100 就行


AlertDialog

1. 明确 Flutter 中 dialog 的基本特性

  • Flutterdialog 实际上是一个由 route 直接切换显示的页面,所以使用 Navigator.of(context) 的 push、pop(xx) 方法进行显示、关闭、返回数据
  • Flutter 中有两种风格的 dialog
    • showDialog() 启动的是 material 风格的对话框
    • showCupertinoDialog() 启动的是 ios 风格的对话框
  • Flutter 中有两种样式的 dialog
    • SimpleDialog 使用多个 SimpleDialogOption 为用户提供了几个选项
    • AlertDialog 一个可选标题 title 和一个可选列表的 actions 选项

2. showDialog 方法讲解

其方法定义如下:

Future<T> showDialog<T>({
  @required BuildContext context,
  bool barrierDismissible = true,
  @Deprecated(
    'Instead of using the "child" argument, return the child from a closure '
    'provided to the "builder" argument. This will ensure that the BuildContext '
    'is appropriate for widgets built in the dialog.'
  ) Widget child,
  WidgetBuilder builder,
}) {
    .......
}
  • context 上下文对象
  • barrierDismissible 点外面是不是可以关闭,默认是 true 可以关闭的
  • builder 是 widget 构造器
  • FlatButton 标准 AlertDialog 中的按钮必须使用这个类型
  • Navigator.of(context).pop(); 对话框内关闭对话框

3. 对话框显示层级

和 android 不同,Flutter 里对话框是属于 root 本页面层级的,看下图:

center 里 column 就是对话框,这里我是写的自定义的对话框,用的就是 column。可见 Flutter 的对话框相比 android 要成熟好用多了,这下我们轻松的实现全局对话框了...

但是有一点要知道,Flutter 中 dialog 和所在页面不在一个层级上,所以我们修改页面的数据,setState 方法就不能影响到 dialog 了,这样就实现不了数据变化了,所以在自定义 dialog 时,我们一般使用使用 StatefulWidget 来承载 dialog widget

4. AlertDialog

哈哈,看名字是不是很亲切啊,AlertDialog 作者非常 Nice,从使用习惯上就考虑照顾从 android 切过来的开发者,比其他一些 widget 的作者要强多了

张这个样子:

示例:

  // 定义对话框
  show(BuildContext context,) {
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (context) {
        return AlertDialog(
          title: Text("这里是测试标题"),
          actions: <Widget>[
            FlatButton(
              child: Text("删除"),
              onPressed: () {
                print("删除");
                Navigator.of(context).pop();
              },
            ),
            FlatButton(
              child: Text("取消"),
              onPressed: () {
                print("取消");
                Navigator.of(context).pop();
              },
            ),
          ],
        );
      },
    );
  }
  
  // 使用对话框
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.adb),
        tooltip: "tips",
        backgroundColor: Colors.blueAccent,
        foregroundColor: Colors.amberAccent,
        onPressed: () {
          show(context);
        },
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.startTop,
      body: Center(
        child: TestWidget(),
      ),
    );
  }

5. 自定义对话框

Flutter 中自定义听容易的,widget 中 child 传不一样的 widget 就是不同的样式了,showDialog 方法中的 builder 我们传自己的样式就是自定义 dialog 了,很简单不是,Flutter 基本上就是这个套路,大家要熟悉啊

  • 自定义 dialog
class TestDialog extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return TestDialogState();
  }
}

class TestDialogState extends State<TestDialog> {
  var num = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      crossAxisAlignment: CrossAxisAlignment.center,
      children: <Widget>[
        Text(num.toString(),style: TextStyle(decoration: TextDecoration.none),),
        Row(
          mainAxisSize: MainAxisSize.min,
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: <Widget>[
            RaisedButton(
              child: Text("+"),
              onPressed: () {
                setState(() {
                  num++;
                });
              },
            ),
            RaisedButton(
              child: Text("-"),
              onPressed: () {
                setState(() {
                  num--;
                });
              },
            ),
          ],
        ),
      ],
    );
  }
}
  • 启动 dialog
onPressed: () {
  showDialog(
    context: context,
    builder: (context) {
      return TestDialog();
    });
},

6. SimpleDialog

SimpleDialog 的样式

          showDialog(
              context: context,
              builder: (context) {
                return new SimpleDialog(
                  title: new Text("SimpleDialog"),
                  children: <Widget>[
                    new SimpleDialogOption(
                      child: new Text("SimpleDialogOption One"),
                      onPressed: () {
                        Navigator.of(context).pop("SimpleDialogOption One");
                      },
                    ),
                    new SimpleDialogOption(
                      child: new Text("SimpleDialogOption Two"),
                      onPressed: () {
                        Navigator.of(context).pop("SimpleDialogOption Two");
                      },
                    ),
                    new SimpleDialogOption(
                      child: new Text("SimpleDialogOption Three"),
                      onPressed: () {
                        Navigator.of(context).pop("SimpleDialogOption Three");
                      },
                    ),
                  ],
                );
              });

7. iOS 风格的 dialog

void showCupertinoDialog() {
    var dialog = CupertinoAlertDialog(
      content: Text(
        "你好,我是你苹果爸爸的界面",
        style: TextStyle(fontSize: 20),
      ),
      actions: <Widget>[
        CupertinoButton(
          child: Text("取消"),
          onPressed: () {
            Navigator.pop(context);
          },
        ),
        CupertinoButton(
          child: Text("确定"),
          onPressed: () {
            Navigator.pop(context);
          },
        ),
      ],
    );

    showDialog(context: context, builder: (_) => dialog);
  }

8. 一些坑

  • 自定义的 dialog 要是太长了超过屏幕长度了,请在外面加一个可以滚动的 SingleChildScrollView
  • 自定义的 dialog 要是有 ListView 的话,必须在最外面加上一个确定宽度和高度的 Container,要不会报错,道理和上面的那条一样的

Toast

上面说了 Flutter 内置没有 Toast, 这次介绍的插件就叫:Toast

  • 导入:
dependencies:
  toast: ^0.1.3
  • 使用:
class TestWidgetState extends State<TestWidget> {
  @override
  Widget build(BuildContext context) {
    return RaisedButton(
      child: Text("殿下您好"),
      onPressed: () {
        Toast.show('这是一个 toast', context);
      },
    );
  }
}

这应该是目前最简单的 Toast 了吧,但是还有比他更 NB 的


OKToast

OKToast 基本上大家都是使用这个插件,OKToast 插件功能飞铲发完善,支持关闭已显示的 toast,支持自定义 view toast,和输入法的联动也非常好。另外 OKToast 插件自己会缓存全局 context,不用我们每次再传 context 了,使用上更灵活了,一般我们在 android 里也是要自己做全局 toast 工具的,flutter 中 OKToast 就是我们最好的选择

1. 导包:

dependencies:
  oktoast: ^2.2.0

2. 使用 oktoast 包裹 MaterialApp widget

MaterialApp 一般都是看做 application 使的,所以 OKToast 包裹 MaterialApp 为的就是全局 context,另一个也是为了显示,OKToast 能包裹 MaterialApp 那么很明显这个 OKToast 一定是个 widget 了

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return OKToast(
      // 有一些通用设置,大家自己看看
      textStyle: TextStyle(
        fontSize: 16,
        color: Colors.white,
      ),
      backgroundColor: Colors.grey.withAlpha(200),
      child: MaterialApp(
        title: 'Frist Blood!',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: MyHomePage(title: 'Flutter Demo Home Page'),
      ),
    );
  }
}

3. 简单使用

这是最简单的用法,的确是非常简洁啊,我们自己写功能组件一定要以好用、清洗这个目标位准啊

showToast("hello world")

4. 关闭 toast

oktoast 这里提供了挺多的选择,可见作者非常用心了

  • 关闭所有 toast
dismissAllToast();
  • 显示 toast 时关闭之前显示的 toast
showToas的t("msg", dismissOtherToast: true);
  • 初始化时设置显示新的 toast 时自动关闭之前的 toast
OKToast(
  dismissOtherOnShow: true,
  ...
)
  • 关闭指定 toast
var future = showToast("msg");
future.dismiss(); 

5. oktoast 可以设置的参数

下面的参数在包裹 MaterialApp 那里或是具体显示时都可以使用,这个作者我真实太喜欢了,okToast 这个组件写的真是太好了

  • backgroundColor - 背景颜色
  • duration - 延迟隐藏时间
  • onDismiss - 隐藏时的回调
  • position - toast 的位置
  • radius - 圆角的尺寸
  • textAlign - 文字在内部的对齐方式
  • textDirection - ltr 或 rtl
  • textPadding - 文本距离边框的 padding
  • textStyle - 文本的样式

比如我们在顶部显示 toast

showToast("AA",position:ToastPosition.top );

6. 自定义 toast

oktoast 提供了一个函数:showToastWidget,往期中传自己的 widget 就行了,oktoast 本质上就是一个在 root 跟页面的页面

  • 使用自定义 toast:
showToastWidget(getToastWidget());
  • 自定义 widget:
  getToastWidget() {
    return Center(
      child: Container(
        padding: const EdgeInsets.all(5),
        color: Colors.black.withOpacity(0.7),
        child: Row(
          children: <Widget>[
            Icon(
              Icons.add,
              color: Colors.white,
            ),
            Text(
              '添加成功',
              style: TextStyle(color: Colors.white),
            ),
          ],
          mainAxisSize: MainAxisSize.min,
        ),
      ),
    );
  }

SnackBar

SnackBar 这里有出现变化了啊,虽然 SnackBar 还不是一个 widget,还是用方法启动,但是变化的是 SnackBar 需要 Scaffold 来启动,这就蛋疼了啊,之前的 dialog 自己专门有个 widget 层级的,使用 futrue 来启动不是挺好的嘛,你这里为啥飞得用 Scaffold 呢。我个人总之是不喜欢的,可见 Google 内部团队成员之间思路也是不统一的,团队建设有待加强啊。另外这样纷乱的 API 真是给 Google 掉份啊

1. 先创建 SnackBar 对象

    var snackBar = SnackBar(
      content: Text("测试版..."),
      action: SnackBarAction(
        label: "取消",
        onPressed: () {
          print("已取消!");
        },
      ),
    );

可选的参数:

  • content - 内容
  • backgroundColor - 背景颜色
  • elevation - 阴影高度
  • shape
  • behavior - 位置
    • SnackBarBehavior.fixed - 底部
    • behavior:SnackBarBehavior.floating - 顶部,不过顶部真的是非常难看,会和状态栏重合,下面有图大家看一下
  • action - 右边按钮
  • duration - 时间,默认是 4秒
  • animation - 显示/隐藏动画

2. 启动

Scaffold.of(context).showSnackBar(snackBar);

SnackBar 在顶部显示时可难看了