Why navigation matters in Flutter
Most real apps have more than one screen: a list of items, a detail view, and a screen to add or edit data. In Flutter, navigation is usually handled with a stack: when you open a new screen, it is pushed on top; when you go back, it is popped off. The main tool is Navigator, and you can navigate either with direct routes (creating a MaterialPageRoute) or with named routes (a string route name mapped to a widget builder).
The mental model: a stack of routes
- Push: open a new screen by adding a route to the stack.
- Pop: close the current screen by removing the top route.
- Passing data: provide constructor arguments to the next screen (or use named route arguments).
- Returning results:
pushreturns aFuture; when the pushed screen callspop(result), the previous screen receives that result.
Step-by-step: build a 3-screen mini app
You will build a tiny “Notes” flow with three screens:
- NotesListScreen: shows a list of notes and a button to add.
- NoteDetailScreen: shows one note and allows editing.
- EditNoteScreen: edits or creates a note and returns the updated text.
Step 1: Define a simple model
Keep the data structure minimal so you can focus on navigation. This example uses an in-memory list.
class Note { final int id; String text; Note({required this.id, required this.text});}Step 2: Organize routes in one place (named routes)
Named routes help keep navigation consistent and avoid scattering route strings throughout your code. Create a small routes helper and a single onGenerateRoute function to handle arguments.
class AppRoutes { static const notes = '/'; static const detail = '/detail'; static const edit = '/edit';}Route<dynamic> onGenerateRoute(RouteSettings settings) { switch (settings.name) { case AppRoutes.notes: return MaterialPageRoute(builder: (_) => NotesListScreen()); case AppRoutes.detail: final note = settings.arguments as Note; return MaterialPageRoute(builder: (_) => NoteDetailScreen(note: note)); case AppRoutes.edit: final args = settings.arguments as EditArgs; return MaterialPageRoute(builder: (_) => EditNoteScreen(args: args)); default: return MaterialPageRoute( builder: (_) => const Scaffold( body: Center(child: Text('Route not found')), ), ); }}For edit, we’ll pass a small argument object so the screen can handle both “add” and “edit” modes cleanly.
- Listen to the audio with the screen off.
- Earn a certificate upon completion.
- Over 5000 courses for you to explore!
Download the app
class EditArgs { final String initialText; final String title; const EditArgs({required this.initialText, required this.title});}Step 3: Wire routes into MaterialApp
Use initialRoute and onGenerateRoute. This keeps route creation centralized and supports typed arguments.
class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( initialRoute: AppRoutes.notes, onGenerateRoute: onGenerateRoute, ); }}Screen 1: Notes list (push to detail, push to add)
This screen demonstrates two navigation styles:
- Direct route push (inline
MaterialPageRoute) for quick navigation. - Named route push for organized navigation with arguments.
Below, we’ll use named routes to open detail and edit screens, and we’ll also show a direct push example.
class NotesListScreen extends StatefulWidget { @override State<NotesListScreen> createState() => _NotesListScreenState();}class _NotesListScreenState extends State<NotesListScreen> { final List<Note> _notes = [ Note(id: 1, text: 'Buy milk'), Note(id: 2, text: 'Read Flutter docs'), ]; int _nextId = 3; Future<void> _addNote() async { final result = await Navigator.pushNamed( context, AppRoutes.edit, arguments: const EditArgs(initialText: '', title: 'Add Note'), ); if (result is String && result.trim().isNotEmpty) { setState(() { _notes.add(Note(id: _nextId++, text: result.trim())); }); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Notes')), floatingActionButton: FloatingActionButton( onPressed: _addNote, child: const Icon(Icons.add), ), body: ListView.builder( itemCount: _notes.length, itemBuilder: (context, index) { final note = _notes[index]; return ListTile( title: Text(note.text), onTap: () async { await Navigator.pushNamed( context, AppRoutes.detail, arguments: note, ); setState(() {}); }, ); }, ), ); }}Direct push example (MaterialPageRoute)
If you want a quick one-off navigation without route names, you can push a route directly. This is functionally fine for small apps, but named routes scale better.
Navigator.push( context, MaterialPageRoute(builder: (_) => NoteDetailScreen(note: note)),);Screen 2: Detail screen (pop, push to edit, return result)
The detail screen receives a Note via its constructor. It can navigate to the edit screen and wait for a result. When the edit screen returns a new text value, the detail screen updates the note and refreshes.
class NoteDetailScreen extends StatefulWidget { final Note note; const NoteDetailScreen({super.key, required this.note}); @override State<NoteDetailScreen> createState() => _NoteDetailScreenState();}class _NoteDetailScreenState extends State<NoteDetailScreen> { Future<void> _edit() async { final result = await Navigator.pushNamed( context, AppRoutes.edit, arguments: EditArgs(initialText: widget.note.text, title: 'Edit Note'), ); if (result is String && result.trim().isNotEmpty) { setState(() { widget.note.text = result.trim(); }); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Detail'), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), ), body: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(widget.note.text, style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 16), ElevatedButton( onPressed: _edit, child: const Text('Edit'), ), ], ), ), ); }}Notice the pattern:
final result = await Navigator.push...waits for the next screen to close.- The edit screen will close with
Navigator.pop(context, updatedText). - After the await, you update state based on the returned value.
Screen 3: Edit/Add screen (return a value with pop)
This screen is responsible for producing a result. It receives initial text and a title via EditArgs. When the user taps Save, it returns the text to the previous screen.
class EditNoteScreen extends StatefulWidget { final EditArgs args; const EditNoteScreen({super.key, required this.args}); @override State<EditNoteScreen> createState() => _EditNoteScreenState();}class _EditNoteScreenState extends State<EditNoteScreen> { late final TextEditingController _controller; @override void initState() { super.initState(); _controller = TextEditingController(text: widget.args.initialText); } @override void dispose() { _controller.dispose(); super.dispose(); } void _save() { Navigator.pop(context, _controller.text); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(widget.args.title)), body: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ TextField( controller: _controller, decoration: const InputDecoration(labelText: 'Note text'), ), const SizedBox(height: 16), Row( children: [ Expanded( child: ElevatedButton( onPressed: _save, child: const Text('Save'), ), ), const SizedBox(width: 12), Expanded( child: OutlinedButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), ), ], ), ], ), ), ); }}Passing data: constructor vs named route arguments
There are two common ways to pass data to a screen:
- Constructor arguments: best when you create the route directly with
MaterialPageRoute. Example:NoteDetailScreen(note: note). - Named route arguments: best when you centralize route creation. Example:
Navigator.pushNamed(context, AppRoutes.detail, arguments: note), then read it inonGenerateRoute.
In both cases, you should keep the data types explicit and predictable. If you pass multiple values, prefer a small argument class (like EditArgs) instead of a loosely-typed Map.
Returning results: the key pattern
Returning results is what makes multi-screen flows feel connected (add/edit flows, pickers, confirmations). The pattern is:
- Screen A:
final result = await Navigator.push(...) - Screen B:
Navigator.pop(context, result) - Screen A: handle
resultand update state
// Screen Aawait Navigator.pushNamed(context, AppRoutes.edit, arguments: ...);// Screen BNavigator.pop(context, 'new value');Simple route organization tips
- Keep route names in one class (like
AppRoutes) to avoid typos. - Use onGenerateRoute when you need typed arguments and a single place to build routes.
- Keep screens focused: list shows collection, detail shows one item, edit returns a value.
- Be consistent: either mostly named routes or mostly direct routes; mixing is fine, but consistency helps maintainability.
Practice task: implement a detail page that returns a value
Goal: practice both navigation and returning results by updating the list screen after viewing details.
Task requirements
- Create a ProductListScreen that shows a list of product names.
- Create a ProductDetailScreen that shows the product name and a button: “Mark as Favorite”.
- When the user taps “Mark as Favorite”, the detail screen should pop and return
true. - Back on the list screen, if the result is
true, update that product row (for example, append “★” to the name or show a trailing icon).
Suggested implementation steps
- In the list screen, use
final result = await Navigator.push(...)when opening the detail screen. - In the detail screen, call
Navigator.pop(context, true)when the button is pressed. - In the list screen, check
if (result == true)and callsetStateto update the UI.
Starter code skeleton
// In ProductListScreen onTap:final result = await Navigator.push( context, MaterialPageRoute( builder: (_) => ProductDetailScreen(productName: name), ),);if (result == true) { setState(() { // mark favorite in your list data });}// In ProductDetailScreen button:onPressed: () { Navigator.pop(context, true);}