一種更優雅的Flutter Dialog解決方案
阿新 • • 發佈:2020-12-04
# 前言
系統自帶的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包,基本該解決的痛點都已解決了,用於實際業務沒什麼問題**
## 效果
- [點我體驗一下](https://cnad666.github.io/flutter_use/web/index.html#/smartDialog)
![smartDialog](https://cdn.jsdelivr.net/gh/CNAD666/MyData/pic/flutter/blog/20201204145311.gif)
## 引入
- Pub:[flutter_smart_dialog](https://pub.dev/packages/flutter_smart_dialog)
```dart
dependencies:
flutter_smart_dialog: ^1.0.1
```
## 使用
- 主入口配置
- 在主入口這地方需要配置,這樣就可以不傳BuildContext使用Dialog
- 只需要在MaterialApp的builder引數處配置下即可
```dart
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了
- 使用Toast
- msg:必傳資訊
- time:可選,Duration型別
- alignment:可控制toast位置
- 如果想使用花裡花哨的Toast效果,使用show方法定製就行了,炒雞簡單喔,懶得寫,抄下我的ToastWidget,改下屬性即可
```dart
SmartDialog.instance.showToast('test toast');
```
- 使用Loading
```dart
//open loading
SmartDialog.instance.showLoading();
//delay off
await Future.delayed(Duration(seconds: 2));
SmartDialog.instance.dismiss();
```
- 自定義dialog
- 使用SmartDialog.instance.show()方法即可,裡面含有眾多'Temp'為字尾的引數,和下述無'Temp'為字尾的引數功能一致
```dart
SmartDialog.instance.show(
alignmentTemp: Alignment.bottomCenter,
clickBgDismissTemp: true,
widget: Container(
color: Colors.blue,
height: 300,
),
);
```
- SmartDialog配置引數說明
- 為了避免`instance`裡面暴露過多屬性,導致使用不便,此處諸多引數使用`instance`中的`config`屬性管理
| 引數 | 功能說明 |
| ----------------- | ------------------------------------------------------------ |
| alignment | 控制自定義控制元件位於螢幕的位置
**Alignment.center**: 自定義控制元件位於螢幕中間,且是動畫預設為:漸隱和縮放,可使用isLoading選擇動畫
**Alignment.bottomCenter、Alignment.bottomLeft、Alignment.bottomRight**:自定義控制元件位於螢幕底部,動畫預設為位移動畫,自下而上,可使用animationDuration設定動畫時間
**Alignment.topCenter、Alignment.topLeft、Alignment.topRight**:自定義控制元件位於螢幕頂部,動畫預設為位移動畫,自上而下,可使用animationDuration設定動畫時間
**Alignment.centerLeft**:自定義控制元件位於螢幕左邊,動畫預設為位移動畫,自左而右,可使用animationDuration設定動畫時間
**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)是否存在在介面上 | - **返回事件,關閉彈窗解決方案** 使用Overlay的依賴庫,基本都存在一個問題,難以對返回事件的監聽,導致觸犯返回事件難以關閉彈窗佈局之類,想了很多辦法,沒辦法在依賴庫中解決該問題,此處提供一個`BaseScaffold`,在每個頁面使用`BaseScaffold`,便能解決返回事件關閉Dialog問題 ```dart 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 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 {
//上次點選時間
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 dealWillPop() async {
if (widget.onBack != null) {
widget.onBack();
}
//處理彈窗問題
if (SmartDialog.instance.config.isExist) {
SmartDialog.instance.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.instance.showToast("再點一次退出");
return false;
}
return true;
} else {
return true;
}
}
}
```
# 幾個問題解決方案
## 穿透背景
- **穿透背景有倆個解決方案,這裡都說明下**
### AbsorbPointer、IgnorePointer
當時想解決穿透暗色背景,和背景後面的控制元件互動的時候,我幾乎立馬想到這倆個控制元件,先了解下這倆個控制元件吧
- AbsorbPointer
- 阻止子樹接收指標事件,`AbsorbPointer`本身可以響應事件,消耗掉事件
- `absorbing` 屬性(預設true)
- true:攔截向子Widget傳遞的事件 false:不攔截
```dart
AbsorbPointer(
absorbing: true,
child: Listener(
onPointerDown: (event){
LogUtil.log('+++++++++++++++++++++++++++++++++');
},
)
)
```
- IgnorePointer
- 阻止子樹接收指標事件,`IgnorePointer`本身無法響應事件,其下的控制元件可以接收到點選事件(父控制元件)
- `ignoring` 屬性(預設true)
- true:攔截向子Widget傳遞的事件 false:不攔截
```dart
IgnorePointer(
ignoring: true,
child: Listener(
onPointerDown: (event){
LogUtil.log('----------------------------------');
},
)
)
```
**分析**
- 這裡來分析下,首先`AbsorbPointer`這個控制元件是不合適的,因為`AbsorbPointer`本身會消費觸控事件,事件被`AbsorbPointer`消費掉,會導致背景後的頁面無法獲取到觸控事件;`IgnorePointer`本身無法消費觸控事件,又由於`IgnorePointer`和`AbsorbPointer`都具有遮蔽子Widget獲取觸控事件的作用,這個貌似靠譜,這裡試了,可以和背景後面的頁面互動!但是又存在一個十分坑的問題
- 因為使用`IgnorePointer`遮蔽子控制元件的觸控事件,而`IgnorePointer`本身又不消耗觸控事件,會導致無法獲取到背景的點選事件!這樣點選背景會無法關閉dialog彈窗,只能手動關閉dialog;各種嘗試,實在沒辦法獲取到背景的觸控事件,此種穿透背景的方案只能放棄
### Listener、behavior
這種方案,成功實現想要的穿透效果,這裡瞭解下`behavior`的幾種屬性
- deferToChild:僅當一個孩子被命中測試擊中時,屈服於其孩子的目標才會在其範圍內接收事件
- opaque:不透明目標可能會受到命中測試的打擊,導致它們既在其範圍內接收事件,又在視覺上阻止位於其後方的目標也接收事件
- translucent:半透明目標既可以接收其範圍內的事件,也可以在視覺上允許目標後面的目標也接收事件
有戲了!很明顯translucent是有希望的,嘗試了幾次,然後成功實現了想要的效果
注意,這邊有幾個坑點,提一下
- 務必使用`Listener`控制元件來使用behavior屬性,使用GestureDetector中behavior屬性會存在一個問題,一般來說:都是Stack控制元件裡面的Children,裡面有倆個控制元件,分上下層,在此處,GestureDetector設定behavior屬性,倆個GestureDetector控制元件上下疊加,會導致下層GestureDetector獲取不到觸控事件,很奇怪;使用`Listener`不會產生此問題
- 我們的背景使用`Container`控制元件,裡面的`color`不要設定值,我這裡設定了`Colors.transparent`,直接會導致下層接受不到觸控事件,color為空才能使下層控制元件接受到觸控事件,此處不要設定color即可
下面是寫的一個驗證小示例
```dart
class TestLayoutPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _buildBg(children: [
//底下
Listener(
onPointerDown: (event) {
print(context, '底部藍色區域++++++++');
},
child: Container(
height: 300,
width: 300,
color: Colors.blue,
),
),
//上面 事件穿透
Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (event) {
print(context, '上面紅色區域---------');
},
child: Container(
height: 200,
width: 200,
),
),
]);
}
Widget _buildBg({List 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彈窗
- 此處只多提供一個OverlayEntryExtra,如果需要更多,可copy本庫,自行定義,多增加一個OverlayEntry都會讓內部邏輯和方法使用急劇複雜,維護也會變得不可預期,故只多提供一個OverlayEntry
- FlutterSmartDialog提供`OverlayEntry`和`OverlayEntryExtra`可以高度自定義,相關實現,可檢視內部實現
- FlutterSmartDialog內部已進行相關實現,使用`show()`方法中的`isUseExtraWidget`區分
# 最後
這個庫花了一些時間去構思實現,算是解決幾個很大的痛點
- 如果大家對`返回事件`有什麼好的處理思路,麻煩在評論裡告知,謝謝!
FlutterSmartDialog一些資訊
- Github:[flutter_smart_dialog](https://github.com/CNAD666/flutter_smart_dialog)
- Pub:[flutter_smart_dialog](https://pub.dev/packages/flutter_smart_dialog)
- 使用效果體驗:[點我](https://cnad666.github.io/flutter_use/web/index.html#/smar
**Alignment.center**: 自定義控制元件位於螢幕中間,且是動畫預設為:漸隱和縮放,可使用isLoading選擇動畫
**Alignment.bottomCenter、Alignment.bottomLeft、Alignment.bottomRight**:自定義控制元件位於螢幕底部,動畫預設為位移動畫,自下而上,可使用animationDuration設定動畫時間
**Alignment.topCenter、Alignment.topLeft、Alignment.topRight**:自定義控制元件位於螢幕頂部,動畫預設為位移動畫,自上而下,可使用animationDuration設定動畫時間
**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)是否存在在介面上 | - **返回事件,關閉彈窗解決方案** 使用Overlay的依賴庫,基本都存在一個問題,難以對返回事件的監聽,導致觸犯返回事件難以關閉彈窗佈局之類,想了很多辦法,沒辦法在依賴庫中解決該問題,此處提供一個`BaseScaffold`,在每個頁面使用`BaseScaffold`,便能解決返回事件關閉Dialog問題 ```dart 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