开发过程中常见这样的需求,页面中有几个按钮,用户初次进入时需求对这几个按钮高亮展现并加上文字提示。常见的一种方案是找UI切图,那怎么彻底使用代码来完成呢?

思路

就以Flutter原始Demo页面为例,假如咱们需求对中间展现区域以及右下角按钮进行一个引导提示。

Flutter使用Overlay与ColorFiltered实现新手引导蒙版

咱们需求做到的作用是除了红色框内的Widget,其余部分要盖上一层半通明黑色浮层,适当所以全屏浮层,红色区域镂空。

首要是黑色浮层,这个比较简单,Flutter中的Overlay能够容易完成,它能够浮在恣意的Widget之上,包括Dialog

那么怎么镂空呢?

一种思路是首要拿到对应的Widget与其宽高xy偏移量,然后在Overlay中先铺一层浮层后,把该WidgetOverlay的对应方位中再制作一遍。也就是说该Widget存在两份,一份是本来的Widget,另一份是在Overlay之上又制作一层,并且不会被浮层所掩盖,即为高亮。这是一种思路,但假如你需求进行引导提示的Widget本身有通明度,那么这个方案就略有问题,由于你的浮层即为半通明,那么用户就能够穿过顶层的Widget看到下面的内容,略有瑕疵。

那么另一种思路就是咱们不去在Overlay之上盖上另一个克隆Widget,而是将Overlay半通明黑色涂层对应方位进行镂空即可,就不存在任何问题了。

Flutter BlendMode

既然需求镂空,咱们需求了解一下Flutter中的图层混合形式概念

在画布上制作形状或图像时,能够使用不同的算法来混合像素,每个算法都存在两个输入,即源(正在制作的图像 src)和目标(要组成源图像的图像 dst)

咱们把半通明黑色涂层 和 需求进行高亮的Widget 理解为src和dst。

接下来咱们通过下面的图例可知,假如咱们需求完成镂空作用,需求的混合形式为SrcOutDstOut,由于他们的混合形式为一个源展现,且该源与另一个源有非通明像素交汇部分彻底除掉。

Flutter使用Overlay与ColorFiltered实现新手引导蒙版

ColorFiltered

Flutter中为咱们供给了ColorFiltered,这是一个官方为咱们封装的一个以Color作为源的混合形式Widget。其接纳两个参数,colorFilterchild,前者咱们能够理解为上述的src,后者则为dst

下面以一段简单的代码阐明

