Flutter实现蒙版式引导

1,510 阅读10分钟

在很多APP中都会出现第一次打开某些页面时,会把整个页面都蒙上一层蒙版只突出显示重点介绍的内容。在Android原生中的实现基本上都是在根view上添加一层引导的layout,在这个layout上添加和删除引导的view,知道完成之后在移除这个layout,这样对原有页面没有任何影响。基于flutter的特殊性想采用这种方式显然不太合适。这里我们采用另一种方式,在需要引导的页面push一个新的widget,这个widget负责实现引导类似于上面我们介绍的layout。我们先来看看本文要实现的效果。

原始页面

image.png

引导页1

image.png

引导页2

image.png

引导页3

image.png

引导页4

image.png

前置知识

在正式开始之前我们还有几个小问题需要解决:由于是采用push新的widget,我们必须让其透明;新的widget的push时机。

-透明:其实系统已经给我们提供好了,我们在构建蒙版layout(GuideLayout)时,在他的build方法中只需返回一个Material组件即可,当然需要配置一个属性type;比如我们这里的,

  @override
  Widget build(BuildContext context) {
    Size screenSize = MediaQuery.of(context).size;
    return Material(
      color: Color(0x00ffffff),
      type: MaterialType.transparency,
      child: ...
  }
 }

之所以配置这个属性是因为在Material的介绍中注释已经说明了。 To create a transparent piece of material, use [MaterialType.transparency].

-时机:如果是需要网络请求后显示,肯定是网络请求回来直接push。但如果是静态页的话,我们通常会在initState中注册监听器实现。flutter中为我们提供了监听器。这些监听器都在WidgetsBinding类中。他们可以监听当前页面处于前台还是后台,绘制完成的监听。这里我们采用绘制完成的监听。

WidgetsBinding.instance.addPostFrameCallback();

这个方法会在当前帧结束之后回调,而且只会回调一次。如果熟悉Android,那么他就类似于view.post。至此前置工作完毕,正式开始弹出页面的逻辑。

效果分析及准备

在开始之前我们先看看需要哪些东西能实现这个效果。首先弹出页面整个有蒙层,只有需要引导的部分没有蒙层,而且需要引导的部分没蒙层的形状为矩形,圆角矩形,椭圆,圆形。其次需要引导的widget附近有用于说明的widget。最后每次只显示一个需要引导的widget,最后一个显示完成之后pop这个widget。对于这三大部分内容其实最为关键的还是在蒙版上“挖孔”,也就是在蒙版上扣除特定区域,这就需要用到曲线的拟合。其实这个实现在之前的Flutter自定义view的实现中有所介绍,只不过没有深入。这里重点介绍一下。

准备之曲线拟合

曲线拟合我们采用的是Path result = Path.combine(PathOperation.difference, path1, path2);这里最重要的是第一个参数,他决定了最终曲线保留两条曲线的区域,一共有五个值,下面采用示意图来表示每种值的拟合效果

image.png

这里的path1是一个圆形,path2是一个长方形。要想实现蒙版“挖孔”效果,其实path1可以是全屏的矩形,画笔颜色为蒙版颜色,需要挖孔的区域为path2,对比几种拟合效果,曲线拟合的第一个参数应该是PathOperation.difference。至此蒙版挖孔也实现了。

准备之引导数据类

对比引导的效果我们不难看出,要想添加引导需要的东西主要有,

  • “挖孔”的区域以及挖孔的位置:childSize表示大小,Offset表示距离屏幕左侧及上册的偏移量。
  • ”说明文本”以及位置:之所以是带引号的说明文本,是因为这里的说明不仅仅是一段文字,有可能还配有图片,所以说明文本定义为一个Widget。
  • 点击的回调:对于点了某一个引导往往需要存储相关数据,因此需要将点击事件传递出去,因为是与单个引导绑定我们只需传递一个function即可,所以采用系统定义好的typedef GestureTapCallback = void Function();
  • 点击组件可关闭:引导页往往是点击任何位置都可关闭,但有些时候我们希望用户只能点击“引导区域“才可关闭,所以我们需要定义一个bool值来表示,默认是点击整个区域关闭。
  • 引导区域的形状:通过开篇的4张引导图可以看出,引导区域有四种类型,所以我们可以定义一个枚举来表示
enum ChildShape {
  CIRCLE, //圆形
  RECTANGLE, //矩形
  OVAL, //椭圆
  ROUND_RECTANGLE //圆角矩形
}

数据类完整定义如下

class GuideChild {
  //突出显示的widget的大小
  Size childSize;

