前言
弹出层(Popup)一直是各类App中一个重要的交互组成部分,很多时候,一个App中甚至会出现各种形形色色的弹出层。
比如,只有下半部分背景变暗的dropdown list,像这样:
再比如,引导用户操作的操作指引,像这样:
有时候,指引还有可能同时高亮显示多个组件,像这样:
甚至loading,是不是也可以看作是一种弹出层:
那么在Flutter上,能否简单方便的实现一个弹出层呢?答案是肯定的!
Github地址强势插入:https://github.com/BakerJQ/flutter_easy_popup
思路
对于一个弹出层来说,最重要的一个特性是什么?
对!他是弹出来的!
@$@!(一顿暴打...)
额咳。。。听我说完。。。
这也就意味着,它需要覆盖在当前页面之上。那么通过查阅,我们可以发现Flutter提供了两种方式来实现这一效果。
并不合适的方案:Overlay
第一种就是Overlay组件,该组件可以实现将Widget覆盖在所有页面之上。
Overlay有两个特性:
- 跨页面的覆盖,页面的跳转对覆盖层的Widget不会有任何影响
- 不阻挡手势,如果覆盖层没有阻挡手势的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。