skip to content

In Flutter, how do showDialog and showModalBottomSheet return a result to the caller, and which of their defaults most often surprise developers?

level: middleimportance: must knowfreq 55%

answer

  1. a Future of a nullable T
  2. Navigator.pop with a value
  3. null when dismissed
  4. 9/16 height cap on sheets
  5. mounted before using context again

basics

~20 s

Both return a Future<T?> that completes with the value passed to Navigator.pop, or null when the user dismisses them. Surprising defaults: barrierDismissible is true, and a modal sheet is capped at 9/16 of the height unless isScrollControlled is true.

solid answer

~40 s

`showDialog<T>` and `showModalBottomSheet<T>` push a modal route and return a `Future<T?>`. Inside the builder, a button closes it with `Navigator.pop(context, value)` using the builder's context, and the caller gets that value; tapping the barrier, pressing back or dragging a sheet down completes it with `null`, so the result must be treated as nullable. The defaults that bite: `showDialog` has `barrierDismissible: true`; `showModalBottomSheet` has `isScrollControlled: false`, which caps the sheet at 9/16 of the available height, so tall content or a form needs `isScrollControlled: true`; `showDragHandle` is off unless set or themed; and `useSafeArea` is `false` for sheets but `true` for dialogs. After `await`, the caller's `BuildContext` may be gone, so I check `context.mounted` before showing a snack bar or navigating.

code

dart · 44 lines
dart
enum RentalPlan { hourly, day }

Future<void> _pickPlan(BuildContext context) async {
  final plan = await showModalBottomSheet<RentalPlan>(
    context: context,
    isScrollControlled: true, // lift the 9/16 height cap
    showDragHandle: true,
    builder: (sheetContext) => Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        ListTile(
          title: const Text('Hourly'),
          onTap: () => Navigator.pop(sheetContext, RentalPlan.hourly),
        ),
        ListTile(
          title: const Text('Day pass'),
          onTap: () => Navigator.pop(sheetContext, RentalPlan.day),
        ),
      ],
    ),
  );
  if (plan == null || !context.mounted) return; // dismissed, or screen gone

  final confirmed = await showDialog<bool>(
    context: context,
    builder: (dialogContext) => AlertDialog(
      title: const Text('Reserve this bike?'),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(dialogContext, false),
          child: const Text('Cancel'),
        ),
        FilledButton(
          onPressed: () => Navigator.pop(dialogContext, true),
          child: const Text('Reserve'),
        ),
      ],
    ),
  );
  if (confirmed != true || !context.mounted) return; // null means dismissed
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text('Reserved on the ${plan.name} plan')),
  );
}

go deeper

for a junior

Recall that both functions return a Future you await, that Navigator.pop(context, value) sends the value back, and that dismissing gives null.

for a middle

Explain the defaults that bite: barrierDismissible, the 9/16 sheet height cap and isScrollControlled, showDragHandle, useSafeArea, and the mounted check after await.

for a senior

Show production habits: typed results, null treated as cancel, sheets with forms above the keyboard, and flows that survive the opening screen being popped.

for a principal

Decide when a flow belongs in a dialog or sheet at all versus its own route, weighing deep links, back behaviour and how much state the overlay holds.

