1. 引言
🤡 组员对「Flutter自定义绘制」不太熟悉,不知从何下手整这个「用户操作引导组件」,所以这个活就落到杰哥身上了。
👻 它是 App 中一个很常见的组件,用于 帮助用户 快速熟悉和掌握App的使用方法 (首次使用 & 新功能) ,提高用户体验和操作效率。表现形式通常为这两者的组合:
- 「高亮指引」→ 通过遮罩层或光标聚集突出界面的关键区域.
- 「悬浮提示」→ 特定功能或控件上显示简单的文字说明。
具体UI效果如下 (图摘自:《APP UI结构:用户引导&提示》):
💁♂️ 不难看出其中的关键技术难点为「如何实现特定组件的高亮」,思路有两:
- ① 在需要高亮组件上方 (中间隔着半透明遮罩),放置一个和组件一样的图片或组件。
- ② 获取高亮组件的范围区域,配合「混合模式」中的 dstOut (目标图像和源图像重叠的区域变透明) 或 clear (将绘制区域变成透明) 模式来实现对应区域透明。
前者一天就不靠谱,搞起来麻烦而且不好复用,那就只有思路二咯,「混合模式」在《二十九、🖌玩转自定义绘制三部曲[上]》中已经详细讲解过了,这里就不复读了,😄 不了解Flutter自定义绘制的建议移步做下前置阅读:
😄 接着由浅入深,先用 CustomPainter 写个最简单的Demo 实现 组件高亮,然后再延伸封装和扩展。
2. 简单实现-组件高亮 🌰
2.1. 弹窗用哪个?
💁♂️ 开始编写具体代码前,得先定下 弹窗 的方式,em... 选 Route(路由) 还是 Overlay(浮窗) ?😄 都可以,在《二十八、UI实战-玩转自定义弹窗💥》提到过,Flutter弹窗 实现的主要思路有两种:Stack 和 Overlay,Route 本质上也是基于 Overlay 实现的。🤡 但直接用 Overlay 有个小坑需要注意,它会置于 最顶层,在上面弹出窗口反而会显示到了它的下方:
😶 这个坑的解法:
给弹出的浮层内容视图 套一个Navigator,showDialog() 传参 useRootNavigator:false,查找 最近的 Navigator 而不是 根Navigator。
😄 个人建议:对于需要 一直处于页面最上层 的弹窗才用 Overlay,其它情况都用 Route。而在这里,用户引导操作页位于顶层没毛病,所以这里直接用 Overlay 来弹窗 😆。
2.2. 如何获取高亮组件的宽高 & 坐标?
2.2.1. GlobalKey
😶 最常规的获取方式:
先为 高亮Widget 的 key 属性设置一个 GlobalKey,然后通过它访问Widget的 BuildContext,从而获得 RenderBox,进而获取到 Widget 的 尺寸和位置信息。
获取代码示例:
😀 如果你不了解Flutter中的各种Key,可以先看下《十、进阶-玩转各种Key🔑》,GlobalKey 能够在 Widget树 中唯一标识一个Widget,通过它无需依赖于 Widget 的位置和层级结构,就能方便地访问到特定Widget。不 过有两点需要注意:
- ① 每个GlobalKey对象只能被一个Widget使用!多个Widget使用同一个GlobalKey对象会报错:Another exception was thrown: Multiple widgets used the same GlobalKey。
- ② 只有在需要的时候才设置 GlobalKey,以避免造成不必要的内存浪费 + 性能下降,比如在 ListView 中为每个 子Widget 都设置一个GlobalKey,任何条目改变时,Flutter都需要重新检查整个列表。当列表很长的时候,能明显感觉到加载慢,滑动卡顿等不佳的用户体验。
2.2.2. WidgetsBinding.instance.addPostFrameCallback()
😏 此方法 注册的回调 会在 当前帧绘制完毕后立即执行,简单点说 →「Widget 🌳 渲染完、屏幕刷新后」执行,可以在这里拿到「渲染后的布局信息」。有时我们还会在这里进行「状态更新」,这样做的好处是避免在 Widget 构建过程中做不必要的渲染或更新。
😶 避免在 build() 中调用此方法,因为 build() 可能会被多次调用,推荐在 initState() 或其它不高频的回调中调用,如:
- didChangeDependencies() - State依赖的InheritedWidget变化时调用
- didUpdateWidget() - 父 Widget 重新构建并且传递了新的参数给当前 Widget 时调用。
获取代码实例:
😀 然后,拿到 子Widget的尺寸和位置信息,一般都是需要向上传递给 父Widget 的,一种常见玩法 →「构造方法向下逐层传递回调」,具体代码示例:
😅 可以是可以,但这只适合「嵌套层次较少」的场景,如果「嵌套了很多层」,写起来就巨麻烦,每一层Widget 都要定义一个这样的 回调属性,然后 构造方法 里传递这个值,🙃 耦合严重,改起来也头疼。
😀 两种更好的做法是「向上发送通知」或「使用状态管理工具」,说下前者,用到 Flutter 提供的「Notification」机制,用法非常简单,具体代码示例:
① 自定义 Notification 类
② 子Widget发送通知
③ 接收通知的父Widget套一个 NotificationListener 来监听指定类型的通知:
运行后,父组件如约收到子组件发送的通知:
上面 onNotification() 返回 true,表示消费调当前通知,不再继续向上传递。如果返回 false,通知还会继续传递,直到找到一个处理该通知的组件 (回调返回true) 或到达 根节点。😶 第二种状态管理就不用说了,可选项有很多,如:内置的 InheritedWidget 和 Provider、Riverpod、Bloc、Redux、GetX 等。
2.2.3. 自定义 RenderObject
核心:在 performLayout() 中通过 WidgetsBinding.instance.addPostFrameCallback() 添加回调监听。
① 自定义 RenderProxyBox 类
② 自定义 SingleChildRenderObjectWidget
③ 需要获取尺寸和位置信息的子Widget套上:
运行后,Flutter 布局确定组件大小和位置时会调用 performLayout(),然后获取到子组件的信息:
2.3. CustomPaint + CustomPainter 抠出高亮区域
😀 弄到高亮组件的尺寸和位置信息,接下来的绘制就简单了,根据这参数生成 Path(矩形) :
然后创建一个混合模式为 BlendMode.dstOut 画笔,依次绘制半透明背景,再绘制高亮区域:
然后这里用到了 canvas 的 saveLayer() 而非 save() ,说下两者的区别:
- canvas.save() : 保存画布的当前状态,包括变换、裁剪和其他属性。这允许我们对画布进行更改,然后使用 canvas.restore() 将其 恢复到保存的状态。
- canvas.saveLayer() : 类似于 canvas.save() ,但它还会为后续的绘图命令创建一个 新图层。当你想对画布的特定部分应用混合或不透明度等效果时,可以用上它。调用 canvas.restore() 时,新图层会与之前的图层合并。
😄 如果你这里不用 saveLayer() 你会发现不是高亮反而是 变黑,接着加一个弹窗方法,其中传递一个 移除浮层的回调,以便点击时关闭引导:
最后加上 GlobalKey 并传递给 需要高亮的Widget:
运行效果如下:
👏 非常简单就实现了组件高亮的基本效果啦,源码【--->c31/d2/custom_painter_demo.dart<---】。另外,除了 CustomPaint 组件支持 BlendMode (混合模式) 能实现高亮效果外,ShaderMask、ColorFiltered#ColorFilter.mode、DecoratedBox#BoxDecoration 等组件也可以,不过在实现用户操作引导组件这个场景,个人感觉支持 复杂自定义绘制 的 CustomPaint 更合适,灵活而且稳定可控。
3. 规规矩矩-把活干完 😐
关键技术难点解决了,接着就是 封装,这里不太好做统一封装,毕竟不同APP想要的引导效果可能不太一样。众口难调🤷♀️,所以,这里只是 抛砖引玉,以我司项目为例,进行简单封装,读者可以借鉴思路,契合实际业务 自行扩展或者封装。通用套路:
Stack作为父容器 ,先盖一层 CustomPaint绘制高亮区域,然后就是 按需添加其它组件,通过在组件的外层套一个 Positioned 来调整组件的具体摆放位置。
😶 通过观察,发现公司项目里用到的用户引导组件 非常简单,长这样:
有 多个引导页,每个都是 一组三要素:高亮组件 + 文字说明 + 操作按钮 (上下一页),然后顺序只有上面的两种。😀 这完全可以用 纯自定义绘制 来实现,接着具体实现一波~
3.1. 引导页的控制器类
暴露两个方法来调用 State 中切换上下一页的方法:
3.2. 引导页的实体类
属性为对应的 三要素 需要的参数,高亮加了「内边距」和「高亮形状」的支持 (矩形、圆角矩形、圆形),控制按钮传递一个控制器的回调,方便外部调用:
3.3. 引导页Widget
除了定义一个 GuidePage 的引导页列表外,还定义了一个 引导结束的回调,毕竟,有时有引导完成执行相关操作的需求 (如弹窗)。State 中定义了两个 ValueNotifier,分别用于 保存当前引导页的索引 (当前第几页) 和 用户点击位置坐标 (判断按钮点击位置用到)。初始化了一个 UserGuideController 实例,定义了切换上/下一页的方法,build() 返回的Widget 套了一个 ValueListenableBuilder,当引导页索引变化时会触发Widget的刷新:
3.4. 构建引导也的具体逻辑
依次是:
- 获取高亮区域的原始尺寸和位置
- 解析padding更新尺寸和位置
- 根据传入的不同形状初始化Path
- 给 CustomPaint 套一个 GestureDetector 用以捕获用户的点击位置,并将相关参数往下传递:
再往下就是具体的自定义绘制逻辑了,定义一些用需要用的参数,将绘制过程拆解成三个方法:
3.4.1. paintHighLight()-绘制高亮区域
跟简单例子那里一样:
3.4.2. paintTips()-绘制文字
这部分涉及到计算,会复杂一些,拆解为三个部分,① 文字相关值的计算:
② 绘制文字标签的三角形:
③ 绘制圆角背景 & 文字,返回一个y轴的坐标,后面绘制按钮要用到:
3.4.3. paintButtons()-绘制按钮
这一部分同样涉及到计算:
3.4.4. 点击回调处理
😶 还要判断下点击位置,触发对应按钮的回调:
3.5. 添加弹出引导页的方法
走的 Overlay(浮窗) ,传入回调中移除 OverlayEntry(浮层) :
3.6. 写下测试代码
源码【--->c31/d3/test_user_guide.dart<---】
运行效果如下:
👏 实现起来还是比较简单的,主要是计算需要花点时间,这里的 悬浮文字 和 按钮 也可以自己叠组件算。😀 活干完了,接着整下活,找几个效果写来玩玩~
4. 花里胡哨-实现看看 ✨
4.1. 高亮区域-动画过渡效果
Github仓库:kpaxian7/feature_guider
🤔 就是 从当前高亮区域 切换到 上/下一个高亮区域 的过渡效果,用到动画,State 混入 SingleTickerProviderStateMixin,定义两个属性保存切换前后的 Path,初始化 动画控制器 和 动画曲线。
切换上/下一页时 重置和启动动画:
将动画通过构造方法传递到 LightPainter 中:
写一个 插值两个Path 的方法 (矩形中心点 + 宽高变化):
绘制高亮区域那里调下上面的方法 生成插值Path 再绘制:
运行效果如下:
😳 矩形 → 矩形 还好,但从 矩形 → 圆形或圆角矩形 (反过来也是) 最后的过渡明显是有些 突兀 的,因为动画的插值过程都是 绘制矩形,动画完成直接绘制结束Path。🤔 这里把动画执行时间拆解为 前后半段:
- 前半段 (0≤t≤0.5):快速从起始绘制挪到结束位置。
- 后半段 (0.5<t≤1):圆角半径从0过渡到结束高亮区域的圆角大小。
修改后的代码:
运行效果如下:
👏 Nice,有个圆角变化的效果,丝滑了不少,源码【--->c31/d4/a1/test_user_guide.dart<---】
4.2. 悬浮文字-上下浮动效果
Github 仓库:SimformSolutionsPvtLtd/flutter_showcaseview
💁♂️ 加个循环执行的 AnimationController,在绘制文字时获取动画值,添加不断变化的偏移就好。关键代码:
运行效果如下:
👏 so easy,源码【--->c31/d4/a2/test_user_guide.dart<---】
5. 小结
🤡 本节,杰哥带着大伙手把手实现了「用户操作引导组件」的 简易封装,总体来说还是 非常简单 的。核心的技术难点无非「特定组件的高亮」,通过「混合模式-BlendMode」就能实现这样的效果,剩下就是一些自定义绘制的计算。例子里是 CustomPainter 一把梭,更贴合日常开发的通用套路:
Stack 作为父容器 ,先盖一层 CustomPaint 绘制高亮区域,然后按需添加 其它组件,通过在组件的外层套一个 Positioned 来调整组件的具体摆放位置。
🤏 赶紧自己动手试试吧,年前最后一更,提前祝各位读者:春节快乐,所愿皆所成,多喜乐、长安宁🎉。