弹出层(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。