Flutter Custom Dialogs and Bottom Sheets: A Practical Guide
A practical guide to building custom dialogs and bottom sheets in Flutter, with patterns, animations, keyboard-safe layouts, theming, and testing tips.
Image used for representation purposes only.
Overview
Custom dialogs and bottom sheets are small, focused surfaces that help users confirm actions, enter quick data, or view contextual details without leaving the current screen. In Flutter, you can build them with high polish using the stock APIs and a few reusable patterns. This guide walks through when to choose each pattern, how to implement accessible, keyboard-safe layouts, add animations, return results, theme consistently, and test confidently.
Dialog vs. Bottom Sheet: When to Use Each
- Use a dialog when you need an immediate decision, short form input, or critical confirmation that blocks the flow.
- Use a modal bottom sheet when the task is related to the current screen, can be dismissed by swiping, and may need more vertical space or scrolling.
- Use a persistent bottom sheet when you want a non-modal, anchored panel that stays visible while the user interacts with the rest of the UI.
Heuristics:
- Decision now, two to three actions, or destructive confirmation: dialog.
- Supplemental options, filters, pickers, or long lists: modal bottom sheet.
- Status panel, player controls, or background tasks: persistent bottom sheet.
The Basics: A Simple Confirmation Dialog
Future<bool?> showConfirmDeleteDialog(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete item?'),
content: const Text('This action cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
);
},
);
}
// Usage
final confirmed = await showConfirmDeleteDialog(context);
if (confirmed == true) {
// proceed with delete
}
Tips
- Prefer returning a typed result (e.g., bool or a custom enum).
- For destructive actions, make cancel the least-dangerous default and emphasize the action color for the destructive path.
Building a Custom Dialog Surface
Sometimes you want richer layout, custom shape, or advanced interactions.
Future<String?> showNameDialog(BuildContext context) {
final controller = TextEditingController();
return showDialog<String>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Enter a name', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(hintText: 'Project name'),
textInputAction: TextInputAction.done,
onSubmitted: (_) => Navigator.of(dialogContext).pop(controller.text.trim()),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(controller.text.trim()),
child: const Text('Save'),
),
],
)
],
),
),
);
},
);
}
Notes
- Use the builder-supplied dialogContext when popping. This targets the dialog route, even in nested navigators.
- Set barrierDismissible thoughtfully. For required inputs, force an explicit choice.
Custom Transitions with showGeneralDialog
For brand-specific motion, use showGeneralDialog. Here is a subtle scale and fade.
Future<T?> showScaleDialog<T>(BuildContext context, {required Widget child}) {
return showGeneralDialog<T>(
context: context,
barrierDismissible: true,
barrierLabel: 'Dismiss',
transitionDuration: const Duration(milliseconds: 250),
pageBuilder: (_, __, ___) => Center(child: child),
transitionBuilder: (_, anim, __, child) {
final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut);
final scale = Tween<double>(begin: 0.95, end: 1).animate(
CurvedAnimation(parent: anim, curve: Curves.easeOutBack),
);
return FadeTransition(
opacity: fade,
child: ScaleTransition(scale: scale, child: child),
);
},
);
}
// Usage
await showScaleDialog(
context,
child: Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: const Padding(
padding: EdgeInsets.all(24),
child: Text('On-brand animated dialog'),
),
),
);
Modal Bottom Sheets: The Workhorse
A modal bottom sheet is great for options, pickers, and short forms. To make it feel native and be keyboard-safe, enable isScrollControlled and pad for viewInsets.
Future<String?> openCreateTaskSheet(BuildContext context) {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
Text('Create task', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
const TextField(decoration: InputDecoration(labelText: 'Title')),
const SizedBox(height: 12),
FilledButton(
onPressed: () => Navigator.of(context).pop('created'),
child: const Text('Save'),
),
],
),
),
),
);
},
);
}
Tips
- isScrollControlled lets the sheet grow up to full height for content or keyboard.
- Wrap content in SingleChildScrollView and pad with viewInsets for smooth keyboard avoidance.
- Use useSafeArea to respect notches and system UI.
Draggable, Scrollable, and List-heavy Sheets
Combine showModalBottomSheet with DraggableScrollableSheet to show long lists that expand with a drag gesture.
Future<void> openListSheet(BuildContext context) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.95,
builder: (context, scrollController) {
return Material(
clipBehavior: Clip.antiAlias,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: ListView.builder(
controller: scrollController,
itemCount: 50,
itemBuilder: (_, i) => ListTile(
title: Text('Item $i'),
onTap: () => Navigator.of(context).pop('Item $i'),
),
),
);
},
);
},
);
}
Persistent Bottom Sheets
A persistent sheet stays visible and does not block interaction. Great for toolbars or status panels.
void openPersistentSheet(BuildContext context) {
final scaffold = Scaffold.of(context);
final controller = scaffold.showBottomSheet<void>(
(sheetContext) => SafeArea(
child: Material(
elevation: 12,
child: SizedBox(
height: 64,
child: Row(
children: [
const SizedBox(width: 16),
const Icon(Icons.info_outline),
const SizedBox(width: 12),
const Expanded(child: Text('You are offline')),
TextButton(
onPressed: () => controller.close(),
child: const Text('Dismiss'),
),
const SizedBox(width: 8),
],
),
),
),
),
);
}
Tip
- Ensure the BuildContext passed to Scaffold.of has a Scaffold ancestor (e.g., call inside a widget below Scaffold).
Returning Data and Type Safety
- Always type the generic on showDialog and showModalBottomSheet so you get compile-time checks.
- Return rich results as immutable objects if needed.
class ColorChoice {
final String name;
final int argb;
const ColorChoice(this.name, this.argb);
}
final picked = await showModalBottomSheet<ColorChoice>(
context: context,
builder: (_) => ListView(
children: [
ListTile(
title: const Text('Indigo'),
onTap: () => Navigator.of(context).pop(const ColorChoice('Indigo', 0xFF3F51B5)),
),
// more choices
],
),
);
if (picked != null) {
// use picked.name and picked.argb
}
Theming: Make It Feel Native to Your App
Centralize styles with DialogTheme and BottomSheetThemeData to keep edges, elevation, and colors consistent.
final theme = ThemeData(
useMaterial3: true,
dialogTheme: const DialogTheme(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
bottomSheetTheme: const BottomSheetThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
elevation: 8,
),
);
Guidelines
- Prefer surface and onSurface colors from your ColorScheme.
- Keep motion cohesive: match curves and durations with the rest of your app.
- Provide padding that matches your spacing scale.
Accessibility and UX Essentials
- Focus order: Wrap complex sheets in a FocusScope and set autofocus appropriately.
- Screen readers: Provide semantic labels for custom controls and include barrierLabel in showGeneralDialog.
- Tap targets: Ensure actions are at least 48x48 logical pixels.
- Dismiss affordance: Use a visible drag handle or a clear Close action.
- Edge-to-edge: Respect SafeArea and avoid content hidden by system UI.
Handling the Keyboard Gracefully
- With sheets, set isScrollControlled and add bottom padding equal to viewInsets.bottom.
- Use SingleChildScrollView to avoid overflow when the keyboard is up.
- Prefer TextInputAction.done to submit quickly; close the sheet with Navigator.pop on submit.
Composition Patterns: Reusable API
Create wrapper functions that unify your brand styles and behavior.
Future<T?> showAppDialog<T>(BuildContext context, Widget child) {
return showGeneralDialog<T>(
context: context,
barrierDismissible: true,
barrierLabel: 'Dismiss',
transitionDuration: const Duration(milliseconds: 220),
pageBuilder: (_, __, ___) => Center(child: child),
transitionBuilder: (_, anim, __, child) => FadeTransition(
opacity: CurvedAnimation(parent: anim, curve: Curves.easeOut),
child: child,
),
);
}
Future<T?> showAppSheet<T>(BuildContext context, Widget child) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => child,
);
}
Testing Dialogs and Sheets
Widget tests should verify presentation and result passing.
// Open a sheet, expect content, tap action, and verify it closes.
await tester.tap(find.text('Open Sheet'));
await tester.pumpAndSettle();
expect(find.text('Create task'), findsOneWidget);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Create task'), findsNothing);
Tips
- Use pumpAndSettle to wait for animations to complete.
- If your sheet uses DraggableScrollableSheet, you can drag by gestures in tests to verify behavior.
Common Pitfalls and How to Avoid Them
- Using the wrong context for Navigator.pop: use the builder context if you have nested navigators.
- Overflow when the keyboard opens: enable isScrollControlled and pad with viewInsets.bottom.
- Invisible sheet corners: set a shape and a Material with clipBehavior when you provide a transparent background.
- Too many choices in dialogs: move long lists to a modal bottom sheet.
- Silent destructive actions: pair with a confirmation dialog and use clear, strong affordances.
Performance Considerations
- Build lightweight content; defer heavy async work until after the sheet or dialog opens.
- For long lists, prefer ListView.builder and keep images cached or sized.
- Avoid rebuilding the entire screen when only the sheet changes; isolate state to the sheet.
Wrap-up
Flutter’s dialog and bottom sheet APIs are flexible enough to cover most interaction patterns while remaining accessible and brandable. By returning typed results, composing reusable helpers, handling the keyboard, and theming centrally, you get polished overlays that feel like a natural extension of your app rather than a bolt-on. Keep the content short, the actions clear, and the motion cohesive—and your dialogs and sheets will help users move faster with confidence.
Related Posts
Flutter PDF Generation and Viewing: A Complete, Modern Tutorial
Step-by-step Flutter tutorial to generate, preview, print, share, and save PDFs using pdf and printing packages—complete with code, fonts, images, and tips.
Mastering Flutter Text Field Validation: Patterns, UX, and Code Examples
Robust Flutter text field validation patterns—sync and async rules, input formatters, cross‑field logic, UX tips, and tests with practical Dart code.
Flutter Connectivity Done Right: Reliable Network Status and Online Detection
Learn reliable ways to check network status in Flutter, from connectivity_plus to true internet reachability, with production-ready code and UX tips.