Flutter:使用Overlay展示浮动的widget

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

想象一下:你编写出的迷人表单页面


overlay1.png

你把它发给产品经理,他看了一眼说:“我一定要完整的输入国家名称吗,当我输入文字时难道你就不能给我展示些建议吗?”,你想了想:“好吧,他是对的”,因此,你决定开发一个‘自动补全‘的’预先输入’功能,随便你怎么称呼它:一个文本展示框TextField,当用户输入文字的时候展示一些建议选项。开始工作了..你知道怎么拿到建议数据,你知道怎么写逻辑,你知道所有要做的事情..除了不知道怎么将建议选项浮动展示在widgets之上。


overlyay2.png

你想了想:打算重新设计代码结构,为了实现悬浮效果,决定将整个页面包装进一个Stack组件中,你需要准确的计算每个widget显示的位置,非常侵入性、必须要严谨、容易出错,并且直觉告诉你这么做可能是错误的,有其他的实现方式吗?

方案就是:你可以使用Flutter已经提供好的StackOverlay

在这片文章中,我将会介绍如何使用Overlay,来创建悬浮在其他widget之上的widgets,并且并不需要重构你的整个页面。

你可以使用Overlay来展示自动匹配的建议选项,小提示,或着基本上所有的浮动的东西。

Overlay是什么?

官方文档这样定义

A Stack of entries that can be managed independently.
// 一个可以独立管理的Stack子类
Overlays let independent child widgets “float” visual elements on top of other widgets by inserting them into the overlay’s Stack.
// 通过将可独立管理的子节点widgets加入到`overlay`的栈中,Overlays可以将这些widgets浮动展示到显现的elements节点的顶部,

这看起来就是我们正在寻找的内容,当我们创建MaterialApp的时候,它会自动创建一个NavigatorNavigator则又会创建一个Overlay:一个navigator用来管理所展示的views视图的Stack组件

接下来,让我们一起看看怎么使用Overlay来解决我们的问题吧。

注意:这篇文章的核心是介绍如何显示浮动widgets,因此不会涉及太多如何实现自动补全文本输入框textfield的细节,如果你对一个编写良好、可高度自定义的自动补全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
            },
          )
        ],
      ),
    ),
  ),
)

*一个简单页面,包含了三个文本输入框:country、city、address。

然后我们就以country 文本输入框为例吧,将它封装成一个我们自己的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'
      ),
    );
  }
}

接下来我们将要做的是,每次当选中文本输入框获取焦点Focus的时候,将一个浮动的list列表展示出来。当失去焦点Focus的时候,再将它隐藏起来,当然你可以按照自己需求来决定如何实现,你可能需要在用户输入了一些文字后展示它,或者当用户点击Enter按钮的时候再隐藏。无论怎样,让我们先看看如何展示这个悬浮的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绑定了一个FocusNode,并且在initState里面给FocusNode添加了一个监听事件,通过监听事件来获取什么时候获得/失去焦点focus。
  • 每次当我们获取焦点 (_focusNode.hasFocus == true)的时候, 我们通过_createOverlayEntry创建一个OverlayEntry实例对象,然后通过使用Overlay.of(context).insert,将它插入到最邻近的Overlaywidget中去。
  • 每次当我们失去焦点(_focusNode.hasFocus == false)的时候,我们通过使用_overlayEntry.remove来移除刚才添加的overlay实例。
  • _createOverlayEntry通过使用context.findRenderObject来获取我们的widget所在的渲染对象RenderBox,渲染对象里包含位置position、大小size、和一些其他关于渲染的信息,有了这些信息能够帮助我们计算在哪里展示我们的悬浮列表。
  • _createOverlayEntry通过渲染信息来获取当前widget的大小,也可以使用renderBox.localToGlobal来获取当前widget在屏幕上的坐标。我们将localToGlobal设置为Offset.zero 这意味着我们将在渲染对象中使用(0,0)坐标,并且将他们转换为屏幕上相对应的坐标。
  • 接着我们创建了OverlayEntry,这时一个用来将widgets展示到Overlay中的widget。
  • 当前创建的OverlayEntry的是一个Positionedwidget。请牢记Positioned widgets 只能被插入到Stack中,当然Overlay其实也是一个Stack
  • 我们设置Positionedwidget的坐标,给它和TextField相同的X轴坐标,相同的宽度,相同的Y轴坐标,当然为了不遮挡到TextField,在底部进行了一些偏移。
  • Positioned内部,我们设置了一个展示建议选项的ListView(里面默写了例子中的一些国家)。注意到我把所有的内容都包在了Material中,关于这样写有两个原因:Overlay默认不包含Materialwidget,并且很多widgets如果没有有Material祖先节点的话不能展示,除此之外Material还提供了elevation属性,可以让我们给widget设置隐形效果,看起来就像真正浮在上面一样。

以上,我们的建议选择项可以浮在所有widget之上了!

彩蛋:跟随widget滑动!

在我们离开之前,让我们在多学喜一点吧!假如我们的页面是可以滚动,我们可能注意到如下现象:


overlay3.gif

建议选择列表固定在了屏幕上!在某些场景下你可能的确需要固定的,但是在当前场景中,我们不想要它固定,我们想要它跟随我们的TextField一起滚动!

关键词滚动,Flutter给我们提供了两个widget:CompositedTransformFollowerCompositedTransformTarget,简单介绍就是,如果我们关联起一个follower和一个target,那么无论target滚动到哪里,这个follower将跟随它一起滚动!为了关联起一个follower和一个target,我们需要给他们设置相同的LayerLink.

因此我们需要将建议选择列表用CompositedTransformFollower包起来,将TextFieldCompositedTransformTarget包起来。然后我们将他们使用想用的LayerLink关联起来,这样就可以是建议选择列表跟随TextField一起滑动了:

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包裹进OverlayEntry中,将TextFormField包裹进CompositedTransformTarget中。
  • 我们使用相同的LayerLink示例,来关联followertarget,这样followertarget将会在相同的坐标系中,高效的跟随target而动。
  • Positionedwidget中移除了topleft属性,因为默认followertarget有相同的坐标,因此不在需要。然而我们保留了width属性,因为如果不设置的话,follower将会无限的延伸。
  • 为了不遮挡TextFormField,我们给CompositedTransformFollower设置了一个offset(和之前一样的原因)。
  • 最后,我们将showWhenUnlinked属性设置为false,当TextFormField在屏幕上不可见时,用来隐藏OverlayEntry(比如我们滑动出屏幕底部很远的时候)。

经过这些操作,我们的OverlayEntry现在可以跟随我们的TextField一起滚动啦!

overlay4.gif

重要提示:CompositedTransformFollower仍然有一点小bug,当target不可见时,即使follower已经从屏幕上隐藏了,这个follower仍然会响应点击事件,我已经给Flutter Team提了issue,此issue已经关闭,标记为解决。

Overlay是一个强大的widget,它给我们提供了一个简单易用的的用来展示浮动widget的的Stack组件。我用它成功的创建了flutter_typeahead,我确定你也能在多样化的需求中使用到它。

希望这篇文章能够帮助到你,欢迎与我交流。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342

推荐阅读更多精彩内容