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

通过这篇文章,你将了解到实现新手引导的两种方式:
1. 通过GlobalKey获取需高亮控件的Render信息,展示在Overlay蒙层上;
2. 使用BlendMode图像混合模式的方式,把蒙层上特殊颜色的控件过滤掉。

背景

最近准备上线一个新手引导的功能,通过展示蒙层高亮指定控件,引导用户属性App的使用。这种需求其实已经很普遍,pub上的showcaseview已算是成熟方案,但经过查看源码发现其实现并不算太优雅。
于是笔者使用ColorFiltered来过滤颜色这种更加巧妙的方案来实现,故此记录下分享给同学们。

一、showcaseview的实现思路

示例.gif


第一种实现方式是直接使用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

截屏示例

第二种方式是我们自己编写的,主要涉及到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😄!然后自己写的当然更切合业务,并且可维护。就是得自己写轮子咯,所以代码必须开源

写在最后

这篇文章比较基础,笔者主要目的是想展示下图像混合做出的有趣效果,确实是个人觉得比较巧的手段。
关于第二种方式的代码,我也就写了上面这些,全部贴上去了,有需要进一步完善的,欢迎一起讨论!😋

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345

推荐阅读更多精彩内容