思路
开发过程中常见这样的需求,页面中有几个按钮,用户首次进入时需要对这几个按钮高亮展示并加上文字提示。常见的一种方案是找ui切图,那如何完全使用代码来实现呢?
就以flutter原始demo页面为例,如果我们需要对中间展示区域以及右下角按钮进行一个引导提示。
我们需要做到的效果是除了红色框内的widget
,其余部分要盖上一层半透明黑色浮层,相当于是全屏浮层,红色区域镂空。
首先是黑色浮层,这个比较容易,flutter中的overlay
可以轻易实现,它可以浮在任意的widget
之上,包括dialog
。
那么如何镂空呢?
一种思路是首先拿到对应的widget
与其宽高和xy偏移量,然后在overlay
中先铺一层浮层后,把该widget
在overlay
的对应位置中再绘制一遍。也就是说该widget
存在两份,一份是原本的widget
,另一份是在overlay
之上又绘制一层,并且不会被浮层所覆盖,即为高亮。这是一种思路,但如果你需要进行引导提示的widget
自身有透明度,那么这个方案就略有问题,因为你的浮层即为半透明,那么用户就可以穿过顶层的widget
看到下面的内容,略有瑕疵。
那么另一种思路就是我们不去在overlay
之上盖上另一个克隆widget
,而是将overlay
半透明黑色涂层对应位置进行镂空即可,就不存在任何问题了。
flutter blendmode
既然需要镂空,我们需要了解一下flutter中的图层混合模式概念
在画布上绘制形状或图像时,可以使用不同的算法来混合像素,每个算法都存在两个输入,即源(正在绘制的图像 src)和目标(要合成源图像的图像 dst)
我们把半透明黑色涂层 和 需要进行高亮的widget 理解为src和dst。
接下来我们通过下面的图例可知,如果我们需要实现镂空效果,需要的混合模式为srcout
或dstout
,因为他们的混合模式为一个源展示,且该源与另一个源有非透明像素交汇部分完全剔除。
colorfiltered
flutter中为我们提供了colorfiltered
,这是一个官方为我们封装的一个以color作为源的混合模式widget。其接收两个参数,colorfilter
和child
,前者我们可以理解为上述的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, )) ], ), ); } }
效果:
可以看到作为src
的colorfiler
除了与作为dst
的stack
有非透明像素交汇的地方被镂空了,其他地方均正常显示。
此处需要说明一下,作为dst
的child
,要实现蒙版的效果,必须要与src
有所交汇,所以stack
中使用了透明的positioned.fill
填充,之所以要用透明色,是因为我们使用的混合模式srcout
的算法会剔除非透明像素交互部分
实现
上述部分思路已经足够支持我们写出想要的效果了,接下来我们来进行实现
获取镂空位置
首先我需要拿到对应widget
的key
,就可以拿到对应的宽高与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. ); } }
最终效果
小结
本文仅总结代码实现思路,对于具体细节并未处理,可以在promptitem
和promptbuilder
进行更多的属性声明以更加灵活的展示prompt,比如圆角等参数。有任何问题欢迎大家随时讨论。
最后附上github地址:github.com/slowguy/flu…
以上就是flutter使用overlay与colorfiltered新手引导实现示例的详细内容,更多关于flutter使用overlay colorfiltered的资料请关注其它相关文章!