Popups in Flutter the Right Way

Popups in Flutter the Right Way

Effortlessly create custom Flutter popups, from dropdowns to dialogs, using only the SDK. Achieve perfectly aligned designs without extra libraries.

Full code for app that uses Popup
Full code for app that uses Popup. GitHub Gist: instantly share code, notes, and snippets.

Common challenges

Positioning pop-ups is a complex task, with factors such as device rotation and window resizing adding to the challenge. Also, sometimes, the position of the popup needs to be updated frequently (e.g. sliders).

Popups are often implemented using overlays. Managing them directly adds complexity due to their separate lifecycle and limited access to a parent's context because of higher placement in the tree.

OverlayEntry, an imperative API, requires hands-on management of its lifecycle through 'insert' and 'delete' methods. It can outlive its 'parent' widget, which is often higher in the overlay structure. It also lacks access to the 'parent' context, as it is not its direct child.

  final overlayEntry = OverlayEntry(
    // Potentially, a *root* context that doesn't
    // have an access to InheritedWidgets.
    builder: (BuildContext context) {
      return const Text('Hello, World!');
    },
  );

  // Insert the overlay using Overlay.of(context).insert().
  Overlay.of(context).insert(overlayEntry);

  // Remove the overlay using overlayEntry.remove().
  overlayEntry.remove();
💡
Actually, there is a hustle-free way to overcome these obstacles!

OverlayPortal is a modern successor for OverlayEntry. It provides declarative show and hide APIs. The rest of Overlay management is handled for us behind the scenes. See what docs tell us about OverlayPortal:

A widget that renders its overlay child on an Overlay.

The overlay child is initially hidden until OverlayPortalController.show is called on the associated controller. The OverlayPortal uses overlayChildBuilder to build its overlay child and renders it on the specified Overlay as if it was inserted using an OverlayEntry, while it can depend on the same set of InheritedWidgets (such as Theme) that this widget can depend on.
💡
Some terminology: 'target' is the main widget that triggers the popup, while the 'follower' is the popup widget that appears in response

To effectively position elements like a dropdown, we utilize the CompositedTransformTarget and CompositedTransformFollower widgets. This approach automatically aligns the follower widget with specified "anchors," ensuring precise placement.

Anchors of a follower and a target

There is a widget named "Popup" that efficiently incorporates the above-mentioned elements. Here's the code defining it:

The "Popup" widget utilizes four key components: the follower, target, anchors, and a controller for managing the overlay's visibility.

Here's how it works:

  1. Overlay Management: The OverlayPortal uses a controller to toggle the follower's visibility.
  2. Linking Elements: A CompositedTransformTarget, wrapped around the OverlayPortal, employs a 'link'. This link connects the target and follower.
  3. Positioning the Follower: The overlayChildBuilder function generates a CompositedTransformFollower. This component positions the follower based on specified anchor values.
💡
Note that the popup is wrapped in Align. Basically, it can be any widget that fills space. If you omit it, your popup will fill the space on the screen.

To make the Follower centered under the Target we need to set the followerAnchor to topCenter and targetAnchor to bottomCenter.


Help Container Popup

Let's start from "Help" container and create a popup for it. Let's create a widget:

class _HelpButton extends StatelessWidget {
  const _HelpButton(this.controller);

  final OverlayPortalController controller;

  @override
  Widget build(BuildContext context) {
    return Popup(
      follower: _HelpOverlay(controller.hide),
      followerAnchor: Alignment.topRight,
      targetAnchor: Alignment.topRight,
      controller: controller,
      child: OutlinedButton(
        style: OutlinedButton.styleFrom(
          padding: const EdgeInsets.symmetric(horizontal: 8),
        ),
        onPressed: controller.show,
        child: Row(
          children: [
            const Icon(
              Icons.help_outline,
              color: Color(0xFF3F45C4),
            ),
            const SizedBox(width: 4),
            Text(
              'Help',
              style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                    fontFamily: 'Helvetica',
                    fontWeight: FontWeight.w700,
                  ),
            ),
          ],
        ),
      ),
    );
  }
}

This widget takes the OverlayPortalController and passes it to the popup. It renders the outlined button that shows a follower on tap. The follower is positioned in the upper right corner. The following code defines the successor:

class _HelpOverlay extends StatelessWidget {
  const _HelpOverlay(this.hide);

  final VoidCallback hide;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 200,
      child: Card(
        margin: EdgeInsets.zero,
        surfaceTintColor: Colors.white,
        elevation: 4,
        shape: const ContinuousRectangleBorder(
          borderRadius: BorderRadius.all(Radius.circular(28)),
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            ...
          ],
        ),
      ),
    );
  }
}

It takes the hide callback that is invoked when the close button is pressed.

💡
It is recommended for popups to have defined sizes.

Now the widget is ready to be used in the application, e.g. in the AppBar:

Scaffold(
  appBar: AppBar(
    centerTitle: false,
    title: Text(
      'Upgrade Plan',
      style: Theme.of(context).textTheme.headlineSmall?.copyWith(
            fontFamily: 'Helvetica',
            fontWeight: FontWeight.w700,
          ),
    ),
    actions: [_HelpButton(_infoPopupController)],
  ),
);

How this works

The Popup widget makes use of OverlayPortal, which is a more sophisticated alternative to OverlayEntry and provides declarative APIs using the OverlayPortalController.

The main difference between OverlayEntry and OverlayPortal is that the latter builds a child widget of itself, while OverlayEntry builds its widget subtree as a child of the target Overlay somewhere up in the tree.

CompositedTransformTarget and CompositedTransformFollower go to the root of the engine layers. They use fairly simple Proxy Render Objects that apply transformations to actually align the follower to the target.

💡
All these APIs are hidden and we only use the Popup widget with controller that exposes show/hide methods.

Card Selector Popup

Let's apply the popup widget to create the card selector popup. Again, the target is just a basic text button:

Popup(
  follower: _CardSelectorOverlay((value) {
    selectedCardChanged(value);
    controller.hide();
  }),
  controller: controller,
  followerAnchor: Alignment.bottomLeft,
  targetAnchor: Alignment.bottomLeft,
  child: SizedBox(
    height: 48,
    child: Align(
      alignment: Alignment.centerLeft,
      child: TextButton.icon(
        label: Text(
          selectedCard != null ? '**** **** **** 1234' : 'Select card',
          style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                fontFamily: 'Helvetica',
                fontWeight: FontWeight.w700,
              ),
        ),
        onPressed: controller.show,
        icon: _iconForCard(selectedCard),
      ),
    ),
  ),
);

When button is clicked the popup is shown as well. See how it is implemented in this case:

SizedBox(
      width: 250,
      child: Material(
        borderRadius: const BorderRadius.all(Radius.circular(28)),
        elevation: 20,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            ListTile(
              ...
              onTap: () => selectedCardChanged('visa'),
            ),
            ListTile(
              ...
              onTap: () => selectedCardChanged('mastercard'),
            ),
          ],
        ),
      ),
    )

Basically, it is a column with two list tiles. It also has some logic with callbacks to return the chosen card.


Final thoughts

The Popup widget in Flutter leverages OverlayPortal and CompositedTransform widgets for advanced popup creation, offering a more efficient solution than direct OverlayEntries management.

Please write in the comments how useful this article was to you!

Full code for app that uses Popup
Full code for app that uses Popup. GitHub Gist: instantly share code, notes, and snippets.

Read more