FlutterEasyPopup -- 自定义弹出层,So easy!!!

前言

弹出层(Popup)一直是各类App中一个重要的交互组成部分,很多时候,一个App中甚至会出现各种形形色色的弹出层。

比如,只有下半部分背景变暗的dropdown list,像这样:

再比如,引导用户操作的操作指引,像这样:

有时候,指引还有可能同时高亮显示多个组件,像这样:

甚至loading,是不是也可以看作是一种弹出层:

那么在Flutter上,能否简单方便的实现一个弹出层呢?答案是肯定的!

Github地址强势插入:https://github.com/BakerJQ/flutter_easy_popup

思路

对于一个弹出层来说,最重要的一个特性是什么?

对!他是弹出来的!

@$&#@!(一顿暴打...)

额咳。。。听我说完。。。

这也就意味着,它需要覆盖在当前页面之上。那么通过查阅,我们可以发现Flutter提供了两种方式来实现这一效果。

并不合适的方案:Overlay

第一种就是Overlay组件,该组件可以实现将Widget覆盖在所有页面之上。

Overlay有两个特性:

  1. 跨页面的覆盖,页面的跳转对覆盖层的Widget不会有任何影响
  2. 不阻挡手势,如果覆盖层没有阻挡手势的Widget,手势操作可直接穿过覆盖层直接作用到页面上

但是这两个特性,从某种程度上来说,与我们一般意义上的弹出层是相悖的。

首先,对于特性1来说,弹出层在一般情况下,都是与单页面的业务强相关的,那么就不应该出现该页面退出后,弹出层依然存在的情况。最典型的交互就是,在安卓端按返回键后,是将弹出层关闭,而不是返回到上一个页面但是弹出层依然存在。而由于Overlay所持有的BuildContext并不包含Navigator,所以无法对页面路由的跳转做任何操作,也无法接收到安卓端的返回键回调。

其次,对于特性2来说,弹出层一般情况下的交互,都是阻断当前页面手势的。

当然,这两点都可以通过特殊处理去解决,比如在每个页面都包一层WillPopScope去处理安卓端返回的回调,或者在弹出层的Widget外层包一个阻断所有手势的Widget。

但是这样的做法,无疑增加了使用者的负担,也并不符合单一职责原则。因为对于返回键的处理,应该包含在弹出层本身的职责之内的,而不应该由使用的页面去处理。而对于Overlay来说,更适合的场景应该是需要实现悬浮于整个App之上的交互,例如悬浮快捷操作按钮之类的。

最佳选择:PopupRoute

第二种方案,也是最终选择的方案,就是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。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,470评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,393评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,577评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,176评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,189评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,155评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,041评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,903评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,319评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,539评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,703评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,417评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,013评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,664评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,818评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,711评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,601评论 2 353