写点什么

一种更优雅的 Flutter Dialog 解决方案

用户头像
小呆呆666
关注
发布于: 5 小时前

前言

系统自带的 Dialog 实际上就是 Push 了一个新页面,这样存在很多好处,但是也存在一些很难解决的问题


  • 必须传 BuildContext

  • loading 弹窗一般都封装在网络框架中,多传个 context 参数就很头疼;用 fish_redux 还好,effect 层直接能拿到 context,要是用 bloc 还得在 view 层把 context 传到 bloc 或者 cubit 里面。。。

  • 无法穿透暗色背景,点击 dialog 后面的页面

  • 这个是真头痛,想了很多办法,都没能在自带的 dialog 上面解决这个问题

  • 系统自带 Dialog 写成的 Loading 弹窗,在网络请求和跳转页面的情况,会存在路由混乱的情况

  • 情景复盘:loading 库一般封装在网络层,某个页面提交完表单,要跳转页面,提交操作完成,进行页面跳转,loading 关闭是在异步回调中进行(onError 或者 onSuccess),会出现执行了跳转操作时,弹窗还未关闭,延时一小会关闭,因为用的都是 pop 页面方法,会把跳转的页面 pop 掉

  • 上面是一种很常见的场景,涉及到复杂场景更加难以预测,解决方法也有:定位页面栈的栈顶是否是 Loading 弹窗,选择性 Pop,实现麻烦


上面这些痛点,简直个个致命,当然,还存在一些其它的解决方案,例如:


  • 页面顶级使用 Stack

  • 使用 Overlay


很明显,使用 Overlay 可移植性最好,目前很多 toast 和 dialog 三方库便是使用该方案,使用了一些 loading 库,看了其中源码,穿透背景解决方案,和预期想要的效果大相径庭、一些 dialog 库自带 toast 显示,但是 toast 显示却又不能和 dialog 共存(toast 属于特殊的信息展示,理应能独立存在),导致我需要多依赖一个 Toast 库

SmartDialog

基于上面那些难以解决的问题,只能自己去实现,花了一些时间,实现了一个 Pub 包,基本该解决的痛点都已解决了,用于实际业务没什么问题

效果


引入


dependencies:  flutter_smart_dialog: any
复制代码


  • 注意:该库已迁移空安全,注意版本区分


# 非空安全前最后一个稳定版本dependencies:  flutter_smart_dialog: ^1.3.1
复制代码

使用

  • 主入口配置

  • 在主入口这地方需要配置下,这样就可以不传 BuildContext 使用 Dialog 了

  • 只需要在 MaterialApp 的 builder 参数处配置下即可


void main() {  runApp(MyApp());}
///flutter 2.0class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Container(), builder: (BuildContext context, Widget? child) { return FlutterSmartDialog(child: child); }, ); }}
///flutter 1.xclass MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Container(), builder: (BuildContext context, Widget child) { return FlutterSmartDialog(child: child); }, ); }}
复制代码


使用FlutterSmartDialog包裹下 child 即可,下面就可以愉快的使用 SmartDialog 了


  • 使用 Toast:因为 toast 特殊性,此处单独对 toast 做了一些优化

  • time:可选,Duration 类型,默认 1500ms

  • isDefaultDismissType:toast 消失的类型,默认 true

  • true:默认消失类型,类似 android 的 toast,toast 一个一个展示

  • false:非默认消失类型,多次点击,后面 toast 会顶掉前者的 toast 显示

  • widget:可以自定义 toast

  • msg:必传信息

  • alignment:可选,控制 toast 位置

  • 如果想使用花里胡哨的 Toast 效果,请使用 showToast 方法定制就行了,炒鸡简单喔,懒得自己写的,抄下我的 ToastWidget,改下属性就行了哈


SmartDialog.showToast('test toast');
复制代码


  • 使用 Loading:loading 拥有诸多设置属性,参照下方的SmartDialog配置参数说明即可

  • msg:可选,loading 动画下面的文字信息(默认:加载中...)


