Learn how to move between screens using Flutter's Navigator, named routes, and the modern go_router package.
Flutter uses a navigation stack. push adds a screen on top; pop removes the top screen. This is the most basic (imperative) routing approach.
// Navigate TO a new screen (push onto the stack)
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DetailScreen(),
),
);
// Go back (pop the current screen off the stack)
Navigator.pop(context);
// Replace current screen with a new one (no back button)
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const HomeScreen()),
);
// Clear entire stack and push — useful after login/logout
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const HomeScreen()),
(route) => false, // remove all previous routes
);
// Check if we can pop (there is a screen underneath)
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
context — it automatically uses the nearest Navigator in the widget tree, which is provided by MaterialApp.MaterialPageRoute wraps a screen widget and provides the platform-appropriate transition animation (slide on iOS, fade on Android).
// Basic MaterialPageRoute
MaterialPageRoute(
builder: (context) => const MyScreen(),
)
// With settings (route name for analytics)
MaterialPageRoute(
settings: const RouteSettings(name: '/detail'),
builder: (context) => const DetailScreen(),
)
// Custom transition: slide from bottom
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => const MyScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(0.0, 1.0); // start from bottom
const end = Offset.zero;
final tween = Tween(begin: begin, end: end);
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOut);
return SlideTransition(position: tween.animate(curved), child: child);
},
transitionDuration: const Duration(milliseconds: 300),
)
// Fade transition
PageRouteBuilder(
pageBuilder: (context, animation, _) => const MyScreen(),
transitionsBuilder: (context, animation, _, child) {
return FadeTransition(opacity: animation, child: child);
},
)
The simplest way to pass data forward is via constructor parameters on the destination screen widget.
// ---- The data model ----
class Product {
final int id;
final String name;
final double price;
const Product({required this.id, required this.name, required this.price});
}
// ---- Destination screen — accepts data in constructor ----
class ProductDetailScreen extends StatelessWidget {
final Product product; // receives the data
const ProductDetailScreen({super.key, required this.product});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(product.name)),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.name, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text('\$${product.price.toStringAsFixed(2)}', style: const TextStyle(fontSize: 20)),
],
),
),
);
}
}
// ---- From the list screen — navigate and pass data ----
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProductDetailScreen(product: myProduct),
),
);
},
child: ListTile(title: Text(myProduct.name)),
)
Use await Navigator.push() and pass a result with Navigator.pop(context, result).
// ---- Picker screen — returns a selected value ----
class ColorPickerScreen extends StatelessWidget {
const ColorPickerScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Pick a Color')),
body: Column(
children: [
ListTile(
title: const Text('Red'),
onTap: () => Navigator.pop(context, 'red'), // return 'red'
),
ListTile(
title: const Text('Blue'),
onTap: () => Navigator.pop(context, 'blue'),
),
ListTile(
title: const Text('Cancel'),
onTap: () => Navigator.pop(context), // return null
),
],
),
);
}
}
// ---- Caller screen — wait for result ----
Future<void> _pickColor() async {
final String? selected = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (context) => const ColorPickerScreen()),
);
if (selected != null) {
setState(() => _chosenColor = selected);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('You picked: $selected')),
);
}
}
Named routes let you define all routes in one central map in MaterialApp and navigate by string name. Good for small to medium apps.
// ---- Define routes in MaterialApp ----
MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => const HomeScreen(),
'/login': (context) => const LoginScreen(),
'/profile': (context) => const ProfileScreen(),
'/settings':(context) => const SettingsScreen(),
},
)
// ---- Navigate with pushNamed ----
Navigator.pushNamed(context, '/profile');
// Replace current screen
Navigator.pushReplacementNamed(context, '/home');
// Clear stack and navigate
Navigator.pushNamedAndRemoveUntil(context, '/home', (route) => false);
// Passing simple arguments via pushNamed
Navigator.pushNamed(
context,
'/detail',
arguments: {'id': 42, 'title': 'My Item'},
);
// Reading arguments in the destination screen
@override
Widget build(BuildContext context) {
final args = ModalRoute.of(context)!.settings.arguments as Map;
final int id = args['id'];
final String title = args['title'];
return Scaffold(appBar: AppBar(title: Text(title)));
}
Map are not type-safe. For type-safe routing, prefer the go_router package (Section 6).go_router is the officially recommended routing package for Flutter. It supports deep links, URL-based navigation, type-safe params, and nested routes.
# pubspec.yaml dependencies: go_router: ^13.0.0
import 'package:go_router/go_router.dart';
// ---- Define the router ----
final GoRouter _router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/profile/:userId', // URL parameter
builder: (context, state) {
final userId = state.pathParameters['userId']!;
return ProfileScreen(userId: userId);
},
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
routes: [
// Nested route: /settings/notifications
GoRoute(
path: 'notifications',
builder: (context, state) => const NotificationsScreen(),
),
],
),
],
);
// ---- Wire it into MaterialApp ----
MaterialApp.router(
routerConfig: _router,
title: 'My App',
)
// ---- Navigate ----
context.go('/'); // replace current location
context.push('/settings'); // push (back button works)
context.push('/profile/42'); // with URL param
context.pop(); // go back
context.goNamed('home'); // navigate by name
// ---- Query parameters ----
GoRoute(
path: '/search',
builder: (context, state) {
final query = state.uri.queryParameters['q'] ?? '';
return SearchScreen(query: query);
},
)
// Navigate: context.go('/search?q=flutter');
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _currentIndex = 0;
// The screens corresponding to each tab
final List<Widget> _screens = [
const HomeTab(),
const SearchTab(),
const ProfileTab(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
// Show the current tab's widget
body: _screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) => setState(() => _currentIndex = index),
selectedItemColor: Colors.blue,
unselectedItemColor: Colors.grey,
type: BottomNavigationBarType.fixed, // show all labels
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.search_outlined),
activeIcon: Icon(Icons.search),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.person_outline),
activeIcon: Icon(Icons.person),
label: 'Profile',
),
],
),
);
}
}
IndexedStack as the body instead of _screens[_currentIndex] to keep all tab states alive when switching tabs. Without it, each tab rebuilds from scratch.Scaffold(
appBar: AppBar(title: const Text('My App')),
// Opens when user swipes from left or taps the hamburger icon
drawer: Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
// Header section
UserAccountsDrawerHeader(
accountName: const Text('Alice Smith'),
accountEmail: const Text('alice@example.com'),
currentAccountPicture: const CircleAvatar(
backgroundImage: NetworkImage('https://picsum.photos/80'),
),
decoration: const BoxDecoration(color: Colors.blue),
),
// Navigation items
ListTile(
leading: const Icon(Icons.home),
title: const Text('Home'),
onTap: () {
Navigator.pop(context); // close the drawer first
Navigator.pushNamed(context, '/');
},
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Settings'),
onTap: () {
Navigator.pop(context);
Navigator.pushNamed(context, '/settings');
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout, color: Colors.red),
title: const Text('Logout', style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(context);
// perform logout
},
),
],
),
),
body: const Center(child: Text('Swipe left or tap menu to open drawer')),
)
// Programmatically open the drawer
Scaffold.of(context).openDrawer();
// Or with a GlobalKey:
final _scaffoldKey = GlobalKey<ScaffoldState>();
_scaffoldKey.currentState?.openDrawer();
| Approach | Best For | Pros | Cons |
|---|---|---|---|
| Navigator.push | Simple linear flows, 2-5 screens | No setup, simple | Not scalable, no deep links |
| Named Routes | Small apps, quick setup | Central route map | No type safety, limited |
| go_router | Most production apps | URL-based, deep links, type-safe, nested routes | Additional dependency |
| auto_route | Large apps with code gen | Full type safety via generation | Complex setup |
Navigator.pop(context) before pushing from a Drawer item (close the drawer first).pushReplacement for Login → Home to prevent back-navigation to login.go_router for any app that needs deep links or URL navigation on web.