Navigation

Theory #

Flutter uses a stack of routes for navigation between screens. It is always the topmost route in the navigation stack that is shown.

Here is a visual representation of the stack operations.

Stack operations

You can add a new route with:

Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => const SecondScreen(),
  ),
);

Pop a route makes you go back to previous screen:

Navigator.pop(context);

Replace with:

Navigator.pushReplacement(
  context,
  MaterialPageRoute(
    builder: (context) => const SecondScreen(),
  ),
);

Demo #

Here is a demo, so you can see it in action.

import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(
    title: 'Navigation Basics',
    home: FirstScreen(),
  ));
}

class FirstScreen extends StatelessWidget {
  const FirstScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('First Route'),
      ),
      body: Center(
        child: ElevatedButton(
          child: const Text('Push route'),
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (context) => const SecondScreen(),
              ),
            );
          },
        ),
      ),
    );
  }
}

class SecondScreen extends StatelessWidget {
  const SecondScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Second Route'),
      ),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            ElevatedButton(
              onPressed: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (context) => const ThirdScreen(),
                  ),
                );
              },
              child: const Text('Push another route'),
            ),
            SizedBox(height: 24),
            ElevatedButton(
              onPressed: () {
                Navigator.pushReplacement(
                  context,
                  MaterialPageRoute(
                    builder: (context) => const ThirdScreen(),
                  ),
                );
              },
              child: const Text('Push replacement'),
            ),
          ],
        ),
      ),
    );
  }
}

class ThirdScreen extends StatelessWidget {
  const ThirdScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Third Route'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.pop(context);
          },
          child: const Text('Pop'),
        ),
      ),
    );
  }
}

If you pop the last route, you will close the app.

Cupertino #

Here is a Cupertino version. Notice that the animation is different.

import 'package:flutter/cupertino.dart';

void main() {
  runApp(const CupertinoApp(
    title: 'Navigation Basics',
    home: FirstScreen(),
  ));
}

class FirstScreen extends StatelessWidget {
  const FirstScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text('First Route')
      ),
      child: Center(
        child: CupertinoButton(
          child: const Text('Push route'),
          onPressed: () {
            Navigator.push(
              context,
              CupertinoPageRoute(
                builder: (context) => const SecondScreen(),
              ),
            );
          },
        ),
      ),
    );
  }
}

class SecondScreen extends StatelessWidget {
  const SecondScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text('Second Route')
      ),
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            CupertinoButton(
              onPressed: () {
                Navigator.push(
                  context,
                  CupertinoPageRoute(
                    builder: (context) => const ThirdScreen(),
                  ),
                );
              },
              child: const Text('Push another route'),
            ),
            SizedBox(height: 24),
            CupertinoButton(
              onPressed: () {
                Navigator.pushReplacement(
                  context,
                  CupertinoPageRoute(
                    builder: (context) => const ThirdScreen(),
                  ),
                );
              },
              child: const Text('Push replacement'),
            ),
          ],
        ),
      ),
    );
  }
}

class ThirdScreen extends StatelessWidget {
  const ThirdScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: const Text('Third Route'),
      ),
      child: Center(
        child: CupertinoButton(
          onPressed: () {
            Navigator.pop(context);
          },
          child: const Text('Pop'),
        ),
      ),
    );
  }
}

BuildContext #

All the methods on Navigator, that we have seen - takes a BuildContext as first parameter. Why is that?

You see, Navigator is a StatefulWidget and its State object controls what route is shown.

But, where do the Navigator come from? Well, both MaterialApp and CuptertinoApp builds a Navigator as part of their build tree.

To answer the original question. The BuildContext is used to reach up the tree for the NavigatorState. This is similar to what happens when we do Theme.of(context).

So, BuildContext allows us to reach up the tree, therefore it must hold information about the tree. Components also hold information about the tree. In fact BuildContext is just an interface implemented by Component 🤯.

Named routes #

Navigation can also be done using named routes.

Here, we give MaterialApp a map of routes and an initial route.

MaterialApp(
  initialRoute: '/',
  routes: {
    '/': (context) => const NavigationScreen(),
    '/home': (context) => const HomeScreen(),
    '/settings': (context) => const SettingsScreen(),
  },
)

You can then navigate to a route using the name.

Navigator.pushNamed(context, '/home')

Here is a demo with a couple of screens.

import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp(
      title: 'Named Routes Demo',
      initialRoute: '/',
      routes: {
        '/': (context) => const NavigationScreen(),
        '/home': (context) => const HomeScreen(),
        '/settings': (context) => const SettingsScreen(),
      },
    ),
  );
}

class NavigationScreen extends StatelessWidget {
  const NavigationScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(mainAxisSize: MainAxisSize.min, children: [
          ElevatedButton.icon(
            icon: Icon(Icons.home),
            label: Text("Home"),
            onPressed: () => Navigator.pushNamed(context, '/home'),
          ),
          SizedBox(height: 8),
          ElevatedButton.icon(
            icon: Icon(Icons.settings),
            label: Text("Settings"),
            onPressed: () => Navigator.pushNamed(context, '/settings'),
          ),
        ]),
      ),
    );
  }
}

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(child: Icon(Icons.home)),
    );
  }
}

class SettingsScreen extends StatelessWidget {
  const SettingsScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Settings')),
      body: Center(child: Icon(Icons.settings)),
    );
  }
}