Understanding Declarative Navigation in Flutter
Understanding Declarative Navigation in Flutter
What You’ll Learn
Learn how Flutter’s modern declarative navigation approach differs from imperative navigation (Navigator.push()). This pattern is essential for complex apps with deep linking, web URLs, and state-driven navigation.
Imperative vs Declarative: What’s the Difference?
Imperative (Navigator 1.0): You tell Flutter how to navigate
Navigator.push(context, MaterialPageRoute(builder: (_) => DetailsPage()));
Declarative (Navigator 2.0): You tell Flutter what to show based on app state
// Navigation reflects your app state
pages: [
if (showHome) HomePage(),
if (showDetails) DetailsPage(),
]
The declarative approach makes navigation predictable, testable, and works seamlessly with browser URLs and deep links.
Example: Simple Declarative Navigator
Here’s a practical example using Navigator.pages:
import 'package:flutter/material.dart';
// Our app's navigation state
class AppState extends ChangeNotifier {
String? _selectedUserId;
String? get selectedUserId => _selectedUserId;
void selectUser(String id) {
_selectedUserId = id;
notifyListeners();
}
void clearSelection() {
_selectedUserId = null;
notifyListeners();
}
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => AppState(),
child: MaterialApp(
home: AppNavigator(),
),
);
}
}
class AppNavigator extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return Navigator(
pages: [
// Home page is always in the stack
MaterialPage(
key: ValueKey('home'),
child: HomePage(),
),
// Details page only shows when user is selected
if (appState.selectedUserId != null)
MaterialPage(
key: ValueKey('details-${appState.selectedUserId}'),
child: DetailsPage(userId: appState.selectedUserId!),
),
],
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
// Update state when user pops
context.read<AppState>().clearSelection();
return true;
},
);
}
}
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Users')),
body: ListView(
children: [
ListTile(
title: Text('Alice'),
onTap: () => context.read<AppState>().selectUser('alice'),
),
ListTile(
title: Text('Bob'),
onTap: () => context.read<AppState>().selectUser('bob'),
),
],
),
);
}
}
class DetailsPage extends StatelessWidget {
final String userId;
const DetailsPage({required this.userId});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('User: $userId')),
body: Center(
child: Text(
'Details for $userId',
style: TextStyle(fontSize: 24),
),
),
);
}
}
Key Concepts
1. Pages List: Defines the navigation stack based on state
- The pages list represents your entire navigation stack
- Add/remove pages to control navigation
- Flutter automatically animates transitions
2. onPopPage: Handle back button and pop gestures
- Called when user tries to navigate back
- Update your app state to reflect the navigation change
- Return
trueif pop succeeded,falseto prevent it
3. MaterialPage vs Page
MaterialPage: Standard Material Design transitionsCupertinoPage: iOS-style transitions- Custom
Pageclasses for custom transitions
Why Use Declarative Navigation?
✅ Predictable: Navigation is a pure function of state ✅ Testable: Easy to test navigation logic without widgets ✅ Deep linking: URLs map directly to state ✅ State restoration: Browser back button works automatically ✅ Single source of truth: Navigation state lives in one place
Practical Pattern: Router with State
For larger apps, use a router class:
class AppRouter extends ChangeNotifier {
// Navigation state
bool _showSplash = true;
String? _selectedItemId;
// Getters
bool get showSplash => _showSplash;
String? get selectedItemId => _selectedItemId;
// Navigation actions
void completedSplash() {
_showSplash = false;
notifyListeners();
}
void selectItem(String id) {
_selectedItemId = id;
notifyListeners();
}
void goBack() {
_selectedItemId = null;
notifyListeners();
}
// Generate pages based on current state
List<Page> get pages => [
if (_showSplash)
SplashPage()
else ...[
HomePage(),
if (_selectedItemId != null)
DetailsPage(id: _selectedItemId!),
],
];
// Handle system back button
bool handlePopPage(Route route, dynamic result) {
if (!route.didPop(result)) return false;
if (_selectedItemId != null) {
_selectedItemId = null;
notifyListeners();
return true;
}
return false;
}
}
Try It Yourself
Build a shopping app with declarative navigation:
- Create a product list page (always visible)
- Add a product details page (shows when product selected)
- Add a cart page (shows when cart button tapped)
- Implement navigation state in a
ChangeNotifier
Challenge: Add URL support so each page has a unique URL path (e.g., /products, /product/123, /cart). Use RouterDelegate and RouteInformationParser for full web URL support.
Tip of the Day
Migration Strategy: You don’t need to migrate everything at once. Declarative navigation can coexist with imperative Navigator.push() calls. Start with your main navigation flow and gradually migrate secondary flows.
go_router Package: For production apps, consider using the go_router package. It provides declarative routing with URL support, guards, and redirects—all built on Navigator 2.0 principles.