开发过程中常见这样的需求,页面中有几个按钮,用户初次进入时需求对这几个按钮高亮展现并加上文字提示。常见的一种方案是找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…