FlutterEasyPopup -- 自定義彈出層,So easy!!!

1. 前言

彈出層(Popup)一直是各種App中一個重要的交互組成部分,不少時候,一個App中甚至會出現各類形形色色的彈出層。git

好比,只有下半部分背景變暗的dropdown list,像這樣:
github

再好比,引導用戶操做的操做指引,像這樣:
image設計模式

有時候,指引還有可能同時高亮顯示多個組件,像這樣:
image架構

甚至loading,是否是也能夠看做是一種彈出層:
image框架

那麼在Flutter上,可否簡單方便的實現一個彈出層呢?答案是確定的!less

Github地址強勢插入:https://github.com/BakerJQ/flutter_easy_popupide

2. 思路

對於一個彈出層來講,最重要的一個特性是什麼?佈局

對!他是彈出來的!動畫

@$@!(一頓暴打...)ui

額咳。。。聽我說完。。。

這也就意味着,它須要覆蓋在當前頁面之上。那麼經過查閱,咱們能夠發現Flutter提供了兩種方式來實現這一效果。

2.1 並不合適的方案:Overlay

第一種就是Overlay組件,該組件能夠實現將Widget覆蓋在全部頁面之上。

Overlay有兩個特性:

  1. 跨頁面的覆蓋,頁面的跳轉對覆蓋層的Widget不會有任何影響
  2. 不阻擋手勢,若是覆蓋層沒有阻擋手勢的Widget,手勢操做可直接穿過覆蓋層直接做用到頁面上

可是這兩個特性,從某種程度上來講,與咱們通常意義上的彈出層是相悖的。

首先,對於特性1來講,彈出層在通常狀況下,都是與單頁面的業務強相關的,那麼就不該該出現該頁面退出後,彈出層依然存在的狀況。最典型的交互就是,在安卓端按返回鍵後,是將彈出層關閉,而不是返回到上一個頁面可是彈出層依然存在。而因爲Overlay所持有的BuildContext並不包含Navigator,因此沒法對頁面路由的跳轉作任何操做,也沒法接收到安卓端的返回鍵回調。

其次,對於特性2來講,彈出層通常狀況下的交互,都是阻斷當前頁面手勢的。

固然,這兩點均可以經過特殊處理去解決,好比在每一個頁面都包一層WillPopScope去處理安卓端返回的回調,或者在彈出層的Widget外層包一個阻斷全部手勢的Widget。

可是這樣的作法,無疑增長了使用者的負擔,也並不符合單一職責原則。由於對於返回鍵的處理,應該包含在彈出層自己的職責以內的,而不該該由使用的頁面去處理。而對於Overlay來講,更適合的場景應該是須要實現懸浮於整個App之上的交互,例如懸浮快捷操做按鈕之類的。

2.2 最佳選擇:PopupRoute

第二種方案,也是最終選擇的方案,就是PopupRoute了。從它的命名就能夠看出,首先它是一個Popup,其次它是一個Route。這就意味着,它不只能夠覆蓋在當前頁面上,它還接入了Flutter常規的路由體系。

換句話來講,它既然是經過Navigator的push和pop來使用的,那麼對於返回的監聽和阻斷手勢就是它的基本特性了。

而Flutter自帶的一些彈出方法,如showModalBottomSheet、showDialog等,都是經由PopupRoute實現的。

在平常開發工做中,咱們確定會遇到多種方案均可以解決一個問題的狀況,那麼這個時候,更加契合基本設計原則的方案,每每就會是最合適的方案。

3. 實現

如今,咱們來看看如何實現一個可以支持各類形式的Popup。

對於PopupRoute的具體使用,我就不贅述了,網上有太多的使用教程和案例。歸納來講就是繼承PopupRoute,而後實現buildPage方法,return須要彈出的Widget。

咱們主要來關注Popup自己的實現。

3.1 背景變暗

首先須要實現的,就是提供彈出層背景可以變暗的能力。

對於這個能力,原本PopupRoute是已經提供了的,那就是重寫相關的方法:

@override
Color get barrierColor => Colors.black.withAlpha(127);

這樣就可使Popup彈出的時候,帶上一個半透明的蒙層背景。

可是,這個背景只能是覆蓋全屏的,沒法對此進行覆蓋區域的自定義,所以只能使用另外的方式進行實現。

3.2 自定義變暗區域

既然須要自定義變暗的區域,那麼這個區域就只有本身經過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,
      ],
    );
  }
}

這樣,一個暗色的背景層就完成了。

3.3 提供高亮區域

可是上面所實現的背景蒙層,並不能作到提供高亮區域。從直覺上來看,提供高亮其實就是將蒙層按照須要高亮的區域進行鏤空,讓被矇住的組件可以「透過」蒙層。

而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就初步完成了。

3.4 展現動畫

可是目前的這個Popup,是沒有動畫的,這裏先給蒙層添加一個淡入淡出的動畫,動畫基礎方面的知識我就不介紹了。這裏須要注意一點的是,PopupRoute提供了一個方法去定義動畫時間:

@override
Duration get transitionDuration => duration;

經過定義這個get方法,在Popup從Navigator pop的時候,會給你預留出你所定義的時間,這個時間就能夠用來展現動畫。

可是對於Popup具體內容child的動畫,咱們是但願讓用戶本身去定義的。所以咱們提供了一個mixin,該mixin提供一個dismiss接口,傳入的child須要實現這個mixin,而後由用戶本身定義dismiss的動畫或者其餘須要處理的事務。

mixin EasyPopupChild implements Widget {
  dismiss();
}

3.5 簡化使用方式

爲了更加方便的使用,咱們提供幾個能夠直接調用的靜態方法。

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彈出層就完成了。

4. 結語

這裏只是對整個組件的實現思路,作了一個簡單的梳理,其中略過了不少細節。

雖然這只是一個小小的組件,可是在開發過程當中,我也遇到了一些方案抉擇、試錯方面的問題,而這個過程讓我深入的體會到了前人所留下的智慧,就是咱們最大的寶藏。當碰到難以抉擇的設計、架構方面的問題時,每每回過頭看一看基本的設計原則、設計模式,不少問題的答案就天然顯現出來了。

最後再貼一下該組件的Github地址:https://github.com/BakerJQ/flutter_easy_popup

具體的使用方式、參數等,以及動圖裏實現的example都在裏面。歡迎小夥伴們star和提issue。

相關文章
相關標籤/搜索