Flutter使用Overlay与ColorFiltered新手引导实现示例

2022-10-22,,,,

思路

开发过程中常见这样的需求,页面中有几个按钮,用户首次进入时需要对这几个按钮高亮展示并加上文字提示。常见的一种方案是找ui切图,那如何完全使用代码来实现呢?

就以flutter原始demo页面为例,如果我们需要对中间展示区域以及右下角按钮进行一个引导提示。

我们需要做到的效果是除了红色框内的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,因为他们的混合模式为一个源展示,且该源与另一个源有非透明像素交汇部分完全剔除。

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,
              ))
        ],
      ),
    );
  }
}

效果:

可以看到作为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.
    );
  }
}

最终效果

小结

本文仅总结代码实现思路,对于具体细节并未处理,可以在promptitempromptbuilder进行更多的属性声明以更加灵活的展示prompt,比如圆角等参数。有任何问题欢迎大家随时讨论。

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

以上就是flutter使用overlay与colorfiltered新手引导实现示例的详细内容,更多关于flutter使用overlay colorfiltered的资料请关注其它相关文章!

《Flutter使用Overlay与ColorFiltered新手引导实现示例.doc》

下载本文的Word格式文档,以方便收藏与打印。