Flutter Navigation Basics: Navigator.push & Pop Tutorial | (Ch. 13)

Flutter Course Chapter 13: Basic Navigation, illustrating the concept of a "Navigation Stack" with mobile screens layering on top of each other.

Welcome to Chapter 13! In the previous chapter, we built the "skeleton" of a complex app. We learned how to add an AppBar at the top, a Drawer on the side, and a BottomNavigationBar at the footer.

But there was one major piece missing. When we tapped on "Profile" or "Settings," nothing happened. We were stuck on the home screen.

An app with only one screen isn't much of an app. To build real software, we need to know how to move users from Screen A to Screen B, and how to get them back safely.

This concept is called Navigation.

In Flutter, Navigation is built on a very simple, elegant metaphor: a Stack of Pancakes. In this chapter, we will master the two most fundamental commands in all of Flutter development: Navigator.push() and Navigator.pop().

1. The Mental Model: The Navigation Stack

Before we write a single line of code, you must understand how Flutter manages screens. Flutter doesn't "replace" screens like a website changes URLs. Instead, it "stacks" them.

The Pancake Analogy

Imagine you have a plate.

  1. Home Screen: When your app starts, Flutter puts one pancake on the plate. This is your Home widget. You are looking at the top pancake.
  2. Push: When you navigate to the "Settings" screen, Flutter puts a *new* pancake on top of the Home pancake. Now you are looking at Settings. The Home screen is still there! It's just underneath, hidden.
  3. Push Again: If you go from Settings to "Account Details," Flutter puts a *third* pancake on top.
  4. Pop: When you hit the "Back" button, Flutter takes the top pancake (Account Details) and throws it in the trash. What do you see? The Settings pancake again!

This data structure is called a Stack (Last In, First Out). The Navigator widget is the chef that manages this stack.

Routes vs. Screens

In standard conversation, we say "Screen" or "Page." In Flutter terminology, these are called Routes.

A Route is an abstraction. It wraps your widget (like SettingsScreen) and gives it transition animations (like sliding in from the right on iOS or fading in on Android).

2. Going Forward: `Navigator.push()`

To add a new pancake to the stack, we use the push method.

The `MaterialPageRoute` Wrapper

You cannot just push a widget directly. You have to wrap it in a Route. The most common one is MaterialPageRoute.

MaterialPageRoute does two things:

  1. It holds the widget you want to show (the builder).
  2. It handles the platform-specific animation. On an iPhone, it will make the new screen slide in from the right. On Android, it might zoom or fade in. It handles this automatically.

The Syntax

Here is the standard code you will write thousands of times:


// Inside a button's onPressed:
Navigator.push(
  context,
  MaterialPageRoute(builder: (context) => const SecondScreen()),
);

Let's break it down:

  • context: This tells the Navigator *where* in the widget tree we currently are, so it can find the nearest Navigator.
  • MaterialPageRoute: The wrapper we discussed.
  • builder: A function that creates the widget we want to go to (SecondScreen).

3. Going Back: `Navigator.pop()`

To remove the top pancake and go back to the previous one, we use pop.

The Physical Back Button

If you are using a Scaffold with an AppBar, Flutter automatically gives you a "Back" arrow button in the top-left corner whenever there is a screen underneath the current one.

On Android, the physical (or gesture) back button on the phone also triggers a pop automatically.

However, sometimes you want to trigger this manually (e.g., a "Cancel" or "Save" button).

The Syntax


// Inside a button's onPressed:
Navigator.pop(context);

That's it! It destroys the current screen widget (calling its dispose method if it's stateful) and reveals the screen underneath.

4. Passing Data TO a New Screen

Navigating is easy. But usually, you need to send data along with the user. For example, clicking a product in a list should open the "Details" screen *for that specific product*.

Constructor Injection

The best way to do this is simply by using the Constructor of the widget you are navigating to.

1. Define the parameter in the destination widget. 2. Pass the value when you call the constructor in push.

Example: Passing a "Todo" Item

Step 1: The Destination Screen


class DetailScreen extends StatelessWidget {
  // 1. Declare the field
  final String todoText;

  // 2. Require it in the constructor
  const DetailScreen({super.key, required this.todoText});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Task Details")),
      body: Center(
        child: Text(todoText), // 3. Use the data
      ),
    );
  }
}

Step 2: Pushing the Screen


final String myTask = "Buy Milk";

Navigator.push(
  context,
  MaterialPageRoute(
    // Pass the data right here!
    builder: (context) => DetailScreen(todoText: myTask),
  ),
);

5. Returning Data FROM a Screen

This is slightly more advanced, but very necessary. Imagine a "Selection Screen."

  • Screen A asks: "Choose a generic color."
  • User taps button to go to Screen B.
  • Screen B shows a list: Red, Blue, Green.
  • User taps "Blue."
  • Screen B closes (pops), and Screen A now knows the user picked "Blue."

