Flutter: Using Overlay to display floating widgets

Imagine this: you design your charming form

You send it to your product manager, he looks at it and says: “So I have to type in the whole country name? Can’t you show me suggestions as I type?” and you think to yourself: “Well, he’s right!” So you decide to implement a “typeahead”, an “autocomplete” or whatever you want to call it: A text field that shows suggestions as the user types. You start working.. you know how to get the suggestions, you know how to do the logic, you know everything.. except how to make the suggestions float on top of other widgets.

You think about it; to achieve this you have to redesign your whole screen into a Stack, and then calculate exactly where each widget has to show. Very intrusive, extremely rigorous, incredibly error-prone, and just simply feels wrong. But there is another way..

You could use Flutter’s pre-provided Stack, the Overlay.

In this article I will explain how to use the Overlay widget to create widgets that float on top of everything else, without having to restructure your whole view.

You could use this to create autocomplete suggestions, tooltips, or basically anything that floats

What is the Overlay widget?

The official docs define the Overlay widget as:

Stack of entries that can be managed independently.
Overlays let independent child widgets “float” visual elements on top of other widgets by inserting them into the overlay’s  Stack.

This is exactly what we’re looking for. When we create our MaterialApp, it automatically creates a Navigator, which in turn creates an Overlay; a Stack widget that the navigator uses to manage the display of the views.

So let’s see how to use the Overlay to solve our problem.

Note: This article is concerned with displaying floating widgets, and thus won’t go much into the details of implementing a typeahead (autocomplete) field. If you’re interested in a well-coded, highly customizable typeahead widget, make sure to check out my package, flutter_typeahead

Initial program

Let’s start with the simple form:

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
            },
          )
        ],
      ),
    ),
  ),
)
  • It is a simple view that contains three text fields: country, city and address.

We, then, take the countries field, and abstract it into its own stateful widget that we call 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'
      ),
    );
  }
}

What we will do next is to display a floating list every time the field receives focus, and hide that list whenever focus is lost. You could change that logic depending on your use case. You might want to only display it when the user types some characters, and remove it when the user hits Enter. In all cases, let’s take a look at how to display this floating 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'
      ),
    );
  }
}
  • We assign a FocusNode to the TextFormField, and add a listener to it in initState. We will use this listener to detect when the field gains/loses focus.
  • Every time we receive focus (_focusNode.hasFocus == true), we create an OverlayEntry using _createOverlayEntry, and we insert it into the closest Overlay widget, using Overlay.of(context).insert
  • Every time we lose focus (_focusNode.hasFocus == false), we remove the overlay entry that we have added, using _overlayEntry.remove.
  • _createOverlayEntry inquires for the render box of our widget, using context.findRenderObject function. This render box enables us to know the position, size, and other rendering information of our widget. This will help us later know where to place our floating list.
  • _createOverlayEntry uses the render box to obtain the size of the widget, it also uses renderBox.localToGlobal to get the coordinates of the widget in the screen. We provide the localToGlobal method with Offset.zero, this means that we are taking the (0, 0) coordinates inside this render box, and converting them to their corresponding coordinates on the screen.
  • We then create an OverlayEntry, which is a widget used to display widgets in the Overlay.
  • The content of the OverlayEntry is a Positioned widget. Remember that Positioned widgets can only be inserted in a Stack, but also remember that the Overlay is indeed a Stack.
  • We set the coordinates of the Positioned widget, we give it the same xcoordinate as the TextField, the same width, and the same coordinate but shifted a bit to the bottom in order not to cover the TextField.
  • Inside the Positioned, we display a ListView with the suggestions that we want (I hardcoded a few entries in the example). Notice that I placed everything inside a Material widget. That is for two reasons: because the Overlay does not contain a Material widget by default, and many widgets cannot be displayed without a Material ancestor, and because the Material widget provides the elevation property which allows us to give the widget a shadow to make it look as if it is really floating.

And that’s it! Our suggestions box now floats on top of everything else!

Bonus: Follow the scroll!

Before we leave, let’s try and learn one more thing! If our view is scrollable, then we might notice something:

The suggestions box scrolls with us!

The suggestions box sticks to its place on the screen! Now this might be desired in some cases, but in this case, we don’t want that, we want it to followour TextField!

The key here is the word follow. Flutter provides us with two widgets: the CompositedTransformFollower and the CompositedTransformTarget. Simply put, if we link a follower and a target, then the follower will follow the target wherever it goes! To link a follower and a target we have to provide both of them with the same LayerLink.

Thus, we will wrap our suggestions box with a CompositedTransformFollower, and our TextField with a CompositedTransformTarget. Then, we will link them by providing them with the same LayerLink. This will make the suggestions box follow the TextField wherever it goes:

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'
        ),
      ),
    );
  }
}
  • We wrapped our Material widget in the OverlayEntry with a CompositedTransformFollower, and wrapped our TextFormField with a CompositedTransformTarget.
  • We provided both the follower and the target with the same LayerLinkinstance. This will cause the follower to have the same coordinate space as the target, making it effectively follow it around.
  • We removed the top and left properties from the Positioned widget. These are not needed anymore, since the follower will have the same coordinates as the target by default. We kept the width property of the Positioned, however, because the follower tends to extend infinitely if not bounded.
  • We provided the CompositedTransformFollower with an offset, to disallow it from covering the TextField (same as before)
  • Finally, we set showWhenUnlinked to false, to hide the OverlayEntrywhen the TextField is not visible on the screen (like if we scroll too far to the bottom)

And with that, our OverlayEntry now follows our TextField!

Important Note: The CompositedTransformFollower is still a little buggy; even though the follower is hidden from the screen when the target is no more visible, the follower still responds to tap events. I have opened an issue with the Flutter Team:

CompositedTransformFollower responds to taps even when showWhenUnlinked is false · Issue #21320 ·…
Steps to Reproduce If we have a CompositedTransformFollower that follows a CompositedTransformTarget, and we scroll the…github.com

And will update the post when the issue is resolved


The Overlay is a powerful widget that provides us with a handy Stack to place our floating widgets. I have successfully used it to create flutter_typeahead, and I’m sure you too can use it for a variety of use cases.

I hope this has been useful. Let me know what you think!

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值