赞
踩
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
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
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 }, ) ], ), ), ), )
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' ), ); } }
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._focusNode.hasFocus == true
), we create an OverlayEntry
using _createOverlayEntry
, and we insert it into the closest Overlay
widget, using Overlay.of(context).insert
_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.OverlayEntry
, which is a widget used to display widgets in the Overlay
.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
.Positioned
widget, we give it the same xcoordinate 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
.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!
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' ), ), ); } }
Material
widget in the OverlayEntry
with a CompositedTransformFollower
, and wrapped our TextFormField
with a CompositedTransformTarget
.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.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.CompositedTransformFollower
with an offset, to disallow it from covering the TextField
(same as before)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!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。