class TestColorFilteredPage extends StatelessWidget {
  const TestColorFilteredPage({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return ColorFiltered(
      colorFilter: const ColorFilter.mode(Colors.yellow, BlendMode.srcOut),
      child: Stack(
        children: [
          Positioned.fill(
              child: Container(
            color: Colors.transparent,
          )),
          Positioned(
              top: 100,
              left: 100,
              child: Container(
                color: Colors.black,
                height: 100,
                width: 100,
              ))
        ],
      ),
    );
  }
}

作用:

Flutter使用Overlay与ColorFiltered实现新手引导蒙版

能够看到作为srccolorFiler除了与作为dstStack非通明像素交汇的地方被镂空了,其他地方均正常显现。

此处需求阐明一下,作为dstchild,要完成蒙版的作用,有必要要与src有所交汇,所以Stack中使用了通明的Positioned.fill填充,之所以要用通明色,是由于咱们使用的混合形式srcOut的算法会除掉非通明像素交互部分

完成

上述部分思路已经满足支撑咱们写出想要的作用了,接下来咱们来进行完成

获取镂空方位

首要我需求拿到对应Widgetkey,就能够拿到对应的宽高与xy偏移量

RenderObject? promptRenderObject =
    promptWidgetKey.currentContext?.findRenderObject();
double widgetHeight = promptRenderObject?.paintBounds.height ?? 0;
double widgetWidth = promptRenderObject?.paintBounds.width ?? 0;
double widgetTop = 0;
double widgetLeft = 0;
if (promptRenderObject is RenderBox) {
  Offset offset = promptRenderObject.localToGlobal(Offset.zero);
  widgetTop = offset.dy;
  widgetLeft = offset.dx;
}

ColorFiltered child

lastOverlay = OverlayEntry(builder: (ctx) {
  return GestureDetector(
    onTap: () {
      // 点击后移除当时展现的overlay
      _removeCurrentOverlay();
      // 预备展现下一个overlay
      _prepareToPromptSingleWidget();
    },
    child: Stack(
      children: [
        Positioned.fill(
            child: ColorFiltered(
          colorFilter: ColorFilter.mode(
              Colors.black.withOpacity(0.7), BlendMode.srcOut),
          child: Stack(
            children: [
              // 通明色填充背景,作为蒙版
              Positioned.fill(
                  child: Container(
                color: Colors.transparent,
              )),
              // 镂空区域
              Positioned(
                  left: l,
                  top: t,
                  child: Container(
                    width: w,
                    height: h,
                    decoration: decoration ??
                        const BoxDecoration(color: Colors.black),
                  )),
            ],
          ),
        )),
        // 文字提示,需求放在ColorFiltered的外层
        Positioned(
            left: l - 40,
            top: t - 40,
            child: Material(
              color: Colors.transparent,
              child: Text(
                tips,
                style: const TextStyle(fontSize: 14, color: Colors.white),
              ),
            ))
      ],
    ),
  );
});
Overlay.of(context)?.insert(lastOverlay!);

其中的文字偏移量,能够自己通过代码来设置,展现在中心,或者判断方位跟从Widget展现均可,此处不再赘述。

最终咱们把Overlay添加到屏幕上展现即可。

完好代码

这儿我将逻辑封装在静态东西类中,鉴于单个页面可能会有不止一个引导Widget,所以关于这个静态东西类,咱们需求传入需求进行高亮引导的Widget和提示语的调集。

class PromptItem {
  GlobalKey promptWidgetKey;
  String promptTips;
  PromptItem(this.promptWidgetKey, this.promptTips);
}
class PromptBuilder {
  static List<PromptItem> toPromptWidgetKeys = [];
  static OverlayEntry? lastOverlay;
  static promptToWidgets(List<PromptItem> widgetKeys) {
    toPromptWidgetKeys = widgetKeys;
    _prepareToPromptSingleWidget();
  }
  static _prepareToPromptSingleWidget() async {
    if (toPromptWidgetKeys.isEmpty) {
      return;
    }
    PromptItem promptItem = toPromptWidgetKeys.removeAt(0);
    RenderObject? promptRenderObject =
        promptItem.promptWidgetKey.currentContext?.findRenderObject();
    double widgetHeight = promptRenderObject?.paintBounds.height ?? 0;
    double widgetWidth = promptRenderObject?.paintBounds.width ?? 0;
    double widgetTop = 0;
    double widgetLeft = 0;
    if (promptRenderObject is RenderBox) {
      Offset offset = promptRenderObject.localToGlobal(Offset.zero);
      widgetTop = offset.dy;
      widgetLeft = offset.dx;
    }
    if (widgetHeight != 0 &&
        widgetWidth != 0 &&
        widgetTop != 0 &&
        widgetLeft != 0) {
      _buildNextPromptOverlay(
          promptItem.promptWidgetKey.currentContext!,
          widgetWidth,
          widgetHeight,
          widgetLeft,
          widgetTop,
          null,
          promptItem.promptTips);
    }
  }
  static _buildNextPromptOverlay(BuildContext context, double w, double h,
      double l, double t, Decoration? decoration, String tips) {
    _removeCurrentOverlay();
    lastOverlay = OverlayEntry(builder: (ctx) {
      return GestureDetector(
        onTap: () {
          // 点击后移除当时展现的overlay
          _removeCurrentOverlay();
          // 预备展现下一个overlay
          _prepareToPromptSingleWidget();
        },
        child: Stack(
          children: [
            Positioned.fill(
                child: ColorFiltered(
              colorFilter: ColorFilter.mode(
                  Colors.black.withOpacity(0.7), BlendMode.srcOut),
              child: Stack(
                children: [
                  // 通明色填充背景,作为蒙版
                  Positioned.fill(
                      child: Container(
                    color: Colors.transparent,
                  )),
                  // 镂空区域
                  Positioned(
                      left: l,
                      top: t,
                      child: Container(
                        width: w,
                        height: h,
                        decoration: decoration ??
                            const BoxDecoration(color: Colors.black),
                      )),
                ],
              ),
            )),
            // 文字提示,需求放在ColorFiltered的外层
            Positioned(
                left: l - 40,
                top: t - 40,
                child: Material(
                  color: Colors.transparent,
                  child: Text(
                    tips,
                    style: const TextStyle(fontSize: 14, color: Colors.white),
                  ),
                ))
          ],
        ),
      );
    });
    Overlay.of(context)?.insert(lastOverlay!);
  }
  static _removeCurrentOverlay() {
    if (lastOverlay != null) {
      lastOverlay!.remove();
      lastOverlay = null;
    }
  }
}
class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
  int _counter = 0;
  GlobalKey centerWidgetKey = GlobalKey();
  GlobalKey bottomWidgetKey = GlobalKey();
  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }
  @override
  void initState() {
    super.initState();
    // 页面展现时进行prompt制作,在此添加observer监听等待渲染完成后挂载prompt
    WidgetsBinding.instance.addObserver(this);
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      List<PromptItem> prompts = [];
      prompts.add(PromptItem(centerWidgetKey, "这是中心Widget"));
      prompts.add(PromptItem(bottomWidgetKey, "这是底部Button"));
      PromptBuilder.promptToWidgets(prompts);
    });
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          mainAxisAlignment: MainAxisAlignment.center,
          // 需求高亮展现的widget,需求声明其GlobalKey
          key: centerWidgetKey,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        // 需求高亮展现的widget,需求声明其GlobalKey
        key: bottomWidgetKey,
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

最终作用

Flutter使用Overlay与ColorFiltered实现新手引导蒙版
Flutter使用Overlay与ColorFiltered实现新手引导蒙版

小结

本文仅总结代码完成思路,关于具体细节并未处理,能够在PromptItemPromptBuilder进行更多的特点声明以更加灵敏的展现prompt,比方圆角等参数。有任何问题欢迎大家随时讨论。

最终附上github地址:github.com/slowguy/flu…