一種更優雅的Flutter Dialog解決方案

前言

系統自帶的Dialog實際上就是Push了一個新頁面,這樣存在不少好處,可是也存在一些很難解決的問題html

  • 必須傳BuildContextgit

    • loading彈窗通常都封裝在網絡框架中,多傳個context參數就很頭疼;用fish_redux還好,effect層直接能拿到context,要是用bloc還得在view層把context傳到bloc或者cubit裏面。。。
  • 沒法穿透暗色背景,點擊dialog後面的頁面github

    • 這個是真頭痛,想了不少辦法,都沒能在自帶的dialog上面解決這個問題
  • 系統自帶Dialog寫成的Loading彈窗,在網絡請求和跳轉頁面的狀況,會存在路由混亂的狀況web

    • 情景覆盤:loading庫通常封裝在網絡層,某個頁面提交完表單,要跳轉頁面,提交操做完成,進行頁面跳轉,loading關閉是在異步回調中進行(onError或者onSuccess),會出現執行了跳轉操做時,彈窗還未關閉,延時一小會關閉,由於用的都是pop頁面方法,會把跳轉的頁面pop掉
    • 上面是一種很常見的場景,涉及到複雜場景更加難以預測,解決方法也有:定位頁面棧的棧頂是不是Loading彈窗,選擇性Pop,實現麻煩

上面這些痛點,簡直個個致命,固然,還存在一些其它的解決方案,例如:redux

  • 頁面頂級使用Stack
  • 使用Overlay

很明顯,使用Overlay可移植性最好,目前不少Toast和dialog三方庫即是使用該方案,使用了一些loading庫,看了其中源碼,穿透背景解決方案,和預期想要的效果截然不同、一些dialog庫自帶toast顯示,可是toast顯示卻又不能和dialog共存(toast屬於特殊的信息展現,理應能獨立存在),致使我須要多依賴一個Toast庫網絡

SmartDialog

基於上面那些難以解決的問題,只能本身去實現,花了一些時間,實現了一個Pub包,基本該解決的痛點都已解決了,用於實際業務沒什麼問題app

效果

smartDialog

引入

dependencies:
  flutter_smart_dialog: any

使用

  • 主入口配置框架

    • 在主入口這地方須要配置下,這樣就能夠不傳BuildContext使用Dialog了
    • 只須要在MaterialApp的builder參數處配置下便可
void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: SmartDialogPage(),
      builder: (BuildContext context, Widget child) {
        return Material(
          type: MaterialType.transparency,
          child: FlutterSmartDialog(child: child),
        );
      },
    );
  }
}

使用FlutterSmartDialog包裹下child便可,下面就能夠愉快的使用SmartDialog了less

  • 使用Toast異步

    • msg:必傳信息
    • time:可選,Duration類型
    • alignment:可選,控制toast位置
    • 若是想使用花裏胡哨的Toast效果,使用show方法定製就好了,炒雞簡單喔,懶得本身寫的,抄下個人ToastWidget,改下屬性就好了哈
SmartDialog.showToast('test toast');
  • 使用Loading

    • msg:可選,loading動畫下面的文字信息(默認:加載中...)
//open loading
SmartDialog.showLoading();

//delay off
await Future.delayed(Duration(seconds: 2));
SmartDialog.dismiss();
  • 自定義dialog

    • 使用SmartDialog.instance.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類更易維護
參數 功能說明
alignment 控制自定義控件位於屏幕的位置<br/>Alignment.center: 自定義控件位於屏幕中間,且是動畫默認爲:漸隱和縮放,可以使用isLoading選擇動畫<br/>Alignment.bottomCenter、Alignment.bottomLeft、Alignment.bottomRight:自定義控件位於屏幕底部,動畫默認爲位移動畫,自下而上,可以使用animationDuration設置動畫時間<br/>Alignment.topCenter、Alignment.topLeft、Alignment.topRight:自定義控件位於屏幕頂部,動畫默認爲位移動畫,自上而下,可以使用animationDuration設置動畫時間<br/>Alignment.centerLeft:自定義控件位於屏幕左邊,動畫默認爲位移動畫,自左而右,可以使用animationDuration設置動畫時間<br/> Alignment.centerRight:自定義控件位於屏幕左邊,動畫默認爲位移動畫,自右而左,可以使用animationDuration設置動畫時間
isPenetrate 默認:false;是否穿透遮罩背景,交互遮罩以後控件,true:點擊能穿透背景,false:不能穿透;穿透遮罩設置爲true,背景遮罩會自動變成透明(必須)
clickBgDismiss 默認:false;點擊遮罩,是否關閉dialog---true:點擊遮罩關閉dialog,false:不關閉
maskColor 遮罩顏色
animationDuration 動畫時間
isUseAnimation 默認:true;是否使用動畫
isLoading 默認:true;是否使用Loading動畫;true:內容體使用漸隱動畫 false:內容體使用縮放動畫,僅僅針對中間位置的控件
isExist 默認:false;主體SmartDialog(OverlayEntry)是否存在在界面上
isExistExtra 默認:false;額外SmartDialog(OverlayEntry)是否存在在界面上
  • Config屬性使用,舉個栗子

    • 內部已初始化相關屬性;若是須要定製,可在主入口處,初始化本身想要的屬性
SmartDialog.instance.config
    ..clickBgDismiss = true
    ..isLoading = true
    ..isUseAnimation = true
    ..animationDuration = Duration(milliseconds: 270)
    ..isPenetrate = true
    ..maskColor = Colors.black.withOpacity(0.1)
    ..isExist = false
    ..isExistExtra = false
    ..alignment = Alignment.center;
  • 返回事件,關閉彈窗解決方案

使用Overlay的依賴庫,基本都存在一個問題,難以對返回事件的監聽,致使觸犯返回事件難以關閉彈窗佈局之類,想了不少辦法,沒辦法在依賴庫中解決該問題,此處提供一個BaseScaffold,在每一個頁面使用BaseScaffold,便能解決返回事件關閉Dialog問題

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.resizeToAvoidBottomPadding,
        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 resizeToAvoidBottomPadding;
    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,
                resizeToAvoidBottomPadding: widget.resizeToAvoidBottomPadding,
                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一些信息

相關文章
相關標籤/搜索