Flutter使用Overlay與ColorFiltered新手引導(dǎo)實(shí)現(xiàn)示例
思路
開(kāi)發(fā)過(guò)程中常見(jiàn)這樣的需求,頁(yè)面中有幾個(gè)按鈕,用戶首次進(jìn)入時(shí)需要對(duì)這幾個(gè)按鈕高亮展示并加上文字提示。常見(jiàn)的一種方案是找UI切圖,那如何完全使用代碼來(lái)實(shí)現(xiàn)呢?
就以Flutter原始Demo頁(yè)面為例,如果我們需要對(duì)中間展示區(qū)域以及右下角按鈕進(jìn)行一個(gè)引導(dǎo)提示。

我們需要做到的效果是除了紅色框內(nèi)的Widget,其余部分要蓋上一層半透明黑色浮層,相當(dāng)于是全屏浮層,紅色區(qū)域鏤空。
首先是黑色浮層,這個(gè)比較容易,F(xiàn)lutter中的Overlay可以輕易實(shí)現(xiàn),它可以浮在任意的Widget之上,包括Dialog。
那么如何鏤空呢?
一種思路是首先拿到對(duì)應(yīng)的Widget與其寬高和xy偏移量,然后在Overlay中先鋪一層浮層后,把該Widget在Overlay的對(duì)應(yīng)位置中再繪制一遍。也就是說(shuō)該Widget存在兩份,一份是原本的Widget,另一份是在Overlay之上又繪制一層,并且不會(huì)被浮層所覆蓋,即為高亮。這是一種思路,但如果你需要進(jìn)行引導(dǎo)提示的Widget自身有透明度,那么這個(gè)方案就略有問(wèn)題,因?yàn)槟愕母蛹礊榘胪该?,那么用戶就可以穿過(guò)頂層的Widget看到下面的內(nèi)容,略有瑕疵。
那么另一種思路就是我們不去在Overlay之上蓋上另一個(gè)克隆Widget,而是將Overlay半透明黑色涂層對(duì)應(yīng)位置進(jìn)行鏤空即可,就不存在任何問(wèn)題了。
Flutter BlendMode
既然需要鏤空,我們需要了解一下Flutter中的圖層混合模式概念
在畫(huà)布上繪制形狀或圖像時(shí),可以使用不同的算法來(lái)混合像素,每個(gè)算法都存在兩個(gè)輸入,即源(正在繪制的圖像 src)和目標(biāo)(要合成源圖像的圖像 dst)
我們把半透明黑色涂層 和 需要進(jìn)行高亮的Widget 理解為src和dst。
接下來(lái)我們通過(guò)下面的圖例可知,如果我們需要實(shí)現(xiàn)鏤空效果,需要的混合模式為SrcOut或DstOut,因?yàn)樗麄兊幕旌夏J綖橐粋€(gè)源展示,且該源與另一個(gè)源有非透明像素交匯部分完全剔除。

ColorFiltered
Flutter中為我們提供了ColorFiltered,這是一個(gè)官方為我們封裝的一個(gè)以Color作為源的混合模式Widget。其接收兩個(gè)參數(shù),colorFilter和child,前者我們可以理解為上述的src,后者則為dst。
下面以一段簡(jiǎn)單的代碼說(shuō)明
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有非透明像素交匯的地方被鏤空了,其他地方均正常顯示。
此處需要說(shuō)明一下,作為dst的child,要實(shí)現(xiàn)蒙版的效果,必須要與src有所交匯,所以Stack中使用了透明的Positioned.fill填充,之所以要用透明色,是因?yàn)槲覀兪褂玫幕旌夏J?code>srcOut的算法會(huì)剔除非透明像素交互部分
實(shí)現(xiàn)
上述部分思路已經(jīng)足夠支持我們寫(xiě)出想要的效果了,接下來(lái)我們來(lái)進(jìn)行實(shí)現(xiàn)
獲取鏤空位置
首先我需要拿到對(duì)應(yīng)Widget的key,就可以拿到對(duì)應(yīng)的寬高與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: () {
// 點(diǎn)擊后移除當(dāng)前展示的overlay
_removeCurrentOverlay();
// 準(zhǔn)備展示下一個(gè)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,
)),
// 鏤空區(qū)域
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!);
其中的文字偏移量,可以自己通過(guò)代碼來(lái)設(shè)置,展示在中心,或者判斷位置跟隨Widget展示均可,此處不再贅述。
最后我們把Overlay添加到屏幕上展示即可。
完整代碼
這里我將邏輯封裝在靜態(tài)工具類中,鑒于單個(gè)頁(yè)面可能會(huì)有不止一個(gè)引導(dǎo)Widget,所以對(duì)于這個(gè)靜態(tài)工具類,我們需要傳入需要進(jìn)行高亮引導(dǎo)的Widget和提示語(yǔ)的集合。
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: () {
// 點(diǎn)擊后移除當(dāng)前展示的overlay
_removeCurrentOverlay();
// 準(zhǔn)備展示下一個(gè)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,
)),
// 鏤空區(qū)域
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();
// 頁(yè)面展示時(shí)進(jìn)行prompt繪制,在此添加observer監(jiān)聽(tīng)等待渲染完成后掛載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.
);
}
}
最終效果


