In Flutter, how do showDialog and showModalBottomSheet return a result to the caller, and which of their defaults most often surprise developers?
answer
- a Future of a nullable T
- Navigator.pop with a value
- null when dismissed
- 9/16 height cap on sheets
- mounted before using context again
basics
~20 sBoth 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 linesenum 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
Recall that both functions return a Future you await, that Navigator.pop(context, value) sends the value back, and that dismissing gives null.
Explain the defaults that bite: barrierDismissible, the 9/16 sheet height cap and isScrollControlled, showDragHandle, useSafeArea, and the mounted check after await.
Show production habits: typed results, null treated as cancel, sheets with forms above the keyboard, and flows that survive the opening screen being popped.
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.