//open loadingSmartDialog.showLoading();
//delay offawait Future.delayed(Duration(seconds: 2));SmartDialog.dismiss();
复制代码


  • 自定义 dialog

  • 使用 SmartDialog.show()方法即可,里面含有众多Temp为后缀的参数,和下述无Temp为后缀的参数功能一致

  • 特殊属性isUseExtraWidget:是否使用额外覆盖浮层,可与主浮层独立开;可与 loading,dialog 之类独立开,自带的 showToast 便是开启了该配置,可与 loading 共存


SmartDialog.show(    alignmentTemp: Alignment.bottomCenter,    clickBgDismissTemp: true,    widget: Container(      color: Colors.blue,      height: 300,    ),);
复制代码


  • SmartDialog 配置参数说明

  • 为了避免instance里面暴露过多属性,导致使用不便,此处诸多参数使用instance中的config属性管理

  • 使用 config 设置的属性都是全局的,将这些属性单独使用 Config 管理,是为了方便修改和管理这些属性,也是为了使 SmartDialog 类更易维护



  • Config 属性使用,举个栗子

  • 内部已初始化相关属性;如果需要定制,可在主入口处,初始化自己想要的属性


SmartDialog.instance.config    ..clickBgDismiss = true    ..isLoading = true    ..isUseAnimation = true    ..animationDuration = Duration(milliseconds: 270)    ..isPenetrate = false    ..maskColor = Colors.black.withOpacity(0.1)    ..alignment = Alignment.center;
复制代码


  • 返回事件,关闭弹窗解决方案


使用 Overlay 的依赖库,基本都存在一个问题,难以对返回事件的监听,导致触犯返回事件难以关闭弹窗布局之类,想了很多办法,没办法在依赖库中解决该问题,此处提供一个BaseScaffold,在每个页面使用BaseScaffold,便能解决返回事件关闭 Dialog 问题


  • Flutter 2.0


