🏠 Home / Hub

Flutter 04 — Navigation & Routing

Learn how to move between screens using Flutter's Navigator, named routes, and the modern go_router package.

1. Navigator.push / Navigator.pop

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);
}
The Navigator is available via context — it automatically uses the nearest Navigator in the widget tree, which is provided by MaterialApp.

2. MaterialPageRoute

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);
  },
)

3. Passing Data to the Next Screen

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)),
)

4. Returning Data from a Screen

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')),
    );
  }
}

5. Named Routes

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)));
}
Named routes with arguments via Map are not type-safe. For type-safe routing, prefer the go_router package (Section 6).

6. go_router Package (Modern Routing)

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');

7. BottomNavigationBar with State

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',
          ),
        ],
      ),
    );
  }
}
Use IndexedStack as the body instead of _screens[_currentIndex] to keep all tab states alive when switching tabs. Without it, each tab rebuilds from scratch.

8. Drawer Navigation

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();

9. Navigation Best Practices

ApproachBest ForProsCons
Navigator.pushSimple linear flows, 2-5 screensNo setup, simpleNot scalable, no deep links
Named RoutesSmall apps, quick setupCentral route mapNo type safety, limited
go_routerMost production appsURL-based, deep links, type-safe, nested routesAdditional dependency
auto_routeLarge apps with code genFull type safety via generationComplex setup
Rules of thumb:
- Always Navigator.pop(context) before pushing from a Drawer item (close the drawer first).
- Use pushReplacement for Login → Home to prevent back-navigation to login.
- Prefer go_router for any app that needs deep links or URL navigation on web.
- Keep navigation logic in one place (not scattered across widgets).

📌 Study Checklist