  //突出显示widget的位置(偏移量)
  Offset offset;

  //突出显示widget的形状
  ChildShape childShape = ChildShape.RECTANGLE;

  //用于解释说明突出显示widget的组件
  Widget descWidget;

  //用于解释说明突出显示widget的组件位置
  Offset descOffset;

  //点击组件的回调
  GestureTapCallback callback;

  //仅点击组件可关闭
  bool closeByClickChild = false;

  double padding = 5;
}

准备工作至此结束。

实现

页面背景,既然我们采用曲线拟合的方式来画,所以我们采用CustomPainter来绘制背景。因为是整个蒙上一层,所以Material的child就应该是CustomPaint,又因为整个页面都可点击,所以Material的child应该是GestureDetector,而CustomPaint则作为GestureDetector的child。这个CustomPaint的大小应该是整个屏幕,所以需要展示的”说明文本“就可以是他的child。因为我们给定了child的位置,所以CustomPaint的child应该是一个Stack+Positioned组件,从而可以按指定位置显示。所以build代码为

@override
  Widget build(BuildContext context) {
    Size screenSize = MediaQuery.of(context).size;
    return Material(
      color: Color(0x00ffffff),
      type: MaterialType.transparency,
      child: GestureDetector(
        onTapUp: tapUp,
        child: CustomPaint(
          size: screenSize,
          painter: BgPainter(
              offset: widget.children.first.offset,
              childSize: widget.children.first.childSize,
              shape: widget.children.first.childShape,
              padding: widget.children.first.padding),
          child: Stack(
            children: [
              Positioned(
                child: widget.children.first.descWidget,
                left: widget.children.first.descOffset.dx,
                top: widget.children.first.descOffset.dy,
              )
            ],
          ),
        ),
      ),
    );
  }

下面来分析一下大框的实现。在GestureDetector中我们监听了onTapUp事件,主要是因为在这个方法中我们可以拿到手指抬起时的位置,从而当要求仅可点击”引导区域”关闭时我们判断点击位置。点击的逻辑如下

  void tapUp(TapUpDetails details) {
    print("tapUp==>>${details.globalPosition}");
    if (widget.children.first.closeByClickChild) {
      Path path = new Path();
      path.addRect(Rect.fromLTWH(
          widget.children.first.offset.dx,
          widget.children.first.offset.dy,
          widget.children.first.childSize.width,
          widget.children.first.childSize.height));
      if (!path.contains(details.globalPosition)) return;
    }

    widget.children.first.callback?.call();
    widget.children.removeAt(0);
    if (widget.children.length == 0) {
      widget.onCompete?.call();
      Navigator.of(context).pop();
    } else {
      setState(() {});
    }

    print("length==>>${widget.children.length}");
  }
}

当设置了仅可点击“引导区域“关闭时,我们会判断是否在区域中页面。其他情况我们会回调传递过来的点击回调,并且让当前页面显示下一个需要显示的引导。如果没有需要显示的引导就关闭这个GuideLayout。(这里只判断了矩形区域的点击,感兴趣的可以补充一下其他几种样式的区域)剩下的就是背景的绘制

class BgPainter extends CustomPainter {
  Offset offset;
  Size childSize;

  Path path1;
  Path path2;
  Paint _paint;

  ChildShape shape;
  double padding;

  BgPainter({this.offset, this.childSize, this.shape, this.padding}) {
    path1 = Path();
    path2 = Path();
    _paint = Paint()
      ..color = Color(0x90000000)
      ..style = PaintingStyle.fill
      ..isAntiAlias = true;
  }

