一起养成写作习惯!这是我参加「日新计划 4 月更文挑战」的第2天,点击检查活动详情。

经过这篇文章,你将了解到完成新手引导的两种办法:
1. 经过GlobalKey获取需高亮控件的Render信息,展现在Overlay蒙层上;
2. 运用BlendMode图画混合形式的办法,把蒙层上特别色彩的控件过滤掉。

背景

最近预备上线一个新手引导的功能,经过展现蒙层高亮指定控件,引导用户属性App的运用。这种需求其实已经很普遍,pub上的showcaseview已算是成熟计划,但经过检查源码发现其完成并不算太高雅。
于是笔者运用ColorFiltered来过滤色彩这种愈加奇妙的计划来完成,故此记录下分享给同学们。

一、showcaseview的完成思路

Flutter实现新手引导蒙层的两种方式

第一种完成办法是直接运用showcaseview库,究竟自己造的轮子,很容易脱轨~。showcaseview的完成原理十分简单。

  • 调用Showcase组件时传入GlobalKeychild
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的代码,很清晰。但咱们剖析完发现这个计划存在两个问题:

  1. 经过InheritedWidget来办理key,激活需求高亮的控件,这使得一次最多只能高亮一个key;别的这种办法使得代码很不简练,你有必要不断在布局里边嵌套Showcase(key: _xxx, child: xxx)
  2. 高亮的控件必定烘托两次,overlayEntry上多制作了一次,再过程来回切换的过程就会涉及到overlayEntry上控件的再次构建。

总的来说,这个库虽存在问题,但肯定满意事务需求,完成办法也尚可,究竟评分已经是pub同类组件最高。

二、奇妙的ColorFiter

Flutter实现新手引导蒙层的两种方式
Flutter实现新手引导蒙层的两种方式
Flutter实现新手引导蒙层的两种方式
第二种办法是咱们自己编写的,主要涉及到BlendMode图画混合形式,对特定色彩进行滤色,即可完成高亮作用。

  • 简单说下Flutter的BlendMode,这儿涉及到两个目标:源图画和方针图画;
  • 经过BlendMode的各种形式,将原图画和方针图画进行混合;
  • 如:源图画是蒙层【黑色】,咱们把形式设置为srcOut【显现源和方针的不重合部分】;方针图画是高亮控件的方位【白色】,形式是dstOut【显现方针和源不重合的部分】。这样关于源,黑色和白色重合的当地会不显现;而关于方针,白色和黑色完全重合也不显现,天然重合部分就镂空了。

完成逻辑(纯demo)

  1. 首要我把展现蒙层弹框抽象成一个东西类,事务端需求弹出直接调用办法即可;
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);
  }
}
  1. 既然是东西类,那事务端有必要对蒙层可控,因而需求供给操控器给事务端。因为归于跨组件通信,咱们直接选用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();
  }
}
  1. 怎么运用蒙层进行过滤,以到达高亮的作用
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(),),
      ],
    );
  }
}
  1. 事务端调用
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]),
    );
  }
  1. 持续优化的方向:因为时间真的十分有限,所以这个仅仅我花了1h写出来的demo,底子不具备作为一个pub的才能。这个代码需求优化的当地如下:
  • 引导描绘没有写,这个描绘控件也是需求调用方可配置的;
  • controller供给的才能还不行,至少需求进入某一步、封闭蒙层、上一步/下一步等一系列办法;
  • 蒙层每一步需求供给回调给调用方,pub默许进入下一步,但事务端有特别操作,直接call,这时事务端完全能够经过controller的才能对蒙层进行操作;
  • 需求持续扩展,满意调用方直接经过蒙层来做跨页面流程引导的需求。此时key就不应该一次传入,而是由事务端随时传,随时切过程.

归纳比照

  1. 功能比照:差别其实不大,showcaseview多烘托了一次控件,但ColorFiter也多了图画混合的核算。但假设高亮的控件过于复杂,那第一种办法创建两次组件的确会让功能打一些扣头。
  2. 可保护度:笔者认为第二种会愈加切合开发者的运用习惯,状态办理起来愈加便利,究竟stream可是神器,比方:EvenBus!然后自己写的当然更切合事务,而且可保护。便是得自己写轮子咯,所以代码有必要开源

写在最终

这篇文章比较根底,笔者主要意图是想展现下图画混合做出的风趣作用,的确是个人觉得比较巧的手段。
关于第二种办法的代码,我也就写了上面这些,悉数贴上去了,有需求进一步完善的,欢迎一起评论!