Building a custom dropdown in Flutter
Josué Martínez
Posted on September 9, 2024
Is been almost 3 months since I started working as a Flutter developer in a small startup company and a been learning a lot. So, as usual, today I wanna share one of the things I learned in the past few months: dealing with custom dropdowns.
Since we don't use Flutter's stock dropdowns at work (either Material or Cupertino), I needed to figure out how to make a custom one at least 2 times in the past months and I'm pretty sure I'm not the only one. That said, let's move on.
First things first
In this post I'll use two key widgets to build the dropdown, so before going into the code, let's understand these widgets.
CompositedTransformTarget
This one is really important to simplify the task and the docs says:
When this widget is composited during the compositing phase (which comes after the paint phase, as described in
WidgetsBinding.drawFrame
), it updates the link object so that anyCompositedTransformFollower
widgets that are subsequently composited in the same frame and were given the sameLayerLink
can position themselves at the same screen location.
In short: this widget will hold the global position of any child widget and we can use the CompositedTransformFollower
widget to position any other widget in the same place by using the same LayerLink
. So, I assume you can image how we're gonna use it: to position the "attached" section of the dropdown right below the other one.
OverlayPortal
Right, so, we already solved a problem, but we have another one: working with the raw overlay API is a pain and I know that by experience 💀. So... There's a better and simpler way to put widgets on top of others? Of course: OverlayPortal
, which is part of the overlay API, but way more simple and so we're gonna use it to build the dropdown.
Also, in case you think Stack
would do the job too, please don't do that. I know Stack can also position widgets on top of others, but final code will look very "tricky" as you have to put the widgets in way that they will be for sure on top of the others, leading you to write an absolute mess of code, making it a bit hard to read and work on later.
The other problem with Stack
is that it will lead you to use Positioned
, forcing you to hardcode values, which isn't good if you want your app to (and it should) be responsive no matter if it will run only on smartphones. That method also doesn't take in consideration growable lists, which is a very common pattern in app designs.
Let's start already
Alright, so the process is pretty simple, first we need a LayerLink
and a OverlayPortalController
:
// This controller doesn't have a `dispose` method,
// so you can use an `StatelessWidget`
final _controller = OverlayPortalController();
final _link = LayerLink();
Then, we place our OverlayPortal
widget anywhere we need it in our build
method or somewhere else:
OverlayPortal(
controller: _controller,
overlayChildBuilder: (context) => const Placeholder(),
),
As you can imagine, the overlayChildBuilder
in OverlayPortal
takes a builder function that will be use to build the widget that will be on top of all our other widgets, allowing us to write a separated widget to keep this as clean as possible.
Alright, let's keep going and add a child to the OverlayPortal
, which will be the interactive section, so we also need to wrap it with CompositedTransformTarget
:
OverlayPortal(
controller: _controller,
overlayChildBuilder: (context) => const Placeholder(),
child: CompositedTransformTarget(
link: _link,
child: ElevatedButton(
onPressed: _controller.toggle,
child: const Text('Toggle dropdown'),
),
),
),
Now, let's make the overlay child follow the same positioning as the button:
OverlayPortal(
controller: _controller,
overlayChildBuilder: (context) => CompositedTransformFollower(
// Ensure to use the same `LayerLink`
link: _link,
child: const Placeholder(),
),
child: CompositedTransformTarget(
link: _link,
child: ElevatedButton(
onPressed: _controller.toggle,
child: const Text('Toggle dropdown'),
),
),
),
And that's it, we have the base implementation, but if you try that right now, you will notice some issues:
- The
Placeholder
is on top of the button - The
Placeholder
covers too much space
The first issue is actually pretty easy to fix: just set the targetAnchor
property in CompositedTransformFollower
to Alignment.bottomLeft
. Like this:
OverlayPortal(
controller: _controller,
overlayChildBuilder: (context) => CompositedTransformFollower(
// Ensure to use the same `LayerLink`
link: _link,
targetAnchor: Alignment.bottomLeft,
child: const Placeholder(),
),
child: CompositedTransformTarget(
link: _link,
child: ElevatedButton(
onPressed: _controller.toggle,
child: const Text('Toggle dropdown'),
),
),
),
Now the other issue is a bit more complicated to fix. But let's understand why does that happens: anything that belongs into an overlay in Flutter essentially uses a whole new BuildContext
, which also means the very top widget in that widget tree will be enforced to be as the same size of the viewport and that cannot be changed.
So, to fix that, we need a widget that takes all that available space and allows it's child to be of any desired size. There are few widgets that fits that condition, but in this example we're gonna use UnconstrainedBox
to fix this problem and then we can finally use any sizing widget, a SizedBox
for example, to limit the size of the dropdown widget.
OverlayPortal(
controller: _controller,
overlayChildBuilder: (context) => UnconstrainedBox(
child: CompositedTransformFollower(
// Ensure to use the same LayerLink
link: _link,
targetAnchor: Alignment.bottomLeft,
child: const SizedBox(
width: 200,
height: 200,
child: Placeholder()
),
),
),
child: CompositedTransformTarget(
link: _link,
child: ElevatedButton(
onPressed: _controller.toggle,
child: const Text('Toggle dropdown'),
),
),
),
Almost perfect
Despite this implementation for dropdowns is actually really good and simple, there's still one problem: what if there's insufficient space to show the dropdown?
Let's say you have a dynamic layout where things can change size or position. In that case, your dropdown might not have enough space to show and can clip out of bounds, like this:
This is an important problem because user then couldn't have access to the dropdown menu, leaving them with a really bad UX. And, to be honest, I haven't found an elegant solution to this yet either using external packages or not (maybe visibility_detector
could help but, again, I haven't tried yet).
Final words
Well that's all for today, hope you enjoyed it all! If you liked this, let a comment or react in this post :)
And, of course, here's the entire (but slightly more elaborated) code of this article: https://gist.github.com/Miqueas/4d0b92a15c0cc24fee6ef389250ab027
Have a nice day and see you later!
Posted on September 9, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.