Skip to content
FlutterLearn

Beginner · Lesson 6 · 10 min read

Navigation Between Screens

Push and pop screens, pass data both ways, and set up named routes — plus when you should graduate to a router package.

Updated July 24, 2026

What you will learn

  • Push and pop routes with Navigator
  • Pass data to a screen and return a result from it
  • Define named routes and handle unknown paths
  • Know when declarative routing is worth adopting

Flutter models navigation as a stack of routes. Pushing puts a new screen on top; popping removes it and reveals what was underneath. The back button and the iOS swipe gesture both just pop.

Push and pop

Dart
// Go to a new screen
Navigator.of(context).push(
  MaterialPageRoute(builder: (context) => const DetailScreen()),
);

// Come back
Navigator.of(context).pop();

// Replace the current screen (no back button to it)
Navigator.of(context).pushReplacement(
  MaterialPageRoute(builder: (context) => const HomeScreen()),
);

// Clear the whole stack — typical after login or logout
Navigator.of(context).pushAndRemoveUntil(
  MaterialPageRoute(builder: (context) => const HomeScreen()),
  (route) => false,
);

MaterialPageRoute gives you the platform-appropriate transition automatically: a slide from the right on iOS, a fade-and-lift on Android.

Passing data forward

The simplest approach is a constructor argument. It is type-safe and the compiler catches mistakes.

Dart
class LessonDetailScreen extends StatelessWidget {
  const LessonDetailScreen({super.key, required this.lesson});

  final Lesson lesson;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(lesson.title)),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Text(lesson.summary),
      ),
    );
  }
}

// At the call site
onTap: () => Navigator.of(context).push(
  MaterialPageRoute(
    builder: (_) => LessonDetailScreen(lesson: lesson),
  ),
),

Returning data back

push returns a Future that completes when the pushed route pops. Whatever you pass to pop becomes that future's value.

Dart
Future<void> _pickFilter(BuildContext context) async {
  final selected = await Navigator.of(context).push<String>(
    MaterialPageRoute(builder: (_) => const FilterScreen()),
  );

  if (!context.mounted) return;   // route may have been disposed
  if (selected == null) return;   // user pressed back without choosing

  setState(() => _filter = selected);
}

// Inside FilterScreen
ListTile(
  title: const Text('Beginner'),
  onTap: () => Navigator.of(context).pop('beginner'),
)

Named routes

For small-to-medium apps, named routes keep navigation code out of your widgets and give you a single place to see every screen.

Dart
MaterialApp(
  initialRoute: '/',
  routes: {
    '/': (context) => const HomeScreen(),
    '/settings': (context) => const SettingsScreen(),
    '/about': (context) => const AboutScreen(),
  },
  // Handle routes not in the map — deep links, typos
  onUnknownRoute: (settings) => MaterialPageRoute(
    builder: (_) => const NotFoundScreen(),
  ),
)

// Navigate by name
Navigator.of(context).pushNamed('/settings');

// With an argument
Navigator.of(context).pushNamed('/lesson', arguments: lessonId);

// Read it in the target screen
final lessonId = ModalRoute.of(context)!.settings.arguments as String;

That cast on the last line is the weakness of named routes: arguments are Object?, so type errors surface at runtime instead of compile time. For anything beyond a handful of screens, this cost adds up.

Dialogs, sheets and snackbars

Dart
// Confirmation dialog that returns a bool
final confirmed = await showDialog<bool>(
  context: context,
  builder: (context) => AlertDialog(
    title: const Text('Delete lesson?'),
    content: const Text('This cannot be undone.'),
    actions: [
      TextButton(
        onPressed: () => Navigator.of(context).pop(false),
        child: const Text('Cancel'),
      ),
      FilledButton(
        onPressed: () => Navigator.of(context).pop(true),
        child: const Text('Delete'),
      ),
    ],
  ),
);

// Bottom sheet
showModalBottomSheet<void>(
  context: context,
  isScrollControlled: true,
  builder: (context) => const FilterSheet(),
);

// Snackbar — note it uses ScaffoldMessenger, not Navigator
ScaffoldMessenger.of(context).showSnackBar(
  const SnackBar(content: Text('Saved')),
);

Dialogs and sheets are routes too — that is why they take a context, return a future, and are dismissed with pop.

When to move to a router package

Navigator and named routes cover most apps. Adopt a declarative router such as go_router when you need:

  • Deep links and web URLs that map cleanly to screens, including path parameters
  • Redirects — bouncing signed-out users to login from any route
  • Nested navigation — a bottom navigation bar where each tab keeps its own stack
  • Type-safe routes generated from your route definitions

Key takeaways

  • Navigation is a stack: push adds, pop removes, and push returns a future carrying the popped value.
  • Constructor arguments are the type-safe way to pass data; named-route arguments require an unsafe cast.
  • Always check context.mounted before using a context after an await.
  • Dialogs, sheets and the snackbar follow the same route-and-future pattern (snackbars use ScaffoldMessenger).

Practice

Two-screen notes app

Build a list of notes with a floating action button that pushes an editor screen. When the editor pops, return the new note and insert it into the list. Add a tap-to-edit flow that pre-fills the editor and returns the updated note, plus a delete confirmation dialog.

Show hints
  • Type your push: push<Note>(...) so the returned future is Future<Note?>.
  • A null result means the user backed out — leave the list unchanged.