  @override
  void paint(Canvas canvas, Size size) {
    path1.reset();
    path2.reset();

    path1.addRect(Rect.fromLTWH(0, 0, size.width, size.height));

    switch (shape) {
      case ChildShape.RECTANGLE:
        path2.addRect(Rect.fromLTWH(offset.dx - padding, offset.dy - padding,
            childSize.width + padding * 2, childSize.height + padding * 2));
        break;
      case ChildShape.CIRCLE:
        double length;
        double left;
        double top;
        double radius = sqrt(childSize.width * childSize.width +
            childSize.height * childSize.height);
        length = radius + padding * 2;
        left = offset.dx - (radius - childSize.width) / 2 - padding;
        top = offset.dy - (radius - childSize.height) / 2 - padding;
        path2.addOval(Rect.fromLTWH(left, top, length, length));

        break;
      case ChildShape.OVAL:
        double length;
        double left;
        double top;
        double radius = sqrt(childSize.width * childSize.width +
            childSize.height * childSize.height);
        length = radius + padding * 2;
        left =
            offset.dx - (radius + padding * 4 - childSize.width) / 2 - padding;
        top = offset.dy - (radius - childSize.height) / 2 - padding;
        path2.addOval(Rect.fromLTWH(
            left, top, length + padding * 6, length + padding * 2));
        break;
      case ChildShape.ROUND_RECTANGLE:
        path2.addRRect(RRect.fromRectXY(
            Rect.fromLTWH(offset.dx - padding, offset.dy - padding, childSize.width + padding * 2, childSize.height + padding * 2), padding * 2, padding * 2));
        break;
    }

    Path result = Path.combine(PathOperation.difference, path1, path2);

    canvas.drawPath(result, _paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}

其实重点就是通过path来绘制圆形,椭圆和圆角矩形。绘制的内容大致结束了。那么这个layout还要给外部提供调用,这里采用静态方法


  static void showGuide(BuildContext context, List<GuideChild> children,
      GestureTapCallback onComplete) {
    Navigator.of(context).push(PageRouteBuilder(
        pageBuilder: (context, animation, secAnim) {
          return FadeTransition(
            ///渐变过渡 0.0-1.0
            opacity: Tween(begin: 0.0, end: 1.0).animate(
              CurvedAnimation(
                ///动画样式
                parent: animation,

                ///动画曲线
                curve: Curves.fastOutSlowIn,
              ),
            ),
            child: GuideLayout(
              children,
              onCompete: onComplete,
            ),
          );
        },
        opaque: false));
  }
}

之所以需要context则是因为他需要压栈,children则是需要引导的所有,onComplete则是所有引导都完事时候提供给调用者的回调。至此整个定义完成,完整代码

import 'dart:math';

import 'package:flutter/material.dart';

enum ChildShape {
  CIRCLE, //圆形
  RECTANGLE, //矩形
  OVAL, //椭圆
  ROUND_RECTANGLE //圆角矩形
}

class GuideChild {
  //突出显示的widget的大小
  Size childSize;

  //突出显示widget的位置(偏移量)
  Offset offset;

  //突出显示widget的形状
  ChildShape childShape = ChildShape.RECTANGLE;

  //用于解释说明突出显示widget的组件
  Widget descWidget;

  //用于解释说明突出显示widget的组件位置
  Offset descOffset;

  //点击组件的回调
  GestureTapCallback callback;

  //仅点击组件可关闭
  bool closeByClickChild = false;

  double padding = 5;
}

class GuideLayout extends StatefulWidget {
  final List<GuideChild> children;
  final GestureTapCallback onCompete;

  GuideLayout(this.children, {this.onCompete});

  @override
  State<StatefulWidget> createState() {
    return GuideLayoutState();
  }

  static void showGuide(BuildContext context, List<GuideChild> children,
      GestureTapCallback onComplete) {
    Navigator.of(context).push(PageRouteBuilder(
        pageBuilder: (context, animation, secAnim) {
          return FadeTransition(
            ///渐变过渡 0.0-1.0
            opacity: Tween(begin: 0.0, end: 1.0).animate(
              CurvedAnimation(
                ///动画样式
                parent: animation,

                ///动画曲线
                curve: Curves.fastOutSlowIn,
              ),
            ),
            child: GuideLayout(
              children,
              onCompete: onComplete,
            ),
          );
        },
        opaque: false));
  }
}

class GuideLayoutState extends State<GuideLayout> {
  @override
  Widget build(BuildContext context) {
    Size screenSize = MediaQuery.of(context).size;
    return Material(
      color: Color(0x00ffffff),
      type: MaterialType.transparency,
      child: GestureDetector(
        onTapUp: tapUp,
        child: CustomPaint(
          size: screenSize,
          painter: BgPainter(
              offset: widget.children.first.offset,
              childSize: widget.children.first.childSize,
              shape: widget.children.first.childShape,
              padding: widget.children.first.padding),
          child: Stack(
            children: [
              Positioned(
                child: widget.children.first.descWidget,
                left: widget.children.first.descOffset.dx,
                top: widget.children.first.descOffset.dy,
              )
            ],
          ),
        ),
      ),
    );
  }

  void tapChild() {
    widget.children.first.callback?.call();

    setState(() {
      if (widget.children.length == 1) {
        widget.onCompete?.call();
        Navigator.of(context).pop();
      } else if (widget.children.length > 1) {
        widget.children.removeAt(0);
      }
    });
  }

