Module 10: Navigation and Routing
Teach: How Flutter's Navigator manages a stack of screens, and how to push, pop, and pass data between them. See: A multi-screen product browser where tapping an item opens a detail page, and data flows both directions. Feel: Confident that building multi-screen apps is just a matter of pushing and popping -- like a stack of plates.
The Navigator Is a Stack of Plates
Teach: How Flutter's Navigator works as a stack data structure, using push and pop to manage screens. See: The mental model of screens stacking like cafeteria plates, with push adding and pop removing. Feel: That navigation is not mysterious -- it is just a stack with two operations.
Think about a stack of plates in a cafeteria. You put a plate on top, and when you're done with it, you take it off. That's literally how Flutter navigation works. Every screen in your app sits on a stack. When you navigate forward, you push a new plate on top. When you go back, you pop the top plate off. The screen underneath was there the whole time, just waiting patiently.
Here is the single most important mental model for Flutter navigation: it is a stack. The Navigator widget manages this stack for you, and you interact with it using two primary methods.
Navigator.push -- Adding a Screen
When a user taps something and you want to show a new screen, you push it onto the stack:
Navigator.push(
context,
MaterialPageRoute(builder: (context) => DetailScreen()),
);
That MaterialPageRoute is how you tell Flutter what to put on top. The builder function returns the widget that becomes the new screen. Flutter handles the slide-in animation automatically.
Navigator.pop -- Going Back
When the user is done with the current screen, you pop it off:
Navigator.pop(context);
That is it. One line. The screen slides away, and whatever was underneath reappears. The back button in the AppBar and the device back button both call Navigator.pop under the hood.
Navigator.push adds a screen to the top of the stack. Navigator.pop removes it. Every multi-screen Flutter app is built on these two operations.
Passing Data Forward: Constructor Arguments
Teach: How to pass data from one screen to another using plain Dart constructor arguments. See: A product detail screen that receives a name and price through its constructor when pushed. Feel: Relieved that passing data between screens requires no special API -- just constructors.
So you can push screens and pop screens. But what good is a detail screen if it doesn't know which item the user tapped? You need to hand data to the new screen. And the way you do it in Flutter is almost disappointingly simple -- you just pass it through the constructor. No special API. No magic. Just Dart constructors doing what constructors do.
Let's say you have a product list, and when the user taps a product, you want the detail screen to know about it. First, set up the detail screen to accept data:
class ProductDetailScreen extends StatelessWidget {
final String productName;
final double price;
const ProductDetailScreen({
super.key,
required this.productName,
required this.price,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(productName)),
body: Center(
child: Text('Price: \$${price.toStringAsFixed(2)}'),
),
);
}
}
Then, when you push, just fill in those constructor parameters:
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProductDetailScreen(
productName: 'Wireless Headphones',
price: 79.99,
),
),
);
That's the whole pattern. The data travels forward through the constructor.
Teach: How to send data back from a popped screen to the screen that pushed it. See: The await/pop pattern where push returns a Future and pop can carry a return value. Feel: That two-way data flow between screens is elegant and straightforward.
Returning Data: The await/pop Pattern
Teach: How to send data back from a popped screen to the screen that pushed it. See: The await/pop pattern where push returns a Future and pop can carry a return value. Feel: That two-way data flow between screens is elegant and straightforward.
Here is something that trips people up the first time: Navigator.push returns a Future. That means you can await it. And when the pushed screen pops, it can send data back.
Think of it like sending someone to the store. You wait for them to come back, and they hand you a bag of groceries. The "waiting" is the await, and the "bag of groceries" is the value passed to Navigator.pop.
The Calling Screen (the one that waits)
void _openSelectionScreen() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (context) => SelectionScreen()),
);
if (result != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('You selected: $result')),
);
}
}
Notice the <String> generic on Navigator.push. That tells Dart what type of data to expect back.
The Pushed Screen (the one that returns data)
// User made a choice -- pop with data
Navigator.pop(context, 'Added to cart');
// User hit back without choosing -- pop with nothing
Navigator.pop(context);
When pop is called with a second argument, that value becomes the result of the await on the calling screen. If no value is passed, result is null.
This pattern is incredibly useful. Imagine a color picker screen, a selection dialog, or a form that creates something. The calling screen pushes, waits, and then reacts to whatever came back. You will use this pattern constantly in real apps.
Named Routes: Navigation by String
Teach: How to centralize navigation using string-based named routes and onGenerateRoute. See: A route table in MaterialApp that maps URL-style strings to screens, with argument passing. Feel: That named routes bring order to navigation as an app grows beyond a few screens.
So far, you have been creating routes inline with MaterialPageRoute. That works great, but as your app grows, you might want a central registry of all your screens. That is what named routes give you.
Setting Up Named Routes
Define your routes in MaterialApp:
MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => HomeScreen(),
'/details': (context) => DetailScreen(),
'/settings': (context) => SettingsScreen(),
'/profile': (context) => ProfileScreen(),
},
)
Navigating with Named Routes
Navigator.pushNamed(context, '/details');
Clean and simple. No MaterialPageRoute boilerplate.
Passing Arguments with Named Routes
Named routes can carry arguments too, but the mechanism is slightly different. You pass a generic arguments object:
// Push with arguments
Navigator.pushNamed(
context,
'/details',
arguments: {'id': 42, 'title': 'Wireless Headphones'},
);
On the receiving screen, you extract them from the route settings:
@override
Widget build(BuildContext context) {
final args = ModalRoute.of(context)!.settings.arguments
as Map<String, dynamic>;
return Scaffold(
appBar: AppBar(title: Text(args['title'])),
body: Center(child: Text('Item ID: ${args['id']}')),
);
}
onGenerateRoute for More Control
If you need to parse route names dynamically or handle unknown routes gracefully, use onGenerateRoute:
MaterialApp(
onGenerateRoute: (settings) {
if (settings.name == '/product') {
final args = settings.arguments as Map<String, dynamic>;
return MaterialPageRoute(
builder: (context) => ProductDetailScreen(
productName: args['name'],
price: args['price'],
),
);
}
// Fallback for unknown routes
return MaterialPageRoute(
builder: (context) => NotFoundScreen(),
);
},
)
This gives you the clean URL-style navigation of named routes plus the ability to unpack arguments into constructors.
Named routes centralize your navigation logic. Use onGenerateRoute when you need to parse arguments or handle unknown routes.
Named routes are the Flutter equivalent of URLs in a web app. For small apps, the inline MaterialPageRoute approach is perfectly fine — it's actually easier to follow because the screen construction and the navigation call live next to each other. But as your app grows past six or seven screens, the map-based route table becomes a helpful reference. And if you ever need deep linking — "open this screen when the user taps a notification" — named routes become essential, because that's the mechanism Flutter uses to map incoming deep links to screens. Start with inline, graduate to named routes when complexity demands it.
WillPopScope: Intercepting the Back Button
Teach: How to intercept and conditionally block the back button using WillPopScope. See: An unsaved-changes dialog that appears when the user tries to leave a screen. Feel: In control -- knowing you can protect users from accidentally losing their work.
Sometimes you do not want the user to leave a screen. Maybe they have unsaved changes. Maybe they are in the middle of a payment flow. WillPopScope lets you intercept the back button -- both the AppBar back arrow and the device back button -- and ask "are you sure?" before actually popping.
WillPopScope wraps a widget and provides an onWillPop callback that returns a Future<bool>. Return true to allow the pop, false to block it.
WillPopScope(
onWillPop: () async {
final shouldLeave = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Unsaved Changes'),
content: Text('You have unsaved changes. Leave anyway?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('Stay'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Leave'),
),
],
),
);
return shouldLeave ?? false;
},
child: Scaffold(
appBar: AppBar(title: Text('Edit Settings')),
body: TextField(
decoration: InputDecoration(labelText: 'Setting value'),
),
),
)
Notice something interesting: the showDialog itself uses Navigator.pop to close the dialog and return a boolean. That is pop returning data, exactly like the pattern you learned earlier. Navigation patterns compose beautifully.
Teach: How to combine all these navigation concepts into working multi-screen apps. See: A product browser with push, pop, data passing, and named routes. Feel: Ready to build real multi-screen Flutter applications.
There Are No Dumb Questions
Teach: Answers to the most common navigation questions and misconceptions. See: Clear explanations of push vs pushNamed, popping the root screen, and WillPopScope deprecation. Feel: That the tricky edge cases have simple, memorable answers.
Q: What is the difference between Navigator.push and Navigator.pushNamed?
A: They do the same thing -- add a screen to the stack. push takes a Route object (usually MaterialPageRoute) that you build inline. pushNamed takes a string name and looks it up in the route table you defined in MaterialApp. Named routes are more organized for large apps; inline push is more flexible for passing constructor arguments directly.
Q: Can I use Navigator.push and Navigator.pushNamed in the same app?
A: Absolutely. Many apps use named routes for their main navigation structure and inline push for one-off screens like dialogs or selection screens.
Q: What happens if I pop when there is only one screen on the stack?
A: On Android, it exits the app. On iOS, nothing visible happens (the OS handles the home gesture). In general, do not pop your root screen.
Q: Is WillPopScope deprecated?
A: In Flutter 3.16+, WillPopScope was replaced by PopScope with a slightly different API. If you are on an older version, WillPopScope works fine. The concept is identical -- intercept the pop and decide whether to allow it.
Q: Why store the Future in initState instead of calling it in build?
A: This applies to FutureBuilder but the principle matters here too. If you call an async function inside build, it gets called every time the widget rebuilds. For navigation, each push/pop triggers rebuilds, so you want to be careful about triggering side effects in build.
The WillPopScope deprecation question comes up often because the Flutter docs and online tutorials are in different stages of updating. If you're on Flutter 3.16 or later, the modern API is PopScope. It takes canPop: false and an onPopInvoked callback. The concept is identical — intercept the back gesture and decide what to do — but the parameter names changed. I'll use WillPopScope in our examples because it's still functional and widely documented, but know that PopScope is the forward-compatible name.
Sharpen Your Pencil: Multi-Screen Product Browser
Teach: How to combine push, pop, constructor arguments, and return values in a complete two-screen app. See: A product list that navigates to a detail screen and receives an "added to cart" result back. Feel: Capable of building a real multi-screen experience from scratch.
Build a product browsing app with two screens that pass data in both directions.
ProductListScreen
- Create a
StatefulWidgetcalledProductListScreen. - Define a list of at least 8 products. Each product is a
Map<String, dynamic>with keys:id(int),name(String),price(double),description(String), andinStock(bool). - Display them in a
ListView.builderwithCardandListTile: leading: aCircleAvatarwith the product's first letter.title: the product name.subtitle: the price formatted as"\$XX.XX".trailing:Icons.check_circle(green) if in stock,Icons.cancel(red) if not.- On tap, push
ProductDetailScreenwith the product map. Await the result -- if it is"added_to_cart", show a SnackBar saying"[name] added to cart!".
ProductDetailScreen
- Create a
StatelessWidgetthat accepts a product map via constructor. - Display all fields: name (large bold), price, description, stock status (colored text).
- An
"Add to Cart"ElevatedButton that is disabled when out of stock. When pressed, pop with"added_to_cart". - A
"Back"TextButton that pops without a result.
This exercise combines every navigation technique from the module in one app. Constructor argument passing for forward data. Awaited pop for backward data. Conditional UI based on the product's stock status. The pattern "pop with a result, show a snackbar based on it" is probably the most common interaction you'll build — think of any app that shows "Item added" or "Saved" after you return from a detail screen. Get the Future, await the push, react to the value. That shape is going to stay with you for years.
Sharpen Your Pencil: Named Routes App
Teach: How to structure an app with named routes, argument passing, and back-button interception. See: A three-screen app using route tables, ModalRoute arguments, and WillPopScope for unsaved changes. Feel: Confident wiring up named routes and protecting screens from accidental exits.
Build an app using named routes with argument passing and back-button interception.
Setup
- Create a
MaterialAppwithinitialRoute: '/'and routes for'/','/profile', and'/settings'.
HomeScreen
- Two buttons:
"View Profile"(navigates to/profilewith{'name': 'Alex', 'role': 'Developer'}) and"Settings"(navigates to/settings).
ProfileScreen
- Extracts arguments from
ModalRoute.of(context)!.settings.arguments. - Displays the name and role.
- Has a back button.
SettingsScreen
- A
StatefulWidgetwith aTextField. - Wrapped in
WillPopScope-- when back is pressed, show a confirmation dialog asking"Discard changes?". - Only pop if the user confirms.
The settings screen is where back-button interception really earns its keep. Imagine your user types a paragraph into a feedback form and accidentally swipes back. Without WillPopScope, their work is gone. With it, they get a chance to confirm or return and finish. Three lines of wrapper code save a potential support ticket. And note the argument-passing bit in the profile screen — you pass a map, extract it with ModalRoute.of(context).settings.arguments, and cast it to its expected type. It's a tiny bit more ceremony than constructor passing, but it's the price of centralized route tables.
📝 Module Quiz
Test what you learned. 45 seconds per question. Passing score is set per module. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.
Putting It All Together
Teach: A summary of the five core navigation concepts and when to use each one. See: The full navigation toolkit -- push, pop, constructors, named routes, and WillPopScope -- in perspective. Feel: Ready to build any multi-screen Flutter app with confidence.
Navigation in Flutter comes down to a stack. Push to go forward, pop to go back. Pass data forward through constructors, pass data backward through pop's return value. Named routes give you organization, and WillPopScope gives you control. These five concepts cover about 90% of the navigation you will ever need in a Flutter app.
Where this fits: Navigation connects the individual screens you have been building in previous modules into a complete app experience. In the next modules, you will learn to theme those screens consistently and manage shared state across them.