Flutter:使用Overlay展示浮動(dòng)的widget

本文是一篇翻譯文章,翻譯自
原文地址:Flutter: Using Overlay to display floating widgets
作者:AbdulRahman AlHamali

想象一下:你編寫出的迷人表單頁面


overlay1.png

你把它發(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之上。


overlyay2.png

你想了想:打算重新設(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)提供好的StackOverlay

在這片文章中,我將會(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)牢記Positioned widgets 只能被插入到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)象:


overlay3.gif

建議選擇列表固定在了屏幕上!在某些場景下你可能的確需要固定的,但是在當(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包起來,將TextFieldCompositedTransformTarget包起來。然后我們將他們使用想用的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'
        ),
      ),
    );
  }
}
  • 我們將我們的Material widget用CompositedTransformFollower包裹進(jìn)OverlayEntry中,將TextFormField包裹進(jìn)CompositedTransformTarget中。
  • 我們使用相同的LayerLink示例,來關(guān)聯(lián)followertarget,這樣followertarget將會(huì)在相同的坐標(biāo)系中,高效的跟隨target而動(dòng)。
  • Positionedwidget中移除了topleft屬性,因?yàn)槟J(rèn)followertarget有相同的坐標(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)啦!

overlay4.gif

重要提示: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,我確定你也能在多樣化的需求中使用到它。

希望這篇文章能夠幫助到你,歡迎與我交流。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容