typedef ScaffoldParamVoidCallback = void Function();
class BaseScaffold extends StatefulWidget { const BaseScaffold({ Key? key, this.appBar, this.body, this.floatingActionButton, this.floatingActionButtonLocation, this.floatingActionButtonAnimator, this.persistentFooterButtons, this.drawer, this.endDrawer, this.bottomNavigationBar, this.bottomSheet, this.backgroundColor, this.resizeToAvoidBottomInset, this.primary = true, this.drawerDragStartBehavior = DragStartBehavior.start, this.extendBody = false, this.extendBodyBehindAppBar = false, this.drawerScrimColor, this.drawerEdgeDragWidth, this.drawerEnableOpenDragGesture = true, this.endDrawerEnableOpenDragGesture = true, this.isTwiceBack = false, this.isCanBack = true, this.onBack, }) : assert(primary != null), assert(extendBody != null), assert(extendBodyBehindAppBar != null), assert(drawerDragStartBehavior != null), super(key: key);
///系统Scaffold的属性 final bool extendBody; final bool extendBodyBehindAppBar; final PreferredSizeWidget? appBar; final Widget? body; final Widget? floatingActionButton; final FloatingActionButtonLocation? floatingActionButtonLocation; final FloatingActionButtonAnimator? floatingActionButtonAnimator; final List<Widget>? persistentFooterButtons; final Widget? drawer; final Widget? endDrawer; final Color? drawerScrimColor; final Color? backgroundColor; final Widget? bottomNavigationBar; final Widget? bottomSheet; final bool? resizeToAvoidBottomInset; final bool primary; final DragStartBehavior drawerDragStartBehavior; final double? drawerEdgeDragWidth; final bool drawerEnableOpenDragGesture; final bool endDrawerEnableOpenDragGesture;
///增加的属性 ///点击返回按钮提示是否退出页面,快速点击俩次才会退出页面 final bool isTwiceBack;
///是否可以返回 final bool isCanBack;
///监听返回事件 final ScaffoldParamVoidCallback? onBack;
@override _BaseScaffoldState createState() => _BaseScaffoldState();}
class _BaseScaffoldState extends State<BaseScaffold> { DateTime? _lastPressedAt; //上次点击时间
@override Widget build(BuildContext context) { return WillPopScope( child: Scaffold( appBar: widget.appBar, body: widget.body, floatingActionButton: widget.floatingActionButton, floatingActionButtonLocation: widget.floatingActionButtonLocation, floatingActionButtonAnimator: widget.floatingActionButtonAnimator, persistentFooterButtons: widget.persistentFooterButtons, drawer: widget.drawer, endDrawer: widget.endDrawer, bottomNavigationBar: widget.bottomNavigationBar, bottomSheet: widget.bottomSheet, backgroundColor: widget.backgroundColor, resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset, primary: widget.primary, drawerDragStartBehavior: widget.drawerDragStartBehavior, extendBody: widget.extendBody, extendBodyBehindAppBar: widget.extendBodyBehindAppBar, drawerScrimColor: widget.drawerScrimColor, drawerEdgeDragWidth: widget.drawerEdgeDragWidth, drawerEnableOpenDragGesture: widget.drawerEnableOpenDragGesture, endDrawerEnableOpenDragGesture: widget.endDrawerEnableOpenDragGesture, ), onWillPop: dealWillPop, ); }
///控件返回按钮 Future<bool> dealWillPop() async { if (widget.onBack != null) { widget.onBack!(); }
//处理弹窗问题 if (SmartDialog.instance.config.isExist) { SmartDialog.dismiss(); return false; }
//如果不能返回,后面的逻辑就不走了 if (!widget.isCanBack) { return false; }
if (widget.isTwiceBack) { if (_lastPressedAt == null || DateTime.now().difference(_lastPressedAt!) > Duration(seconds: 1)) { //两次点击间隔超过1秒则重新计时 _lastPressedAt = DateTime.now();
//弹窗提示 SmartDialog.showToast("再点一次退出"); return false; } return true; } else { return true; } }}
复制代码


  • Flutter 1.x


typedef ScaffoldParamVoidCallback = void Function();
class BaseScaffold extends StatefulWidget { const BaseScaffold({ Key key, this.appBar, this.body, this.floatingActionButton, this.floatingActionButtonLocation, this.floatingActionButtonAnimator, this.persistentFooterButtons, this.drawer, this.endDrawer, this.bottomNavigationBar, this.bottomSheet, this.backgroundColor, this.resizeToAvoidBottomInset, this.primary = true, this.drawerDragStartBehavior = DragStartBehavior.start, this.extendBody = false, this.extendBodyBehindAppBar = false, this.drawerScrimColor, this.drawerEdgeDragWidth, this.drawerEnableOpenDragGesture = true, this.endDrawerEnableOpenDragGesture = true, this.isTwiceBack = false, this.isCanBack = true, this.onBack, }) : assert(primary != null), assert(extendBody != null), assert(extendBodyBehindAppBar != null), assert(drawerDragStartBehavior != null), super(key: key);
///系统Scaffold的属性 final bool extendBody; final bool extendBodyBehindAppBar; final PreferredSizeWidget appBar; final Widget body; final Widget floatingActionButton; final FloatingActionButtonLocation floatingActionButtonLocation; final FloatingActionButtonAnimator floatingActionButtonAnimator; final List<Widget> persistentFooterButtons; final Widget drawer; final Widget endDrawer; final Color drawerScrimColor; final Color backgroundColor; final Widget bottomNavigationBar; final Widget bottomSheet; final bool resizeToAvoidBottomInset; final bool primary; final DragStartBehavior drawerDragStartBehavior; final double drawerEdgeDragWidth; final bool drawerEnableOpenDragGesture; final bool endDrawerEnableOpenDragGesture;
///增加的属性 ///点击返回按钮提示是否退出页面,快速点击俩次才会退出页面 final bool isTwiceBack;
///是否可以返回 final bool isCanBack;
///监听返回事件 final ScaffoldParamVoidCallback onBack;
@override _BaseScaffoldState createState() => _BaseScaffoldState();}
class _BaseScaffoldState extends State<BaseScaffold> { DateTime _lastPressedAt; //上次点击时间
@override Widget build(BuildContext context) { return WillPopScope( child: Scaffold( appBar: widget.appBar, body: widget.body, floatingActionButton: widget.floatingActionButton, floatingActionButtonLocation: widget.floatingActionButtonLocation, floatingActionButtonAnimator: widget.floatingActionButtonAnimator, persistentFooterButtons: widget.persistentFooterButtons, drawer: widget.drawer, endDrawer: widget.endDrawer, bottomNavigationBar: widget.bottomNavigationBar, bottomSheet: widget.bottomSheet, backgroundColor: widget.backgroundColor, resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset, primary: widget.primary, drawerDragStartBehavior: widget.drawerDragStartBehavior, extendBody: widget.extendBody, extendBodyBehindAppBar: widget.extendBodyBehindAppBar, drawerScrimColor: widget.drawerScrimColor, drawerEdgeDragWidth: widget.drawerEdgeDragWidth, drawerEnableOpenDragGesture: widget.drawerEnableOpenDragGesture, endDrawerEnableOpenDragGesture: widget.endDrawerEnableOpenDragGesture, ), onWillPop: dealWillPop, ); }
///控件返回按钮 Future<bool> dealWillPop() async { if (widget.onBack != null) { widget.onBack(); }
//处理弹窗问题 if (SmartDialog.instance.config.isExist) { SmartDialog.dismiss(); return false; }
//如果不能返回,后面的逻辑就不走了 if (!widget.isCanBack) { return false; }
if (widget.isTwiceBack) { if (_lastPressedAt == null || DateTime.now().difference(_lastPressedAt) > Duration(seconds: 1)) { //两次点击间隔超过1秒则重新计时 _lastPressedAt = DateTime.now();
//弹窗提示 SmartDialog.showToast("再点一次退出"); return false; } return true; } else { return true; } }}
复制代码

几个问题解决方案

穿透背景

  • 穿透背景有俩个解决方案,这里都说明下

AbsorbPointer、IgnorePointer

当时想解决穿透暗色背景,和背景后面的控件互动的时候,我几乎立马想到这俩个控件,先了解下这俩个控件吧


  • AbsorbPointer

  • 阻止子树接收指针事件,AbsorbPointer本身可以响应事件,消耗掉事件

  • absorbing 属性(默认 true)

  • true:拦截向子 Widget 传递的事件 false:不拦截


AbsorbPointer(    absorbing: true,    child: Listener(        onPointerDown: (event){            print('+++++++++++++++++++++++++++++++++');        },    ))
复制代码


  • IgnorePointer

  • 阻止子树接收指针事件,IgnorePointer本身无法响应事件,其下的控件可以接收到点击事件(父控件)

  • ignoring 属性(默认 true)

  • true:拦截向子 Widget 传递的事件 false:不拦截


IgnorePointer(    ignoring: true,    child: Listener(        onPointerDown: (event){            print('----------------------------------');        },    ))
复制代码


分析


  • 这里来分析下,首先AbsorbPointer这个控件是不合适的,因为AbsorbPointer本身会消费触摸事件,事件被AbsorbPointer消费掉,会导致背景后的页面无法获取到触摸事件;IgnorePointer本身无法消费触摸事件,又由于IgnorePointerAbsorbPointer都具有屏蔽子 Widget 获取触摸事件的作用,这个貌似靠谱,这里试了,可以和背景后面的页面互动!但是又存在一个十分坑的问题

  • 因为使用IgnorePointer屏蔽子控件的触摸事件,而IgnorePointer本身又不消耗触摸事件,会导致无法获取到背景的点击事件!这样点击背景会无法关闭 dialog 弹窗,只能手动关闭 dialog;各种尝试,实在没办法获取到背景的触摸事件,此种穿透背景的方案只能放弃

Listener、behavior

这种方案,成功实现想要的穿透效果,这里了解下behavior的几种属性


  • deferToChild:仅当一个孩子被命中测试击中时,屈服于其孩子的目标才会在其范围内接收事件

  • opaque:不透明目标可能会受到命中测试的打击,导致它们既在其范围内接收事件,又在视觉上阻止位于其后方的目标也接收事件

  • translucent:半透明目标既可以接收其范围内的事件,也可以在视觉上允许目标后面的目标也接收事件


有戏了!很明显 translucent 是有希望的,尝试了几次,然后成功实现了想要的效果


注意,这边有几个坑点,提一下


  • 务必使用Listener控件来使用 behavior 属性,使用 GestureDetector 中 behavior 属性会存在一个问题,一般来说:都是 Stack 控件里面的 Children,里面有俩个控件,分上下层,在此处,GestureDetector 设置 behavior 属性,俩个 GestureDetector 控件上下叠加,会导致下层 GestureDetector 获取不到触摸事件,很奇怪;使用Listener不会产生此问题

  • 我们的背景使用Container控件,我这里设置了Colors.transparent,直接会导致下层接受不到触摸事件,color 为空才能使下层控件接受到触摸事件,此处不要设置 color 即可


下面是写的一个验证小示例


class TestLayoutPage extends StatelessWidget {  @override  Widget build(BuildContext context) {    return _buildBg(children: [      //下层      Listener(        onPointerDown: (event) {          print('下层蓝色区域++++++++');        },        child: Container(          height: 300,          width: 300,          color: Colors.blue,        ),      ),
//上层 事件穿透 Listener( behavior: HitTestBehavior.translucent, onPointerDown: (event) { print('上层区域---------'); }, child: Container( height: 200, width: 200, ), ), ]); }
Widget _buildBg({List<Widget> children}) { return Scaffold( appBar: AppBar(title: Text('测试布局')), body: Center( child: Stack( alignment: Alignment.center, children: children, ), ), ); }}
复制代码

Toast 和 Loading 冲突

  • 这个问题,从理论上肯定会存在的,因为一般 Overlay 库只会使用一个 OverlayEntry 控件,这会导致,全局只能存在一个浮窗布局,Toast 本质是一个全局弹窗,Loading 也是一个全局弹窗,使用其中一个都会导致另一个消失

  • Toast 明显是应该独立于其他弹窗的一个消息提示,封装在网络库中的关闭弹窗的 dismiss 方法,也会将 Toast 消息在不适宜的时候关闭,在实际开发中就碰到此问题,只能多引用一个 Toast 三方库来解决,在规划这个 dialog 库的时候,就想到必须解决此问题

  • 此处内部多使用了一个 OverlayEntry 来解决该问题,提供了相关参数来分别控制,完美使 Toast 独立于其它的 dialog 弹窗

  • 多增加一个 OverlayEntry 都会让内部逻辑和方法使用急剧复杂,维护也会变得不可预期,故额外只多提供一个 OverlayEntry;如果需要更多,可 copy 本库,自行定义,实现该库相关源码,都力求能让人看明白,相信大家 copy 使用时不会感到晦涩难懂

  • FlutterSmartDialog 提供OverlayEntryOverlayEntryExtra可以高度自定义,相关实现,可查看内部实现

  • FlutterSmartDialog 内部已进行相关实现,使用show()方法中的isUseExtraWidget区分

最后

这个库花了一些时间去构思和实现,算是解决几个很大的痛点


  • 如果大家对返回事件有什么好的处理思路,麻烦在评论里告知,谢谢!

项目地址

FlutterSmartDialog 一些信息


系列文章

状态管理


发布于: 5 小时前阅读数: 4
用户头像

小呆呆666

关注

2021,葬爱不在低调 2020.08.17 加入

还未添加个人简介

评论

发布
暂无评论
一种更优雅的Flutter Dialog解决方案