本文是一篇翻譯文章,翻譯自
原文地址:Flutter: Using Overlay to display floating widgets
作者:AbdulRahman AlHamali
想象一下:你編寫出的迷人表單頁面

你把它發(fā)給產(chǎn)品經(jīng)理,他看了一眼說:“我一定要完整的輸入國家名稱嗎,當(dāng)我輸入文字時(shí)難道你就不能給我展示些建議嗎?”,你想了想:“好吧,他是對(duì)的”,因此,你決定開發(fā)一個(gè)‘自動(dòng)補(bǔ)全‘的’預(yù)先輸入’功能,隨便你怎么稱呼它:一個(gè)文本展示框TextField,當(dāng)用戶輸入文字的時(shí)候展示一些建議選項(xiàng)。開始工作了..你知道怎么拿到建議數(shù)據(jù),你知道怎么寫邏輯,你知道所有要做的事情..除了不知道怎么將建議選項(xiàng)浮動(dòng)展示在widgets之上。

你想了想:打算重新設(shè)計(jì)代碼結(jié)構(gòu),為了實(shí)現(xiàn)懸浮效果,決定將整個(gè)頁面包裝進(jìn)一個(gè)Stack組件中,你需要準(zhǔn)確的計(jì)算每個(gè)widget顯示的位置,非常侵入性、必須要嚴(yán)謹(jǐn)、容易出錯(cuò),并且直覺告訴你這么做可能是錯(cuò)誤的,有其他的實(shí)現(xiàn)方式嗎?
方案就是:你可以使用Flutter已經(jīng)提供好的Stack:Overlay
在這片文章中,我將會(huì)介紹如何使用Overlay,來創(chuàng)建懸浮在其他widget之上的widgets,并且并不需要重構(gòu)你的整個(gè)頁面。
你可以使用Overlay來展示自動(dòng)匹配的建議選項(xiàng),小提示,或著基本上所有的浮動(dòng)的東西。
Overlay是什么?
官方文檔這樣定義
A Stack of entries that can be managed independently.
// 一個(gè)可以獨(dú)立管理的Stack子類
Overlays let independent child widgets “float” visual elements on top of other widgets by inserting them into the overlay’s Stack.
// 通過將可獨(dú)立管理的子節(jié)點(diǎn)widgets加入到`overlay`的棧中,Overlays可以將這些widgets浮動(dòng)展示到顯現(xiàn)的elements節(jié)點(diǎn)的頂部,
這看起來就是我們正在尋找的內(nèi)容,當(dāng)我們創(chuàng)建MaterialApp的時(shí)候,它會(huì)自動(dòng)創(chuàng)建一個(gè)Navigator,Navigator則又會(huì)創(chuàng)建一個(gè)Overlay:一個(gè)navigator用來管理所展示的views視圖的Stack組件
接下來,讓我們一起看看怎么使用Overlay來解決我們的問題吧。
注意:這篇文章的核心是介紹如何顯示浮動(dòng)widgets,因此不會(huì)涉及太多如何實(shí)現(xiàn)自動(dòng)補(bǔ)全文本輸入框textfield的細(xì)節(jié),如果你對(duì)一個(gè)編寫良好、可高度自定義的自動(dòng)補(bǔ)全widget感興趣的話,可以來看我寫的package flutter_typeahead
初始代碼
我們以最初的代碼開始吧:
Scaffold(
body: Padding(
padding: const EdgeInsets.all(50.0),
child: Form(
child: ListView(
children: <Widget>[
TextFormField(
decoration: InputDecoration(
labelText: 'Address'
),
),
SizedBox(height: 16.0,),
TextFormField(
decoration: InputDecoration(
labelText: 'City'
),
),
SizedBox(height: 16.0,),
TextFormField(
decoration: InputDecoration(
labelText: 'Address'
),
),
SizedBox(height: 16.0,),
RaisedButton(
child: Text('SUBMIT'),
onPressed: () {
// submit the form
},
)
],
),
),
),
)
*一個(gè)簡單頁面,包含了三個(gè)文本輸入框:country、city、address。
然后我們就以country 文本輸入框?yàn)槔?,將它封裝成一個(gè)我們自己的statefull widget,命名為CountriesField
class CountriesField extends StatefulWidget {
@override
_CountriesFieldState createState() => _CountriesFieldState();
}
class _CountriesFieldState extends State<CountriesField> {
@override
Widget build(BuildContext context) {
return TextFormField(
decoration: InputDecoration(
labelText: 'Country'
),
);
}
}
接下來我們將要做的是,每次當(dāng)選中文本輸入框獲取焦點(diǎn)Focus的時(shí)候,將一個(gè)浮動(dòng)的list列表展示出來。當(dāng)失去焦點(diǎn)Focus的時(shí)候,再將它隱藏起來,當(dāng)然你可以按照自己需求來決定如何實(shí)現(xiàn),你可能需要在用戶輸入了一些文字后展示它,或者當(dāng)用戶點(diǎn)擊Enter按鈕的時(shí)候再隱藏。無論怎樣,讓我們先看看如何展示這個(gè)懸浮的widget吧:
class CountriesField extends StatefulWidget {
@override
_CountriesFieldState createState() => _CountriesFieldState();
}
class _CountriesFieldState extends State<CountriesField> {
final FocusNode _focusNode = FocusNode();
OverlayEntry _overlayEntry;
@override
void initState() {
_focusNode.addListener(() {
if (_focusNode.hasFocus) {
this._overlayEntry = this._createOverlayEntry();
Overlay.of(context).insert(this._overlayEntry);
} else {
this._overlayEntry.remove();
}
});
}
OverlayEntry _createOverlayEntry() {
RenderBox renderBox = context.findRenderObject();
var size = renderBox.size;
var offset = renderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder: (context) => Positioned(
left: offset.dx,
top: offset.dy + size.height + 5.0,
width: size.width,
child: Material(
elevation: 4.0,
child: ListView(
padding: EdgeInsets.zero,
shrinkWrap: true,
children: <Widget>[
ListTile(
title: Text('Syria'),
),
ListTile(
title: Text('Lebanon'),
)
],
),
),
)
);
}
@override
Widget build(BuildContext context) {
return TextFormField(
focusNode: this._focusNode,
decoration: InputDecoration(
labelText: 'Country'
),
);
}
}
- 我們給
TextFormField綁定了一個(gè)FocusNode,并且在initState里面給FocusNode添加了一個(gè)監(jiān)聽事件,通過監(jiān)聽事件來獲取什么時(shí)候獲得/失去焦點(diǎn)focus。 - 每次當(dāng)我們獲取焦點(diǎn)
(_focusNode.hasFocus == true)的時(shí)候, 我們通過_createOverlayEntry創(chuàng)建一個(gè)OverlayEntry實(shí)例對(duì)象,然后通過使用Overlay.of(context).insert,將它插入到最鄰近的Overlaywidget中去。 - 每次當(dāng)我們失去焦點(diǎn)
(_focusNode.hasFocus == false)的時(shí)候,我們通過使用_overlayEntry.remove來移除剛才添加的overlay實(shí)例。 -
_createOverlayEntry通過使用context.findRenderObject來獲取我們的widget所在的渲染對(duì)象RenderBox,渲染對(duì)象里包含位置position、大小size、和一些其他關(guān)于渲染的信息,有了這些信息能夠幫助我們計(jì)算在哪里展示我們的懸浮列表。 -
_createOverlayEntry通過渲染信息來獲取當(dāng)前widget的大小,也可以使用renderBox.localToGlobal來獲取當(dāng)前widget在屏幕上的坐標(biāo)。我們將localToGlobal設(shè)置為Offset.zero這意味著我們將在渲染對(duì)象中使用(0,0)坐標(biāo),并且將他們轉(zhuǎn)換為屏幕上相對(duì)應(yīng)的坐標(biāo)。 - 接著我們創(chuàng)建了OverlayEntry,這時(shí)一個(gè)用來將widgets展示到
Overlay中的widget。 - 當(dāng)前創(chuàng)建的
OverlayEntry的是一個(gè)Positionedwidget。請(qǐng)牢記Positionedwidgets 只能被插入到Stack中,當(dāng)然Overlay其實(shí)也是一個(gè)Stack。 - 我們?cè)O(shè)置
Positionedwidget的坐標(biāo),給它和TextField相同的X軸坐標(biāo),相同的寬度,相同的Y軸坐標(biāo),當(dāng)然為了不遮擋到TextField,在底部進(jìn)行了一些偏移。 - 在
Positioned內(nèi)部,我們?cè)O(shè)置了一個(gè)展示建議選項(xiàng)的ListView(里面默寫了例子中的一些國家)。注意到我把所有的內(nèi)容都包在了Material中,關(guān)于這樣寫有兩個(gè)原因:Overlay默認(rèn)不包含Materialwidget,并且很多widgets如果沒有有Material祖先節(jié)點(diǎn)的話不能展示,除此之外Material還提供了elevation屬性,可以讓我們給widget設(shè)置隱形效果,看起來就像真正浮在上面一樣。
以上,我們的建議選擇項(xiàng)可以浮在所有widget之上了!
彩蛋:跟隨widget滑動(dòng)!
在我們離開之前,讓我們?cè)诙鄬W(xué)喜一點(diǎn)吧!假如我們的頁面是可以滾動(dòng),我們可能注意到如下現(xiàn)象:

