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: [
        //通过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 _highlights = [];
  ...
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        //通过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 _buildDark() {
    List 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 highlights) {
    EasyPopupRoute.setHighlights(context, highlights);
  }
}

至此,一个可以由用户自定义各种使用场景的Popup弹出层就完成了。

结语

这里只是对整个组件的实现思路,做了一个简单的梳理,其中略过了很多细节。

虽然这只是一个小小的组件,但是在开发过程中,我也遇到了一些方案抉择、试错方面的问题,而这个过程让我深刻的体会到了前人所留下的智慧,就是我们最大的宝藏。当碰到难以抉择的设计、架构方面的问题时,往往回过头看一看基本的设计原则、设计模式,很多问题的答案就自然显现出来了。

最后再贴一下该组件的Github地址:https://github.com/BakerJQ/flutter_easy_popup

具体的使用方式、参数等,以及动图里实现的example都在里面。欢迎小伙伴们star和提issue。

你可能感兴趣的:(FlutterEasyPopup -- 自定义弹出层,So easy!!!)