The `async` / `await` Pattern

In Dart, Navigator.push() actually returns a Future. This Future "completes" when the pushed screen is popped.

We can await the result of the push!


// In Screen A (The asker)
void _navigateAndGetSelection(BuildContext context) async {
  // 1. Wait for the push to return
  final result = await Navigator.push(
    context,
    MaterialPageRoute(builder: (context) => const SelectionScreen()),
  );

  // 2. Use the result (after Screen B closes)
  print("User selected: $result");
}

Popping with Result Data

In Screen B, when we pop, we pass an optional second argument.


// In Screen B (The option list)
// When user taps "Blue":
Navigator.pop(context, "Blue");

6. Full Example: The Selection Screen

Let's build a mini-app to demonstrate this two-way data flow.

File: main.dart


import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(home: FirstScreen()));
}

// --- SCREEN 1: THE ASKER ---
class FirstScreen extends StatefulWidget {
  const FirstScreen({super.key});

  @override
  State<FirstScreen> createState() => _FirstScreenState();
}

class _FirstScreenState extends State<FirstScreen> {
  String _selection = "Nothing selected yet";

  // The method to navigate
  void _navigateAndDisplaySelection(BuildContext context) async {
    // AWAIT the result from Screen 2
    final result = await Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => const SelectionScreen()),
    );

    // If the user backed out without selecting, result might be null
    if (!mounted) return; // Safety check for async code
    
    setState(() {
      _selection = result ?? "User canceled selection";
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Returning Data Demo')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(_selection, style: const TextStyle(fontSize: 24)),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () => _navigateAndDisplaySelection(context),
              child: const Text('Pick an Option'),
            ),
          ],
        ),
      ),
    );
  }
}

// --- SCREEN 2: THE OPTIONS ---
class SelectionScreen extends StatelessWidget {
  const SelectionScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Pick an option')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () {
                // Return "Yes" to Screen 1
                Navigator.pop(context, 'Yes!');
              },
              child: const Text('Yes!'),
            ),
            const SizedBox(height: 10),
            ElevatedButton(
              onPressed: () {
                // Return "No" to Screen 1
                Navigator.pop(context, 'No.');
              },
              child: const Text('No.'),
            ),
          ],
        ),
      ),
    );
  }
}

7. Common Pitfalls

Navigation seems simple, but beginners often hit these walls:

  1. The Black Screen of Death: If you navigate to a new widget that does not have a Scaffold (or Material widget) as its root, the background will be black and the text will be ugly red/yellow with double underlines.
    Fix: Always wrap your new screen content in a Scaffold.
  2. "Context" Issues: Navigator.of(context) looks up the widget tree to find the Navigator. If you try to call this from a context where the Navigator doesn't exist yet (rare, but happens in `main()`), it will fail.
  3. Async "Mounted" Checks: When you use `await Navigator.push`, your widget might be destroyed (e.g., user rotates phone or app closes) while the user is on the second screen. When they return, calling `setState` on a destroyed widget throws an error.
    Fix: Always add if (!mounted) return; after the await line.

❓ FAQ: Frequently Asked Questions

Q: Why do we use `push` instead of just replacing the widget in `body`?
Replacing the body works for tabs, but `Navigator.push` gives you three critical things for free:
  1. Animations: The slide/fade transition users expect.
  2. Back Button: The automatic back arrow in the AppBar.
  3. Hardware Back Support: The Android back button works automatically.
Q: What is the difference between `push` and `pushReplacement`?
  • push: Adds a pancake. You can go back. (e.g., List -> Details).
  • pushReplacement: Swaps the current pancake with a new one. You cannot go back to the old one. This is used for things like Login screens. Once you log in and go to Home, hitting "Back" shouldn't take you back to the Login screen; it should exit the app.
Q: Can I send complex objects like a custom User class?
Yes! You can pass any object (int, String, List, CustomClass) via the constructor. Just make sure the destination widget expects that type in its constructor.
Q: What are "Named Routes" (`pushNamed`)?
Named routes (using strings like `'/settings'`) are an alternative way to navigate. They are useful for deep linking (opening app from a URL), but for most simple apps, the method shown in this chapter (Anonymous Routes) is preferred because passing data via constructors is much cleaner and type-safe. We will cover Named Routes in the next chapter.

Conclusion

You now hold the keys to multi-screen applications.

You understand that screens are just widgets stacked on top of each other. You know how to Push a new one, Pop the old one, Pass Data forward via constructors, and Return Data backward via `Future`s.

In the next chapter, we will look at a more organized way to handle navigation for larger apps using Named Routes and the GoRouter package, which is the modern standard for complex Flutter applications.

Comments