小結(jié)
本文僅總結(jié)代碼實(shí)現(xiàn)思路,對(duì)于具體細(xì)節(jié)并未處理,可以在PromptItem和PromptBuilder進(jìn)行更多的屬性聲明以更加靈活的展示prompt,比如圓角等參數(shù)。有任何問(wèn)題歡迎大家隨時(shí)討論。
最后附上github地址:github.com/slowguy/flu…
以上就是Flutter使用Overlay與ColorFiltered新手引導(dǎo)實(shí)現(xiàn)示例的詳細(xì)內(nèi)容,更多關(guān)于Flutter使用Overlay ColorFiltered的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android App將數(shù)據(jù)寫(xiě)入內(nèi)部存儲(chǔ)和外部存儲(chǔ)的示例
這篇文章主要介紹了Android App將數(shù)據(jù)寫(xiě)入內(nèi)部存儲(chǔ)和外部存儲(chǔ)的示例,使用外部存儲(chǔ)即訪問(wèn)并寫(xiě)入SD卡,需要的朋友可以參考下2016-03-03
Flutter 使用cached_image_network優(yōu)化圖片加載體驗(yàn)
在 Flutter 中,cached_image_network 即提供了緩存網(wǎng)絡(luò)圖片功能,同時(shí)還提供了豐富的加載過(guò)程指示。本文就來(lái)看下cached_image_network的具體使用2021-05-05
Android—基于微信開(kāi)放平臺(tái)v3SDK開(kāi)發(fā)(微信支付填坑)
這篇文章主要介紹了Android—基于微信開(kāi)放平臺(tái)v3SDK開(kāi)發(fā)(微信支付填坑),具有一定的參考價(jià)值,有需要的可以了解一下。2016-11-11
go語(yǔ)言之美迅速打rpm包實(shí)現(xiàn)詳解
這篇文章主要為大家介紹了go語(yǔ)言之美迅速打rpm包實(shí)現(xiàn)詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-02-02
安卓中出現(xiàn)過(guò)的一些容易被忽略的異常整理
今天小編就為大家分享一篇關(guān)于安卓中出現(xiàn)過(guò)的一些容易被忽略的異常整理,小編覺(jué)得內(nèi)容挺不錯(cuò)的,現(xiàn)在分享給大家,具有很好的參考價(jià)值,需要的朋友一起跟隨小編來(lái)看看吧2018-12-12
android 關(guān)于webview 加載h5網(wǎng)頁(yè)開(kāi)啟定位的方法
今天小編就為大家分享一篇android 關(guān)于webview 加載h5網(wǎng)頁(yè)開(kāi)啟定位的方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-07-07
Android自定義實(shí)現(xiàn)頂部粘性下拉刷新效果
這篇文章主要為大家詳細(xì)介紹了Android自定義實(shí)現(xiàn)頂部粘性下拉刷新效果,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-10-10
Android實(shí)現(xiàn)ListView異步加載圖片的方法
這篇文章主要介紹了Android實(shí)現(xiàn)ListView異步加載圖片的方法,以實(shí)例形式較為詳細(xì)的分析了Android中ListView異步加載圖片的原理與相關(guān)實(shí)現(xiàn)技巧,具有一定參考借鑒價(jià)值,需要的朋友可以參考下2015-10-10

