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:

A 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 x coordinate as the TextField, the same width, and the same y 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 follow our 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 LayerLink instance. 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 OverlayEntry when 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:

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!

SAUGO 360

SAUGO 360 is a software and networking solutions company…