建議選擇列表固定在了屏幕上!在某些場景下你可能的確需要固定的,但是在當(dāng)前場景中,我們不想要它固定,我們想要它跟隨我們的TextField一起滾動(dòng)!
關(guān)鍵詞滾動(dòng),F(xiàn)lutter給我們提供了兩個(gè)widget:CompositedTransformFollower、CompositedTransformTarget,簡單介紹就是,如果我們關(guān)聯(lián)起一個(gè)follower和一個(gè)target,那么無論target滾動(dòng)到哪里,這個(gè)follower將跟隨它一起滾動(dòng)!為了關(guān)聯(lián)起一個(gè)follower和一個(gè)target,我們需要給他們?cè)O(shè)置相同的LayerLink.
因此我們需要將建議選擇列表用CompositedTransformFollower包起來,將TextField用CompositedTransformTarget包起來。然后我們將他們使用想用的LayerLink關(guān)聯(lián)起來,這樣就可以是建議選擇列表跟隨TextField一起滑動(dòng)了:
class CountriesField extends StatefulWidget {
@override
_CountriesFieldState createState() => _CountriesFieldState();
}
class _CountriesFieldState extends State<CountriesField> {
final FocusNode _focusNode = FocusNode();
OverlayEntry _overlayEntry;
final LayerLink _layerLink = LayerLink();
@override
void initState() {
_focusNode.addListener(() {
if (_focusNode.hasFocus) {
this._overlayEntry = this._createOverlayEntry();
Overlay.of(context).insert(this._overlayEntry);
} else {
this._overlayEntry.remove();
}
});
}
OverlayEntry _createOverlayEntry() {
RenderBox renderBox = context.findRenderObject();
var size = renderBox.size;
return OverlayEntry(
builder: (context) => Positioned(
width: size.width,
child: CompositedTransformFollower(
link: this._layerLink,
showWhenUnlinked: false,
offset: Offset(0.0, size.height + 5.0),
child: Material(
elevation: 4.0,
child: ListView(
padding: EdgeInsets.zero,
shrinkWrap: true,
children: <Widget>[
ListTile(
title: Text('Syria'),
onTap: () {
print('Syria Tapped');
},
),
ListTile(
title: Text('Lebanon'),
onTap: () {
print('Lebanon Tapped');
},
)
],
),
),
),
)
);
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: this._layerLink,
child: TextFormField(
focusNode: this._focusNode,
decoration: InputDecoration(
labelText: 'Country'
),
),
);
}
}
- 我們將我們的
Materialwidget用CompositedTransformFollower包裹進(jìn)OverlayEntry中,將TextFormField包裹進(jìn)CompositedTransformTarget中。 - 我們使用相同的
LayerLink示例,來關(guān)聯(lián)follower和target,這樣follower和target將會(huì)在相同的坐標(biāo)系中,高效的跟隨target而動(dòng)。 - 從
Positionedwidget中移除了top和left屬性,因?yàn)槟J(rèn)follower和target有相同的坐標(biāo),因此不在需要。然而我們保留了width屬性,因?yàn)槿绻辉O(shè)置的話,follower將會(huì)無限的延伸。 - 為了不遮擋
TextFormField,我們給CompositedTransformFollower設(shè)置了一個(gè)offset(和之前一樣的原因)。 - 最后,我們將
showWhenUnlinked屬性設(shè)置為false,當(dāng)TextFormField在屏幕上不可見時(shí),用來隱藏OverlayEntry(比如我們滑動(dòng)出屏幕底部很遠(yuǎn)的時(shí)候)。
經(jīng)過這些操作,我們的OverlayEntry現(xiàn)在可以跟隨我們的TextField一起滾動(dòng)啦!

重要提示:CompositedTransformFollower仍然有一點(diǎn)小bug,當(dāng)target不可見時(shí),即使follower已經(jīng)從屏幕上隱藏了,這個(gè)follower仍然會(huì)響應(yīng)點(diǎn)擊事件,我已經(jīng)給Flutter Team提了issue,此issue已經(jīng)關(guān)閉,標(biāo)記為解決。
Overlay是一個(gè)強(qiáng)大的widget,它給我們提供了一個(gè)簡單易用的的用來展示浮動(dòng)widget的的Stack組件。我用它成功的創(chuàng)建了flutter_typeahead,我確定你也能在多樣化的需求中使用到它。
希望這篇文章能夠幫助到你,歡迎與我交流。