為了應對復雜的業務場景,同時降低侵入性,在保持api穩定基礎上,全面重構了SmartDialog底層
我現在可以自信的說:它現在是一個簡潔,強大,侵入性極低的Pub包
請使用Flutter 2.0及其以上的小伙伴們移步:這一次,解決Flutter Dialog的各種痛點!
前言
系統自帶的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包,基本該解決的痛點都已解決了,用於實際業務沒什么問題
效果
引入
- Pub:查看flutter_smart_dialog插件版本
- 自2.0版本開始,本庫已適配空安全
dependencies:
flutter_smart_dialog: any
- 注意:該庫已遷移空安全,注意版本區分
# 非空安全前最后一個穩定版本
dependencies:
flutter_smart_dialog: ^1.3.1
使用
- 主入口配置
- 在主入口這地方需要配置下,這樣就可以不傳BuildContext使用Dialog了
- 只需要在MaterialApp的builder參數處配置下即可
void main() {
runApp(MyApp());
}
///flutter 2.0
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Container(),
builder: (BuildContext context, Widget? child) {
return FlutterSmartDialog(child: child);
},
);
}
}
///flutter 1.x
class 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做了一些優化
- msg:必傳參數
- time:可選,Duration類型,默認2000ms
- widget:可選,可以自定義toast
- alignment:可選,控制toast位置
- 如果想使用花里胡哨的Toast效果,請使用showToast方法定制就行了,炒雞簡單喔,懶得自己寫的,抄下我的ToastWidget,改下屬性就行了哈
SmartDialog.showToast('test toast');
- 使用Loading:loading擁有諸多設置屬性,參照下方的
SmartDialog配置參數說明
即可- msg:可選,loading動畫下面的文字信息(默認:加載中...)
//open loading
SmartDialog.showLoading();
//delay off
await Future.delayed(Duration(seconds: 2));
SmartDialog.dismiss();
- 自定義dialog
- 使用SmartDialog.show()方法即可,里面含有眾多
Temp
為后綴的參數,和下述無Temp
為后綴的參數功能一致 - 特殊屬性
isUseExtraWidget
:是否使用額外覆蓋浮層,可與主浮層獨立開;可與loading,dialog之類獨立開,自帶的showToast便是開啟了該配置,可與loading共存
- 使用SmartDialog.show()方法即可,里面含有眾多
SmartDialog.show(
alignmentTemp: Alignment.bottomCenter,
clickBgDismissTemp: true,
widget: Container(
color: Colors.blue,
height: 300,
),
);
- SmartDialog配置參數說明
- 為了避免
instance
里面暴露過多屬性,導致使用不便,此處諸多參數使用instance
中的config
屬性管理 - 使用config設置的屬性都是全局的,將這些屬性單獨使用Config管理,是為了方便修改和管理這些屬性,也是為了使SmartDialog類更易維護
- 為了避免
參數 | 功能說明 |
---|---|
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 | 默認:true;點擊遮罩,是否關閉dialog---true:點擊遮罩關閉dialog,false:不關閉 |
maskColor | 遮罩顏色(isPenetrate為true,該參數失效) |
maskWidget | 可高度自定義遮罩樣式,使用該參數,maskColor失效(isPenetrate為true,該參數失效) |
animationDuration | 動畫時間 |
isUseAnimation | 默認:true;是否使用動畫 |
isLoading | 默認:true;是否使用Loading動畫;true:內容體使用漸隱動畫 false:內容體使用縮放動畫,僅僅針對中間位置的dialog |
isExist | 狀態標定:loading和自定義dialog 是否存在在界面上 |
isExistMain | 狀態標定:自定義dialog 是否存在在界面上(show) |
isExistLoading | 狀態標定:loading是否存在界面上(showLoading) |
isExistToast | 狀態標定:toast是否存在在界面上(showToast) |
- 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,
}) : super(key: key);
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;
//custom param
final bool isTwiceBack;
final bool isCanBack;
final ScaffoldParamVoidCallback? onBack;
@override
_BaseScaffoldState createState() => _BaseScaffoldState();
}
class _BaseScaffoldState extends State<BaseScaffold> {
DateTime? _lastTime;
@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 {
widget.onBack?.call();
if (SmartDialog.instance.config.isExist) {
SmartDialog.dismiss();
return false;
}
if (!widget.isCanBack) {
return false;
}
var now = DateTime.now();
var condition =
_lastTime == null || now.difference(_lastTime!) > Duration(seconds: 1);
if (widget.isTwiceBack && condition) {
_lastTime = now;
SmartDialog.showToast("再點一次退出");
return false;
}
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
本身無法消費觸摸事件,又由於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
控件,我這里設置了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提供
OverlayEntry
和OverlayEntryExtra
可以高度自定義,相關實現,可查看內部實現 -
FlutterSmartDialog內部已進行相關實現,使用
show()
方法中的isUseExtraWidget
區分
最后
這個庫花了一些時間去構思和實現,算是解決幾個很大的痛點
- 如果大家對
返回事件
有什么好的處理思路,麻煩在評論里告知,謝謝!
項目地址
FlutterSmartDialog一些信息
-
Github:flutter_smart_dialog
-
使用效果體驗:點擊體驗一下
系列文章
狀態管理