  void tapUp(TapUpDetails details) {
    print("tapUp==>>${details.globalPosition}");
    if (widget.children.first.closeByClickChild) {
      Path path = new Path();
      path.addRect(Rect.fromLTWH(
          widget.children.first.offset.dx,
          widget.children.first.offset.dy,
          widget.children.first.childSize.width,
          widget.children.first.childSize.height));
      if (!path.contains(details.globalPosition)) return;
    }

    widget.children.first.callback?.call();
    widget.children.removeAt(0);
    if (widget.children.length == 0) {
      widget.onCompete?.call();
      Navigator.of(context).pop();
    } else {
      setState(() {});
    }

    print("length==>>${widget.children.length}");
  }
}

class BgPainter extends CustomPainter {
  Offset offset;
  Size childSize;

  Path path1;
  Path path2;
  Paint _paint;

  ChildShape shape;
  double padding;

  BgPainter({this.offset, this.childSize, this.shape, this.padding}) {
    path1 = Path();
    path2 = Path();
    _paint = Paint()
      ..color = Color(0x90000000)
      ..style = PaintingStyle.fill
      ..isAntiAlias = true;
  }

  @override
  void paint(Canvas canvas, Size size) {
    path1.reset();
    path2.reset();

    path1.addRect(Rect.fromLTWH(0, 0, size.width, size.height));

    switch (shape) {
      case ChildShape.RECTANGLE:
        path2.addRect(Rect.fromLTWH(offset.dx - padding, offset.dy - padding,
            childSize.width + padding * 2, childSize.height + padding * 2));
        break;
      case ChildShape.CIRCLE:
        double length;
        double left;
        double top;
        double radius = sqrt(childSize.width * childSize.width +
            childSize.height * childSize.height);
        length = radius + padding * 2;
        left = offset.dx - (radius - childSize.width) / 2 - padding;
        top = offset.dy - (radius - childSize.height) / 2 - padding;
        path2.addOval(Rect.fromLTWH(left, top, length, length));

        break;
      case ChildShape.OVAL:
        double length;
        double left;
        double top;
        double radius = sqrt(childSize.width * childSize.width +
            childSize.height * childSize.height);
        length = radius + padding * 2;
        left =
            offset.dx - (radius + padding * 4 - childSize.width) / 2 - padding;
        top = offset.dy - (radius - childSize.height) / 2 - padding;
        path2.addOval(Rect.fromLTWH(
            left, top, length + padding * 6, length + padding * 2));
        break;
      case ChildShape.ROUND_RECTANGLE:
        path2.addRRect(RRect.fromRectXY(
            Rect.fromLTWH(offset.dx - padding, offset.dy - padding, childSize.width + padding * 2, childSize.height + padding * 2), padding * 2, padding * 2));
        break;
    }

    Path result = Path.combine(PathOperation.difference, path1, path2);

    canvas.drawPath(result, _paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}

使用

使用定义的GuideLayout方式为

GuideLayout.showGuide(context, children, onComplete);

这里的children是一个list,它是GuideChild的集合,onComplete则是引导展示完成的回调。这里需要特别注明的一点是如何构造这个GuideChild。因为我们需要child的size和offset等信息,所以我们给需要添加引导的widget的key赋值,当然这里是globalKey。这个globalKey可以拿到的信息如下

      RenderBox renderBox = _globalKey.currentContext.findRenderObject();
   
      if (!renderBox.size.isEmpty) {
        Offset childOffset = renderBox.localToGlobal(Offset.zero);
        Size  childSize = renderBox.size;
      }

通过globalKey拿到了RenderBox,renderBox.size 就是绑定key的widget的size,renderBox.localToGlobal(Offset.zero);可以拿到绑定key的widget的偏移量。剩下的就是构建说明widget,这个根据具体的需求而定,它的显示位置也是需要根据需要而定,至此构建需要的Child也完成了。实现开篇效果的调用代码如下,感兴趣的可以直接粘贴把玩一下。

import 'package:flutter/material.dart';
import 'package:myapp/guide_layout.dart';

class GuideTest extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _GuideTestState();
  }
}

class _GuideTestState extends State<GuideTest> {
  GlobalKey<_GuideTestState> _globalKey = new GlobalKey();
  GlobalKey<_GuideTestState> _globalKey2 = new GlobalKey();
  GlobalKey<_GuideTestState> _globalKey3 = new GlobalKey();
  GlobalKey<_GuideTestState> _globalKey4 = new GlobalKey();
  List<GuideChild> children = [];

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      print("timeStamp==>>$timeStamp");
      RenderBox renderBox = _globalKey.currentContext.findRenderObject();
      RenderBox renderBox2 = _globalKey2.currentContext.findRenderObject();
      RenderBox renderBox3 = _globalKey3.currentContext.findRenderObject();
      RenderBox renderBox4 = _globalKey4.currentContext.findRenderObject();

