一起养成写作习惯!这是我参加「日新计划 4 月更文挑战」的第2天,点击检查活动详情。
经过这篇文章,你将了解到完成新手引导的两种办法:
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!然后自己写的当然更切合事务,而且可保护。便是得自己写轮子咯,所以
代码有必要开源
。
写在最终
这篇文章比较根底,笔者主要意图是想展现下图画混合做出的风趣作用,的确是个人觉得比较巧的手段。
关于第二种办法的代码,我也就写了上面这些,悉数贴上去了,有需求进一步完善的,欢迎一起评论!