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

目录

思路

Flutter BlendMode

ColorFiltered

实现

获取镂空位置

ColorFiltered child

完整代码

最终效果

小结

思路

开发过程中常见这样的需求,页面中有几个按钮,用户首次进入时需要对这几个按钮高亮展示并加上文字提示。常见的一种方案是找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的资料请关注易知道(ezd.cc)其它相关文章!

推荐阅读