likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 27

通过这篇文章,你将了解到实现新手引导的两种方式: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信息,然后展示在蒙层上;

再看看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😄!然后自己写的当然更切合业务,并且可维护。就是得自己写轮子咯,所以代码必须开源

写在最后

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