## How the result travels `showDialog` and `showModalBottomSheet` both push a **modal route** on a `Navigator` and return a `Future` that completes when that route is popped. The type parameter is the result type: 1. The caller writes `final plan = await showModalBottomSheet<RentalPlan>(...)`. 2. Inside the sheet, the 'Choose' button calls `Navigator.pop(sheetContext, RentalPlan.hourly)`. 3. The route pops and the future completes with `RentalPlan.hourly`. 4. If the user taps the scrim, presses back or drags the sheet away, the future completes with `null`. That is why the return type is `Future<T?>` even for `showDialog<bool>`: 'dismissed' is a real outcome, distinct from 'pressed No'. Use the **builder's** context for `Navigator.pop` inside the dialog or sheet; it belongs to the new route. ## Defaults worth memorising | Parameter | `showDialog` | `showModalBottomSheet` | |---|---|---| | barrier or scrim closes it | `barrierDismissible: true` | `isDismissible: true` | | drag to close | not applicable | `enableDrag: true` | | height | sized by its content | capped at 9/16 unless `isScrollControlled: true` | | drag handle | not applicable | `showDragHandle` off unless set or themed | | `useSafeArea` | `true` | `false` | | `useRootNavigator` | `true` | `false` | In Material 3 a modal bottom sheet is also limited to a maximum width of 640, so on a tablet it appears as a centred panel rather than a full-width strip. ## The 9/16 cap With `isScrollControlled: false` a modal sheet's height is limited to a fraction of the available height given by `scrollControlDisabledMaxHeightRatio`, `9 / 16` by default. Content taller than that is clipped or must scroll inside. Setting `isScrollControlled: true` lifts the cap, which is what you want for: - a sheet with a form, which must move up above the keyboard; - a sheet that hosts its own scrollable, such as a list of nearby bikes; - a sheet meant to cover most of the screen. ## Using the result safely An `await` hands control back to the event loop. By the time the dialog closes, the screen that opened it may have been popped. Before using the caller's `BuildContext` again, to show a `SnackBar` or navigate, check `context.mounted` and return if it is false. Other habits: - make the result type explicit, `showDialog<bool>`, so the compiler checks what `pop` passes; - treat `null` as 'cancelled', not as 'no'; - use `barrierDismissible: false` only when the user must pick an option, and still give an explicit cancel button. ## Modal vs persistent sheets `showModalBottomSheet` blocks the screen behind a scrim until closed. A **persistent** sheet, shown with `Scaffold.of(context).showBottomSheet(...)` or the `Scaffold.bottomSheet` slot, stays alongside the content without a barrier, useful for a 'Current ride' panel over the map. It returns a controller rather than a future of a result. ## Bike-rental flow Tapping a bike on the map opens a modal sheet with its battery level and plans. The sheet uses `isScrollControlled: true` and `showDragHandle: true`. Choosing a plan pops the sheet with that plan; the home screen then shows a confirmation `AlertDialog` whose 'Reserve' and 'Cancel' buttons pop `true` or `false`, and a `null` from tapping outside is treated as cancel. ## Common mistakes in review - `Navigator.pop(context)` inside the builder using the **caller's** context captured from outside; use the builder's own context so the pop targets the overlay's route. - Treating `false` and `null` the same without thinking: sometimes 'dismissed' should re-show the dialog or leave state untouched. - Starting network work inside the dialog's `builder`; builders can run more than once, so do the work in response to a button press. - A sheet whose content is a `ListView` without `isScrollControlled: true`, so the list is squeezed into 9/16 of the height and fights the sheet's own drag. - A `barrierDismissible: false` dialog with no cancel button, which leaves the user no way out except finishing the flow.

  • Why does `await showDialog<bool>(...)` give a bool? and not a bool?
    Because the dialog can close without any button: a barrier tap (with the default `barrierDismissible: true`) or the system back gesture pops the route with no value, and the future completes with `null`. Code should test `== true` or handle `null` as cancelled rather than force-unwrapping.
  • How do you let a bottom sheet with a text field move above the keyboard?
    Pass `isScrollControlled: true` so the sheet is not capped at 9/16 of the height, and pad the sheet's content by the keyboard's inset, `MediaQuery.viewInsetsOf(context).bottom`, so the field is not hidden. Without the first, the sheet cannot grow to make room; without the second, the keyboard covers the field.

saying these in an interview costs you the question

  • showDialog returns the value of the last button pressed, never null.
  • A modal bottom sheet grows to fit its content by default.
  • showModalBottomSheet shows a drag handle by default in Material 3.
  • Use the caller's context after await without checking mounted.
  • Set barrierDismissible: false on every dialog so users must choose.