A saved setting needs a quiet acknowledgement, deleting an account needs an explicit decision, and choosing from a long list needs more room than either can provide. Flutter supplies three distinct feedback surfaces for those jobs: SnackBar, AlertDialog, and a modal bottom sheet.
This tutorial builds all three patterns in one runnable screen. You will add an undoable floating Snackbar, await a typed result from a confirmation dialog, and present a draggable, scrollable sheet that returns the selected item.
The examples require only Flutter's Material library. The important design rule is to match the interruption level to the decision: transient information should not block the screen, while destructive actions should not disappear into a temporary message.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowMatch the feedback surface to the user's task
A Snackbar reports something that has already happened or offers a brief reversal, such as undoing an archive action. It should not contain information that users must remember, and it is a poor place for several competing actions because it disappears.
A dialog deliberately interrupts interaction with the underlying route. Reserve that interruption for a required choice, confirmation, or compact input. If users can safely continue without responding, a dialog is probably too forceful.
A modal bottom sheet remains contextual but offers substantially more room. It suits action menus, filters, pickers, and supporting details. A draggable sheet is especially useful when the content should begin as a compact preview and expand for browsing.
- Snackbar: “Saved”, “Message sent”, or “Item removed” with an undo action.
- Dialog: “Delete this account?” when proceeding has serious consequences.
- Bottom sheet: a list of destinations, sharing methods, filters, or account actions.
Run all three patterns from one Material screen
The following app provides a complete baseline. Each asynchronous surface returns control to the same State object, while the status text makes its result visible.
import 'package:flutter/material.dart';
void main() {
runApp(const FeedbackPatternsApp());
}
class FeedbackPatternsApp extends StatelessWidget {
const FeedbackPatternsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.indigo,
useMaterial3: true,
),
home: const FeedbackPatternsDemo(),
);
}
}
class FeedbackPatternsDemo extends StatefulWidget {
const FeedbackPatternsDemo({super.key});
@override
State<FeedbackPatternsDemo> createState() {
return _FeedbackPatternsDemoState();
}
}
class _FeedbackPatternsDemoState extends State<FeedbackPatternsDemo> {
String _status = 'No feedback shown yet';
void _showArchiveMessage() {
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
content: const Text('Conversation archived'),
behavior: SnackBarBehavior.floating,
action: SnackBarAction(
label: 'UNDO',
onPressed: () {
setState(() {
_status = 'Archive cancelled';
});
},
),
),
);
setState(() {
_status = 'Conversation archived';
});
}
Future<void> _confirmDeletion() async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Delete account?'),
content: const Text(
'Your profile and saved projects will be permanently removed.',
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(dialogContext, false);
},
child: const Text('Keep account'),
),
FilledButton(
onPressed: () {
Navigator.pop(dialogContext, true);
},
child: const Text('Delete'),
),
],
);
},
);
if (!mounted || shouldDelete != true) {
return;
}
setState(() {
_status = 'Account deletion confirmed';
});
}
Future<void> _chooseWorkspace() async {
final workspace = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.45,
minChildSize: 0.30,
maxChildSize: 0.90,
builder: (context, scrollController) {
return ListView(
controller: scrollController,
children: [
const ListTile(
title: Text('Choose a workspace'),
subtitle: Text('Drag upwards to see the complete list'),
),
...List.generate(15, (index) {
final name = 'Workspace ${index + 1}';
return ListTile(
leading: const Icon(Icons.work_outline),
title: Text(name),
onTap: () {
Navigator.pop(sheetContext, name);
},
);
}),
],
);
},
);
},
);
if (!mounted || workspace == null) {
return;
}
setState(() {
_status = 'Selected $workspace';
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Feedback patterns')),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
Text(_status, key: const Key('status')),
const SizedBox(height: 24),
FilledButton(
onPressed: _showArchiveMessage,
child: const Text('Archive conversation'),
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _confirmDeletion,
child: const Text('Delete account'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: _chooseWorkspace,
child: const Text('Choose workspace'),
),
],
),
);
}
}
Both showDialog and showModalBottomSheet push modal routes and return Futures. Awaiting those Futures keeps the result handling beside the code that opened the surface instead of distributing it across callbacks.
Show an actionable floating Snackbar with ScaffoldMessenger
Use ScaffoldMessenger.of(context).showSnackBar, not Scaffold.of(context).showSnackBar. The messenger owns Snackbars across Scaffold transitions, allowing feedback to survive a route change rather than being tied to one ScaffoldState.
void showSavedMessage(BuildContext context) {
final messenger = ScaffoldMessenger.of(context);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: const Text('Draft saved'),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
action: SnackBarAction(
label: 'REVIEW',
onPressed: () {
// Open the saved draft or update local state here.
},
),
),
);
}
SnackBarBehavior.floating separates the bar from the bottom edge and permits a margin. This is useful around bottom navigation or when the message should read as a temporary card. Calling hideCurrentSnackBar first prevents a rapid sequence of events from building a stale queue, although queued messages may be preferable when every event must be announced.
Keep the action label short and make the callback meaningful. An undo operation should actually restore the affected state; it should not merely display another acknowledgement.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowReturn a typed decision from AlertDialog
showDialog<bool> produces a Future<bool?>. Each button closes the dialog with Navigator.pop(dialogContext, value), and the caller awaits that value before performing destructive work.
final approved = await showDialog<bool>(
context: context,
barrierDismissible: true,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Discard changes?'),
content: const Text('Your unsaved edits cannot be recovered.'),
actions: [
TextButton(
onPressed: () {
Navigator.pop(dialogContext, false);
},
child: const Text('Continue editing'),
),
FilledButton(
onPressed: () {
Navigator.pop(dialogContext, true);
},
child: const Text('Discard'),
),
],
);
},
);
if (!mounted || approved != true) {
return;
}
// Discard the draft here.
With barrier dismissal enabled, tapping outside the dialog may return null. Treating only true as approval makes cancellation, barrier dismissal, and the system back action safe by default. Set barrierDismissible: false only when dismissing without an answer would leave the application in an invalid state.
Dialog copy should name both the action and its consequence. Generic buttons such as “Yes” and “No” force users to reread the question, while “Discard” and “Continue editing” remain understandable on their own.
Combine a modal bottom sheet with DraggableScrollableSheet
A normal modal sheet is sized around its child. Setting isScrollControlled: true allows the route to use more of the screen and is the required foundation for a sheet intended to grow with DraggableScrollableSheet.
final choice = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.5,
minChildSize: 0.25,
maxChildSize: 0.95,
builder: (context, controller) {
return ListView.builder(
controller: controller,
itemCount: 30,
itemBuilder: (context, index) {
final label = 'Result ${index + 1}';
return ListTile(
title: Text(label),
onTap: () {
Navigator.pop(sheetContext, label);
},
);
},
);
},
);
},
);
if (!mounted || choice == null) {
return;
}
setState(() {
_status = 'Selected $choice';
});
The inner scrollable must use the ScrollController supplied by the draggable sheet. If it creates its own controller, the list may scroll independently while the sheet refuses to expand as expected. Keep minChildSize, initialChildSize, and maxChildSize between zero and one, in ascending order.
useSafeArea: true keeps content away from system intrusions. If the sheet includes a text field, also pad its content by MediaQuery.viewInsetsOf(context).bottom so the keyboard does not cover the active control.
Use the context belonging to the correct route
A BuildContext describes a location in the widget tree, not the screen in general. The context passed to ScaffoldMessenger.of must have a ScaffoldMessenger ancestor. A context above MaterialApp, or one retained after its widget has been removed, cannot find that ancestor safely.
The builders for dialogs and sheets receive contexts inside their newly created routes. Use those local contexts with Navigator.pop; this makes it clear that you are closing the overlay rather than the underlying page.
final result = await showDialog<String>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Apply preset?'),
actions: [
TextButton(
onPressed: () {
Navigator.pop(dialogContext, 'cancelled');
},
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.pop(dialogContext, 'applied');
},
child: const Text('Apply'),
),
],
);
},
);
if (!mounted) {
return;
}
setState(() {
_status = result ?? 'dismissed';
});
The mounted check matters because the user can leave the page while an overlay or other awaited operation is active. Never call setState, open another route, or look up inherited widgets through a disposed State's context.
Preserve accessibility and predictable dismissal
Material feedback widgets provide semantics and focus behaviour, but the content still needs clear language. Avoid communicating success or failure through colour alone, keep destructive labels explicit, and ensure controls remain large enough to activate comfortably.
A Snackbar is announced but temporary, so essential error recovery should also remain available on the screen. Dialog focus is contained within the modal route until it closes. Bottom-sheet content must remain reachable by scrolling at large text scales; fixed-height columns commonly overflow when text size increases.
- Keep Snackbar messages concise and allow enough duration for the action to be discovered.
- Use a dialog title that identifies the decision rather than a vague warning.
- Provide a visible heading or explanatory first row in a bottom sheet.
- Handle back navigation, barrier taps, and null results as deliberate cancellation paths.
Avoid feedback patterns that fight one another
Do not open a confirmation dialog and then repeat the same decision in a bottom sheet. Stacking modal surfaces obscures navigation and makes dismissal ambiguous. Close the current overlay before presenting another one.
Other frequent mistakes include calling showSnackBar from a context above MaterialApp, forgetting to await a dialog result, treating null as approval, and placing an unbounded list directly inside a sheet. A DraggableScrollableSheet with the supplied controller resolves the long-list case without a brittle fixed pixel height.
Also avoid launching feedback during build. Build can run repeatedly, producing duplicate Snackbars or route exceptions. Trigger surfaces from user callbacks, state-listener transitions, or a post-frame callback when presentation genuinely depends on the first completed layout.
Verify the action and returned state with a widget test
Visual inspection should cover small screens, landscape orientation, large text, barrier dismissal, the system back action, and repeated rapid taps. A widget test can then prove that the Snackbar appears and that its action changes application state.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:feedback_demo/main.dart';
void main() {
testWidgets('Snackbar undo reports the cancelled archive', (tester) async {
await tester.pumpWidget(const FeedbackPatternsApp());
await tester.tap(find.text('Archive conversation'));
await tester.pump();
expect(find.text('Conversation archived'), findsWidgets);
expect(find.text('UNDO'), findsOneWidget);
await tester.tap(find.text('UNDO'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Archive cancelled'), findsOneWidget);
});
}
Replace feedback_demo with the package name from your project's pubspec.yaml. Add separate tests that tap each dialog action, dismiss the dialog through the barrier, select a sheet row, and close the sheet without selecting anything. Those paths confirm that only an explicit affirmative result performs destructive work.
Further reads
Keep going with these related tutorials from this site.
- Flutter: The Complete Guide — follow the complete Flutter learning path
- Flutter Material Widgets Catalogue — compare more Material surfaces and controls
- Flutter Layout Widgets Guide — understand constraints around sheets and scrollable content
- Flutter Animations Complete Guide — design purposeful transitions around feedback
- Flutter Interview Questions — review navigation, context, and widget lifecycle concepts