Building a custom dropdown in Flutter

josuestuff

Josué Martínez

Posted on September 9, 2024

Building a custom dropdown in Flutter

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 any CompositedTransformFollower widgets that are subsequently composited in the same frame and were given the same LayerLink 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();


Enter fullscreen mode Exit fullscreen mode

Then, we place our OverlayPortal widget anywhere we need it in our build method or somewhere else:



OverlayPortal(
  controller: _controller,
  overlayChildBuilder: (context) => const Placeholder(),
),


Enter fullscreen mode Exit fullscreen mode

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'),
    ),
  ),
),


Enter fullscreen mode Exit fullscreen mode

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'),
    ),
  ),
),


Enter fullscreen mode Exit fullscreen mode

And that's it, we have the base implementation, but if you try that right now, you will notice some issues:

  1. The Placeholder is on top of the button
  2. 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'),
    ),
  ),
),


Enter fullscreen mode Exit fullscreen mode

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'),
    ),
  ),
),

Enter fullscreen mode Exit fullscreen mode




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:

Image description

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!

💖 💪 🙅 🚩
josuestuff
Josué Martínez

Posted on September 9, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related