通过这篇文章,你将了解到实现新手引导的两种方式:
1. 通过GlobalKey
获取需高亮控件的Render信息,展示在Overlay
蒙层上;
2. 使用BlendMode
图像混合模式的方式,把蒙层上特殊颜色的控件过滤掉。
背景
最近准备上线一个新手引导的功能,通过展示蒙层高亮指定控件
,引导用户属性App的使用。这种需求其实已经很普遍,pub上的showcaseview已算是成熟方案,但经过查看源码发现其实现并不算太优雅。
于是笔者使用ColorFiltered
来过滤颜色这种更加巧妙的方案来实现,故此记录下分享给同学们。
一、showcaseview的实现思路
第一种实现方式是
直接使用showcaseview库
,毕竟自己造的轮子,很容易脱轨~。showcaseview的实现原理非常简单。
- 调用Showcase组件时传入
GlobalKey
和child
;
final GlobalKey _one = GlobalKey();
showcase(
key: _one,
description: 'Tap to see menu options',
child: Icon(
Icons.menu,
color: Theme.of(context).primaryColor,
),
),
- Showcase中的build方法调用了
AnchoredOverlay
控件,而AnchoredOverlay通过展示OverlayEntry
蒙层,通过GlobalKey拿到需要渲染child的size、offset信息
,然后展示在蒙层上;
class AnchoredOverlay extends StatelessWidget {
final bool showOverlay;
final Widget Function(BuildContext, Rect anchorBounds, Offset anchor)?
overlayBuilder;
final Widget? child;
![fb137f774487f113548bd418d4537751.gif](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/cace10c0d34b4a71b14395482ea5ca92~tplv-k3u1fbpfcp-watermark.image?)
AnchoredOverlay({
Key? key,
this.showOverlay = false,
this.overlayBuilder,
this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
/// OverlayBuilder内部就是Overlay,把RenderBox的信息传入
return OverlayBuilder(
showOverlay: showOverlay,
overlayBuilder: (overlayContext) {
// To calculate the "anchor" point we grab the render box of
// our parent Container and then we find the center of that box.
final box = context.findRenderObject() as RenderBox;
final topLeft =
box.size.topLeft(box.localToGlobal(const Offset(0.0, 0.0)));
final bottomRight =
box.size.bottomRight(box.localToGlobal(const Offset(0.0, 0.0)));
Rect anchorBounds;
anchorBounds = (topLeft.dx.isNaN ||
topLeft.dy.isNaN ||
bottomRight.dx.isNaN ||
bottomRight.dy.isNaN)
? Rect.fromLTRB(0.0, 0.0, 0.0, 0.0)
: Rect.fromLTRB(
topLeft.dx,
topLeft.dy,
bottomRight.dx,
bottomRight.dy,
);
final anchorCenter = box.size.center(topLeft);
return overlayBuilder!(overlayContext, anchorBounds, anchorCenter);
},
child: child,
);
},
);
}
}
再看看OverlayBuilder
@override
void initState() {
super.initState();
if (widget.showOverlay) {
WidgetsBinding.instance!.addPostFrameCallback((_) => showOverlay());
}
}
void showOverlay() {
if (_overlayEntry == null) {
// Create the overlay.
_overlayEntry = OverlayEntry(
builder: widget.overlayBuilder!,
);
addToOverlay(_overlayEntry!);
} else {
// Rebuild overlay.
buildOverlay();
}
}
- 组件都展示出来后,再创建指导视图;然后控制蒙层指导的步骤、管理组件的点击交互即可。
Widget buildOverlayOnTarget(
Offset offset,
Size size,
Rect rectBound,
Size screenSize,
) {
var blur = 0.0;
if (_showShowCase) {
blur = widget.blurValue ?? (ShowCaseWidget.of(context)?.blurValue) ?? 0;
}
// Set blur to 0 if application is running on web and
// provided blur is less than 0.
blur = kIsWeb && blur < 0 ? 0 : blur;
return _showShowCase
? Stack(
children: [
GestureDetector(
onTap: _nextIfAny,
child: ClipPath(
clipper: RRectClipper(
area: rectBound,
isCircle: widget.shapeBorder == CircleBorder(),
radius: widget.radius,
overlayPadding: widget.overlayPadding,
),
child: blur != 0
? BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
decoration: BoxDecoration(
color: widget.overlayColor
.withOpacity(widget.overlayOpacity),
),
),
)
: Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
decoration: BoxDecoration(
color: widget.overlayColor
.withOpacity(widget.overlayOpacity),
),
),
),
),
_TargetWidget(
offset: offset,
size: size,
onTap: _getOnTargetTap,
shapeBorder: widget.shapeBorder,
),
ToolTipWidget(
position: position,
offset: offset,
screenSize: screenSize,
title: widget.title,
description: widget.description,
titleTextStyle: widget.titleTextStyle,
descTextStyle: widget.descTextStyle,
container: widget.container,
tooltipColor: widget.showcaseBackgroundColor,
textColor: widget.textColor,
showArrow: widget.showArrow,
contentHeight: widget.height,
contentWidth: widget.width,
onTooltipTap: _getOnTooltipTap,
contentPadding: widget.contentPadding,
disableAnimation: widget.disableAnimation,
animationDuration: widget.animationDuration,
),
],
)
: SizedBox.shrink();
}
- 重点来了,如何确定当前高亮的控件以及控制高亮控件的步骤流转?答案是:熟悉的
InheritedWidget
,Flutter提供的原始状态管理widget。
通过继承自InheritedWidget
的_InheritedShowCaseView
控件来管理当前步骤activeStep
。
当key被激活时,展示蒙层,通过GlobalKey
的渲染信息在OverlayEntry
上再绘制传入的child,如果未被激活,就直接展示child。
/// 判断是否激活,来确定要不要显示蒙层
///
void showOverlay() {
final activeStep = ShowCaseWidget.activeTargetWidget(context);
setState(() {
_showShowCase = activeStep == widget.key;
});
if (activeStep == widget.key) {
if (ShowCaseWidget.of(context)!.autoPlay) {
timer = Timer(
Duration(
seconds: ShowCaseWidget.of(context)!.autoPlayDelay.inSeconds),
_nextIfAny);
}
}
}
Widget buildOverlayOnTarget(
Offset offset,
Size size,
Rect rectBound,
Size screenSize,
) {
var blur = 0.0;
if (_showShowCase) {
blur = widget.blurValue ?? (ShowCaseWidget.of(context)?.blurValue) ?? 0;
}
// Set blur to 0 if application is running on web and
// provided blur is less than 0.
blur = kIsWeb && blur < 0 ? 0 : blur;
return _showShowCase
? Stack(
children: [
/// 省略引导视图 .......
],
)
: SizedBox.shrink(); // 未激活直接返回sizedBox,即overlay为空
}
}
纯Flutter的代码
,很清晰。但我们分析完发现这个方案存在两个问题:
- 通过
InheritedWidget
来管理key,激活需要高亮的控件,这使得一次最多只能高亮一个key;另外这种方式使得代码很不简洁,你必须不断在布局里面嵌套Showcase(key: _xxx, child: xxx)
; -
高亮的控件必定渲染两次
,overlayEntry上多绘制了一次,再步骤来回切换的过程就会涉及到overlayEntry上控件的再次构建。
总的来说,这个库虽存在问题,但肯定满足业务需求,实现方式也尚可,毕竟评分已经是pub同类组件最高。
二、巧妙的ColorFiter
第二种方式是我们自己编写的,主要涉及到BlendMode
图像混合模式,对特定颜色进行滤色,即可实现高亮效果。
- 简单说下Flutter的
BlendMode
,这里涉及到两个对象:源图像和目标图像; - 通过
BlendMode
的各种模式,将原图像和目标图像进行混合; - 如:源图像是蒙层【黑色】,我们把模式设置为srcOut【显示源和目标的不重合部分】;目标图像是高亮控件的位置【白色】,模式是dstOut【显示目标和源不重合的部分】。
这样对于源,黑色和白色重合的地方会不显示;而对于目标,白色和黑色完全重合也不显示,自然重合部分就镂空了。
实现逻辑(纯demo)
- 首先我把展示蒙层弹框抽象成一个工具类,业务端需要弹出直接调用方法即可;
class MaskGuide {
final MaskController controller;
late OverlayEntry overlayEntry;
MaskGuide(this.controller);
/// 展示蒙层的方法
/// [Params] 上下文对象、需要展示的控件的keys
showMaskGuide(BuildContext context, List<GlobalKey> keys) {
overlayEntry = OverlayEntry(
builder: (context) => MaskGuideWidget(
controller: controller,
keys: keys,
doneCallBack: () {
overlayEntry.remove();
},
),
);
Overlay.of(context)?.insert(overlayEntry);
}
}
- 既然是工具类,那业务端必须对蒙层可控,因此需要提供控制器给业务端。由于属于跨组件通信,我们直接采用stream来实现控制
class MaskController {
StreamController<int> controller = StreamController();
Stream<int> get stream => controller.stream;
void nextStep(int step) {
controller.sink.add(step);
}
/// 关闭stream流
closed() {
controller.close();
}
}
- 如何使用蒙层进行过滤,以达到高亮的效果
class MaskGuideWidget extends StatefulWidget {
const MaskGuideWidget(
{Key? key,
required this.controller,
required this.keys,
this.doneCallBack})
: super(key: key);
final MaskController controller;
final List<GlobalKey> keys;
final Function? doneCallBack;
@override
_MaskGuideWidgetState createState() => _MaskGuideWidgetState();
}
class _MaskGuideWidgetState extends State<MaskGuideWidget> {
int currentStep = 0;
@override
Widget build(BuildContext context) {
return Stack(
children: [
GestureDetector(
onTap: () {
if (currentStep >= widget.keys.length - 1) {
widget.doneCallBack?.call();
return;
}
currentStep++;
widget.controller.nextStep(currentStep);
},
child: ColorFiltered(
// 源图像,使用srcOut
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(.8),
BlendMode.srcOut,
),
child: Stack(
children: [
// 目标图像
Container(
decoration: const BoxDecoration(
color: Colors.white,
backgroundBlendMode: BlendMode.dstOut,
),
),
StreamBuilder<int>(
initialData: 0,
stream: widget.controller.stream,
builder: (context, snapshot) {
RenderBox renderBox = widget
.keys[snapshot.data!].currentContext
?.findRenderObject() as RenderBox;
return Positioned(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(
Radius.circular(renderBox.size.width),
),
),
width: renderBox.size.width,
height: renderBox.size.height,
),
left: renderBox.localToGlobal(Offset.zero).dx,
top: renderBox.localToGlobal(Offset.zero).dy,
);
}),
],
),
),
),
// 这里同样通过key可以拿到位置信息,然后显示步骤描述即可
Positioned(child: SizedBox(),),
],
);
}
}
- 业务端调用
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
final MaskController controller = MaskController();
late MaskGuide maskGuide;
final GlobalKey _one = GlobalKey();
final GlobalKey _two = GlobalKey();
final GlobalKey _three = GlobalKey();
@override
void initState() {
super.initState();
maskGuide = MaskGuide(controller);
WidgetsBinding.instance!.addPostFrameCallback(
(_) => maskGuide.showMaskGuide(context, [_one, _two, _three]),
);
}
- 继续优化的方向:由于时间真的非常有限,所以这个只是我花了1h写出来的demo,根本不具备作为一个pub的能力。这个代码需要优化的地方如下:
- 引导描述没有写,这个描述控件也是需要调用方可配置的;
- controller提供的能力还不够,至少需要
进入某一步、关闭蒙层、上一步/下一步
等一系列方法; - 蒙层每一步需要提供回调给调用方,pub默认进入下一步,但业务端有特殊操作,直接call,这时业务端完全可以通过controller的能力对蒙层进行操作;
- 需要继续扩展,满足调用方直接通过蒙层来做跨页面流程引导的需求。此时
key就不应该一次传入,而是由业务端随时传,随时切步骤.
综合对比
- 性能对比:差别其实不大,showcaseview多渲染了一次控件,但
ColorFiter也多了图像混合的计算
。但假设高亮的控件过于复杂,那第一种方式创建两次组件确实会让性能打一些折扣。
- 可维护度:笔者认为第二种会更加切合开发者的使用习惯,状态管理起来更加方便,毕竟stream可是神器,比如:EvenBus😄!然后自己写的当然更切合业务,并且可维护。就是得自己写轮子咯,所以
代码必须开源
。
写在最后
这篇文章比较基础,笔者主要目的是想展示下图像混合做出的有趣效果,确实是个人觉得比较巧的手段。
关于第二种方式的代码,我也就写了上面这些,全部贴上去了,有需要进一步完善的,欢迎一起讨论!
😋