| name | flutter-implementing-navigation-and-routing |
| description | Handles routing, navigation, and deep linking in a Flutter application. Use when moving between screens or setting up URL-based navigation. |
| metadata | {"model":"models/gemini-3.1-pro-preview","last_modified":"Thu, 12 Mar 2026 22:30:17 GMT"} |
Implementing Navigation and Routing in Flutter
Contents
Core Concepts
- Routes: In Flutter, screens and pages are referred to as routes. A route is simply a widget. This is equivalent to an
Activity in Android or a ViewController in iOS.
- Navigator vs. Router:
- Use
Navigator (Imperative) for small applications without complex deep linking requirements. It manages a stack of Route objects.
- Use
Router (Declarative) for applications with advanced navigation, web URL synchronization, and specific deep linking requirements.
- Deep Linking: Allows an app to open directly to a specific location based on a URL. Supported on iOS, Android, and Web. Web requires no additional setup.
- Named Routes: Avoid using named routes (
MaterialApp.routes and Navigator.pushNamed) for most applications. They have rigid deep linking behavior and do not support the browser forward button. Use a routing package like go_router instead.
Implementing Imperative Navigation
Use the Navigator widget to push and pop routes using platform-specific transition animations (MaterialPageRoute or CupertinoPageRoute).
Pushing and Popping
- Navigate to a new route using
Navigator.push(context, route).
- Return to the previous route using
Navigator.pop(context).
- Use
Navigator.pushReplacement() to replace the current route, or Navigator.pushAndRemoveUntil() to clear the stack based on a condition.
Passing and Returning Data
- Sending Data: Pass data directly into the constructor of the destination widget. Alternatively, pass data via the
settings: RouteSettings(arguments: data) parameter of the PageRoute and extract it using ModalRoute.of(context)!.settings.arguments.
- Returning Data: Pass the return value to the
pop method: Navigator.pop(context, resultData). Await the result on the pushing side: final result = await Navigator.push(...).
Avoiding Pop-then-Push Animation Conflicts [toly+]
When a page collects user input and then navigates to a result page, avoid the pattern await Navigator.push() → pop returns result → push next page. The pop animation and the subsequent push animation overlap, causing both pages to be visible simultaneously.
Preferred pattern: callback + pushReplacement. Instead of popping with a result, pass an onComplete callback into the form page. In the callback, use Navigator.pushReplacement() to swap the form page for the result page in a single transition. The user sees the form page slide away and the result page slide in, with no intermediate flash of the underlying page.
- Use
onComplete callback instead of Navigator.pop(context, result) when the caller needs to navigate after receiving the result.
- Use
Navigator.pushReplacement() inside the callback to replace the current route with the next one.
- This keeps the navigation stack clean: FormPage is replaced by ResultPage, and pressing back returns to the original page.
Implementing Declarative Navigation
For apps requiring deep linking, web URL support, or complex routing, implement the Router API via a declarative routing package like go_router.
- Switch from
MaterialApp to MaterialApp.router.
- Define a router configuration that parses route paths and configures the
Navigator automatically.
- Navigate using package-specific APIs (e.g.,
context.go('/path')).
- Page-backed vs. Pageless Routes: Declarative routes are page-backed (deep-linkable). Imperative pushes (e.g., dialogs, bottom sheets) are pageless. Removing a page-backed route automatically removes all subsequent pageless routes.
Implementing Nested Navigation
Implement nested navigation to manage a sub-flow of screens (e.g., a multi-step setup process or persistent bottom navigation tabs) independently from the top-level global navigator.
- Instantiate a new
Navigator widget inside the host widget.
- Assign a
GlobalKey<NavigatorState> to the nested Navigator to control it programmatically.
- Implement the
onGenerateRoute callback within the nested Navigator to resolve sub-routes.
- Intercept hardware back button presses using
PopScope to prevent the top-level navigator from popping the entire nested flow prematurely.
Workflows
Workflow: Standard Screen Transition
Copy this checklist to track progress when implementing a basic screen transition:
Workflow: Implementing Deep-Linkable Routing
Use this conditional workflow when setting up app-wide routing:
Workflow: Creating a Nested Navigation Flow
Run this workflow when building a multi-step sub-flow (e.g., IoT device setup):
Examples
Example: Passing Data via Constructor (Imperative)
// 1. Define the data model
class Todo {
final String title;
final String description;
const Todo(this.title, this.description);
}
// 2. Source Screen
class TodosScreen extends StatelessWidget {
final List<Todo> todos;
const TodosScreen({super.key, required this.todos});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Todos')),
body: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(todos[index].title),
onTap: () {
// Push and pass data via constructor
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DetailScreen(todo: todos[index]),
),
);
},
);
},
),
);
}
}
// 3. Destination Screen
class DetailScreen extends StatelessWidget {
final Todo todo;
const DetailScreen({super.key, required this.todo});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(todo.title)),
body: Padding(
padding: const EdgeInsets.all(16),
child: Text(todo.description),
),
);
}
}
Example: Nested Navigation Flow
class SetupFlow extends StatefulWidget {
final String initialRoute;
const SetupFlow({super.key, required this.initialRoute});
@override
State<SetupFlow> createState() => _SetupFlowState();
}
class _SetupFlowState extends State<SetupFlow> {
final _navigatorKey = GlobalKey<NavigatorState>();
void _exitSetup() => Navigator.of(context).pop();
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
// Intercept back button to prevent accidental exit
_exitSetup();
},
child: Scaffold(
appBar: AppBar(title: const Text('Setup')),
body: Navigator(
key: _navigatorKey,
initialRoute: widget.initialRoute,
onGenerateRoute: _onGenerateRoute,
),
),
);
}
Route<Widget> _onGenerateRoute(RouteSettings settings) {
Widget page;
switch (settings.name) {
case 'step1':
page = StepOnePage(
onComplete: () => _navigatorKey.currentState!.pushNamed('step2'),
);
break;
case 'step2':
page = StepTwoPage(onComplete: _exitSetup);
break;
default:
throw StateError('Unexpected route name: ${settings.name}!');
}
return MaterialPageRoute(
builder: (context) => page,
settings: settings,
);
}
}
Example: Callback + pushReplacement (Avoiding Animation Conflicts) [toly+]
// ❌ Bad: pop + push causes overlapping animations
final result = await Navigator.push(context, MaterialPageRoute(
builder: (_) => FormPage(),
));
if (result != null) {
Navigator.push(context, MaterialPageRoute(
builder: (_) => ResultPage(data: result),
));
}
// ✅ Good: callback + pushReplacement for seamless transition
Navigator.push(context, MaterialPageRoute(
builder: (_) => FormPage(
onComplete: (result) {
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (_) => ResultPage(data: result),
));
},
),
));