      if (!renderBox.size.isEmpty) {
        Offset childOffset = renderBox.localToGlobal(Offset.zero);
        print(childOffset);

        Offset descOffset =
            Offset(10, childOffset.dy + renderBox.size.height + 10);

        children.add(new GuideChild()
          ..offset = childOffset
          ..childSize = renderBox.size
          ..descOffset = descOffset
          ..descWidget = getDescWidget()
          ..callback = callback1
          ..closeByClickChild = true
          ..childShape = ChildShape.RECTANGLE);

        Offset childOffset2 = renderBox2.localToGlobal(Offset.zero);
        Offset descOffset2 = Offset(100, childOffset2.dy - 50);

        children.add(new GuideChild()
          ..offset = childOffset2
          ..childSize = renderBox2.size
          ..descOffset = descOffset2
          ..descWidget = getDescWidget()
          ..callback = callback2
          ..childShape = ChildShape.ROUND_RECTANGLE);

        Offset childOffset3 = renderBox3.localToGlobal(Offset.zero);
        Offset descOffset3 =
            Offset(50, childOffset3.dy + renderBox3.size.height +50);

        children.add(new GuideChild()
          ..offset = childOffset3
          ..childSize = renderBox3.size
          ..descOffset = descOffset3
          ..descWidget = getDescWidget()
          ..callback = callback3
          ..childShape = ChildShape.OVAL..padding=3);

        Offset childOffset4 = renderBox4.localToGlobal(Offset.zero);
        Offset descOffset4 =
            Offset(180, childOffset4.dy + renderBox4.size.height + 30);

        children.add(new GuideChild()
          ..offset = childOffset4
          ..childSize = renderBox4.size
          ..descOffset = descOffset4
          ..descWidget = getDescWidget()
          ..callback = callback4
          ..childShape = ChildShape.CIRCLE);

        GuideLayout.showGuide(context, children, onComplete);
      }
    });
  }

  Widget getDescWidget() {
    return Container(
      child: DecoratedBox(
        child: Text(
          '这是一个引导文本的说明',
          style: TextStyle(
              color: Colors.white, fontSize: 16, fontWeight: FontWeight.w800),
        ),
        decoration: BoxDecoration(color: Colors.greenAccent),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("测试引导"),
      ),
      body: Center(
        child: Stack(
          children: [
            Positioned(
                left: 30,
                top: 30,
                child: Container(
                  height: 100,
                  width: 100,
                  key: _globalKey,
                  color: Colors.blue,
                  child: Center(
                    child: Text(
                      "测试文本1",
                      style: TextStyle(
                          fontSize: 20,
                          fontWeight: FontWeight.bold,
                          color: Colors.white),
                    ),
                  ),
                )),
            Positioned(
              left: 100,
              top: 600,
              child: Container(
                key: _globalKey2,
                height: 100,
                width: 200,
                color: Colors.red,
                child: Center(
                  child: Text(
                    "测试文本2",
                    style: TextStyle(
                        fontSize: 20,
                        fontWeight: FontWeight.bold,
                        color: Colors.white),
                  ),
                ),
              ),
            ),
            Positioned(
              left: 50,
              top: 400,
              child: Container(
                key: _globalKey3,
                height: 100,
                width: 150,
                color: Colors.purple,
                child: Center(
                  child: Text(
                    "测试文本3",
                    style: TextStyle(
                        fontSize: 20,
                        fontWeight: FontWeight.bold,
                        color: Colors.white),
                  ),
                ),
              ),
            ),
            Positioned(
              left: 230,
              top: 250,
              child: Container(
                key: _globalKey4,
                height: 120,
                width: 100,
                color: Colors.lightGreenAccent,
                child: Center(
                  child: Text(
                    "测试文本4",
                    style: TextStyle(
                        fontSize: 20,
                        fontWeight: FontWeight.bold,
                        color: Colors.white),
                  ),
                ),
              ),
            )
          ],
        ),
      ),
    );
  }

  void callback1() {
    print("点击了第一个引导");
  }

  void callback2() {
    print("点击了第二个引导");
  }

  void callback3() {
    print("点击了第三个引导");
  }

  void callback4() {
    print("点击了第四个引导");
  }

  void onComplete() {
    print("都点完了");
  }
}

不断点击引导log如下

image.png

可以看到基本满足了我们的需求