彈出層(Popup)一直是各種App中一個重要的交互組成部分,不少時候,一個App中甚至會出現各類形形色色的彈出層。git
好比,只有下半部分背景變暗的dropdown list,像這樣:
github
再好比,引導用戶操做的操做指引,像這樣:
設計模式
有時候,指引還有可能同時高亮顯示多個組件,像這樣:
架構
甚至loading,是否是也能夠看做是一種彈出層:
框架
那麼在Flutter上,可否簡單方便的實現一個彈出層呢?答案是確定的!less
Github地址強勢插入:https://github.com/BakerJQ/flutter_easy_popupide
對於一個彈出層來講,最重要的一個特性是什麼?佈局
對!他是彈出來的!動畫
@$@!(一頓暴打...)ui
額咳。。。聽我說完。。。
這也就意味着,它須要覆蓋在當前頁面之上。那麼經過查閱,咱們能夠發現Flutter提供了兩種方式來實現這一效果。
第一種就是Overlay組件,該組件能夠實現將Widget覆蓋在全部頁面之上。
Overlay有兩個特性:
可是這兩個特性,從某種程度上來講,與咱們通常意義上的彈出層是相悖的。
首先,對於特性1來講,彈出層在通常狀況下,都是與單頁面的業務強相關的,那麼就不該該出現該頁面退出後,彈出層依然存在的狀況。最典型的交互就是,在安卓端按返回鍵後,是將彈出層關閉,而不是返回到上一個頁面可是彈出層依然存在。而因爲Overlay所持有的BuildContext並不包含Navigator,因此沒法對頁面路由的跳轉作任何操做,也沒法接收到安卓端的返回鍵回調。
其次,對於特性2來講,彈出層通常狀況下的交互,都是阻斷當前頁面手勢的。
固然,這兩點均可以經過特殊處理去解決,好比在每一個頁面都包一層WillPopScope去處理安卓端返回的回調,或者在彈出層的Widget外層包一個阻斷全部手勢的Widget。
可是這樣的作法,無疑增長了使用者的負擔,也並不符合單一職責原則。由於對於返回鍵的處理,應該包含在彈出層自己的職責以內的,而不該該由使用的頁面去處理。而對於Overlay來講,更適合的場景應該是須要實現懸浮於整個App之上的交互,例如懸浮快捷操做按鈕之類的。
第二種方案,也是最終選擇的方案,就是PopupRoute了。從它的命名就能夠看出,首先它是一個Popup,其次它是一個Route。這就意味着,它不只能夠覆蓋在當前頁面上,它還接入了Flutter常規的路由體系。
換句話來講,它既然是經過Navigator的push和pop來使用的,那麼對於返回的監聽和阻斷手勢就是它的基本特性了。
而Flutter自帶的一些彈出方法,如showModalBottomSheet、showDialog等,都是經由PopupRoute實現的。
在平常開發工做中,咱們確定會遇到多種方案均可以解決一個問題的狀況,那麼這個時候,更加契合基本設計原則的方案,每每就會是最合適的方案。
如今,咱們來看看如何實現一個可以支持各類形式的Popup。
對於PopupRoute的具體使用,我就不贅述了,網上有太多的使用教程和案例。歸納來講就是繼承PopupRoute,而後實現buildPage方法,return須要彈出的Widget。
咱們主要來關注Popup自己的實現。
首先須要實現的,就是提供彈出層背景可以變暗的能力。
對於這個能力,原本PopupRoute是已經提供了的,那就是重寫相關的方法:
@override Color get barrierColor => Colors.black.withAlpha(127);
這樣就可使Popup彈出的時候,帶上一個半透明的蒙層背景。
可是,這個背景只能是覆蓋全屏的,沒法對此進行覆蓋區域的自定義,所以只能使用另外的方式進行實現。
既然須要自定義變暗的區域,那麼這個區域就只有本身經過Widget去實現,最簡單的方式,天然是經過控制一個背景爲暗色的Container。
所以,咱們定一個基礎的彈出層Widget,並將其做爲Popup的基礎框架:
class _PopRouteWidget extends StatelessWidget{ final Widget child; //Popup彈出的內容Widget final Offset offsetLT, offsetRB; //背景區域範圍的left、top、right、bottom ... @override Widget build(BuildContext context) { return Stack( children: <Widget>[ //經過Padding控制變暗的範圍區域 Padding( padding: EdgeInsets.only( left: widget.offsetLT?.dx ?? 0, top: widget.offsetLT?.dy ?? 0, right: widget.offsetRB?.dx ?? 0, bottom: widget.offsetRB?.dy ?? 0, ), child: Container( color: Colors.black.withAlpha(127), ), ), this.child, ], ); } }
這樣,一個暗色的背景層就完成了。
可是上面所實現的背景蒙層,並不能作到提供高亮區域。從直覺上來看,提供高亮其實就是將蒙層按照須要高亮的區域進行鏤空,讓被矇住的組件可以「透過」蒙層。
而Flutter中,ColorFiltered正好提供了這個功能。
ColorFiltered是一個能夠給全部子組件加上一層顏色濾鏡的組件,而且能夠經過BlendMode設置圖像混合模式,這裏的BlendMode就和Android的PorterDuffXferMode是同樣的。
這方面的知識在此就不細說了,你們能夠很方便的搜索到相關資料。
除了定義可鏤空的蒙層,咱們還須要定義鏤空的具體位置,這裏咱們就經過一個RRect的List去定義須要鏤空的位置。
class _PopRouteWidget extends StatelessWidget{ final Widget child; //Popup彈出的內容Widget final Offset offsetLT, offsetRB; //背景區域範圍的left、top、right、bottom final List<RRect> _highlights = []; ... @override Widget build(BuildContext context) { return Stack( children: <Widget>[ //經過Padding控制變暗的範圍區域 Padding( padding: EdgeInsets.only( left: widget.offsetLT?.dx ?? 0, top: widget.offsetLT?.dy ?? 0, right: widget.offsetRB?.dx ?? 0, bottom: widget.offsetRB?.dy ?? 0, ), //經過ColorFiltered實現變暗蒙層 child: ColorFiltered( colorFilter: ColorFilter.mode( Colors.black.withAlpha(127), BlendMode.srcOut,//暗色蒙層爲src,srcOut即展現蒙層與子組件不相交的部分,效果即爲在蒙層上把子組件部分所有鏤空 ), child: Stack( children: _buildDark(), ), ), ), this.child, ], ); } List<Widget> _buildDark() { List<Widget> widgets = []; //Container用以撐開整個佈局,而透明色不會參與BlendMode做用,以此作到僅僅撐開佈局而不參與圖像混合的效果 widgets.add(Container( color: Colors.transparent, )); //根據RRect區域生成須要鏤空的子組件 for (RRect highlight in _highlights) { widgets.add(Positioned( child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( topLeft: highlight.tlRadius, topRight: highlight.trRadius, bottomLeft: highlight.blRadius, bottomRight: highlight.brRadius, )), width: highlight.width, height: highlight.height, ), left: highlight.left, top: highlight.top, )); } return widgets; } }
至此,一個能夠控制背景變暗區域的Popup就初步完成了。
可是目前的這個Popup,是沒有動畫的,這裏先給蒙層添加一個淡入淡出的動畫,動畫基礎方面的知識我就不介紹了。這裏須要注意一點的是,PopupRoute提供了一個方法去定義動畫時間:
@override Duration get transitionDuration => duration;
經過定義這個get方法,在Popup從Navigator pop的時候,會給你預留出你所定義的時間,這個時間就能夠用來展現動畫。
可是對於Popup具體內容child的動畫,咱們是但願讓用戶本身去定義的。所以咱們提供了一個mixin,該mixin提供一個dismiss接口,傳入的child須要實現這個mixin,而後由用戶本身定義dismiss的動畫或者其餘須要處理的事務。
mixin EasyPopupChild implements Widget { dismiss(); }
爲了更加方便的使用,咱們提供幾個能夠直接調用的靜態方法。
class EasyPopup { ///關閉與當前BuildContext關聯的Popup static pop(BuildContext context) { EasyPopupRoute.pop(context); } ///展現Popup static show( BuildContext context, EasyPopupChild child, { ... }) { Navigator.of(context).push( EasyPopupRoute( child: child, ... ), ); } ///對當前BuildContext關聯的Popup設置高亮 static setHighlights(BuildContext context, List<RRect> highlights) { EasyPopupRoute.setHighlights(context, highlights); } }
至此,一個能夠由用戶自定義各類使用場景的Popup彈出層就完成了。
這裏只是對整個組件的實現思路,作了一個簡單的梳理,其中略過了不少細節。
雖然這只是一個小小的組件,可是在開發過程當中,我也遇到了一些方案抉擇、試錯方面的問題,而這個過程讓我深入的體會到了前人所留下的智慧,就是咱們最大的寶藏。當碰到難以抉擇的設計、架構方面的問題時,每每回過頭看一看基本的設計原則、設計模式,不少問題的答案就天然顯現出來了。
最後再貼一下該組件的Github地址:https://github.com/BakerJQ/flutter_easy_popup
具體的使用方式、參數等,以及動圖裏實現的example都在裏面。歡迎小夥伴們star和提issue。