Basic Navigation

  1. Objectives
  2. Code along
  3. Boilerplate
  4. Navigation basics
  5. The Navigation stack
  6. Implementing simple navigation
    1. Explicit backward navigation
    2. Navigating with something other than buttons
  7. Adding a Navigation Drawer
    1. Closing the drawer
    2. Reusing the Drawer
  8. A word on Tabs and Bottom Navigation
  9. Other Resources

Objectives

Code along

Recording https://youtu.be/C2TxKRfa6Ao

Boilerplate

  1. Download an upgraded version of the List and Grid code from last time here: lists_and_grids.zip
  2. Unzip the file. It will create a folder called lists_and_grids. Move this folder to be with your other Flutter projects.
  3. In VSCode, go to File -> Open Folder and select the lists_and_grids/ folder.

navigation lab starting point

Navigation basics

Navigation is the term for transitioning between screens in Flutter. A route specifies the screen to which you want to navigate.

The Navigator widget provides us with the internal mechanics to navigate between screens. This widget does not appear on the screen, but it is integrated with the MaterialApp widget that is the base of your applications.

We are going to show the simplest forms of Flutter navigation in this lab. But, know that there are more customizable navigation capabilities available in the official documentation.

The Navigation stack

Conceptually, navigation from one screen to another is best thought of as a stack as illustrated below:

Stack representation of navigation

  1. Suppose you are on the HomeScreen and you push the button to “Go to the GridView Screen”.
  2. Flutter pushes the GridScreen widget on top of the HomeScreen widget in the navigation stack. This is called forward navigation. The HomeScreen widget is still alive in the widget tree, you just can’t see it any more. Any state/data/variable values in the HomeScreen are preserved. You are not destroying the HomeScreen widget.
  3. Notice how a “back” button has appeared in the GridScreen’s app bar. You get this for free in Flutter. Pushing the Back button pops the top widget off the navigation stack. This is called backward navigation. In this case, the GridScreen widget is popped, which does destroy the GridScreen widget.
  4. The original HomeScreen widget is once again at the top of the Navigation stack, and Flutter displays that screen.

This is a simple example of forward and backward navigation. The example shows two screens on the stack, but you can forward navigate until you have many screens in your navigation stack.

Implementing simple navigation

Let’s add some code to the ElevatedButtons in the HomeScreen widget to perform the navigation. Note that the ElevatedButtons have onPressed properties that specify empty anonymous functions. Put the following code in the onPressed function body for the ListView button:

Navigator.push(
    context,
    MaterialPageRoute(
        builder: (context) => ListScreen(data.paintingNames)),
);

This is the recipe for going to a new screen in Flutter.

Click the “Go to ListView Screen” button and you will see the ListScreen widget transition to the foreground of the app. Note that the Back arrow appears in the app bar at the top of the ListScreen. We get this functionality for free because our ListScreen contains a Scaffold with an AppBar specified. If you are on Android, the system-level back button (the sideways triangle at the bottom of the device) will also backward navigate. If you are on iOS, the general “swipe backward” navigation will also work.

Exercise: adapt the navigation recipe for the onPressed property of the GridView button so that it routes to the GridScreen widget.

Explicit backward navigation

Sometimes, you may want some other user interaction to take you backward, like clicking on a specific button or tapping a menu option.

Let’s add another screen to our app:

class DetailScreen extends StatelessWidget {
  const DetailScreen({this.painting, super.key});

  final BobRossPainting? painting;

  @override
  Widget build(BuildContext context) {
    Widget contentToShow;

    if (painting != null) {
      contentToShow =
          Image.asset("assets/images/painting${painting!.paintingId}.png");
    } else {
      contentToShow = Text("Nothing to show! How did you get here?");
    }

    return Scaffold(
        appBar: AppBar(title: Text("Detail Screen")),
        body: Center(
          child: Column(
            children: [
              contentToShow,
              ElevatedButton(
                  onPressed: () {
                    Navigator.pop(context);
                  },
                  child: Text("Go Back"))
            ],
          ),
        ));
  }
}

Note how this screen has an optional constructor parameter. We show an text message if that parameter is null, and try to display an image using the painting parameter otherwise.

Add another ElevatedButton to the HomeScreen so that we can navigate to our new DetailScreen widget directly.

ElevatedButton(
  onPressed: () {
    Navigator.push(
        context,
        MaterialPageRoute(builder: (context) => DetailScreen()),
    );
  },
  child: Text("Go to the Detail Screen"),
)

Note that the Navigator route says to go to the DetailScreen but does not pass the optional argument. Try it out. You should see the following:

Detail screen with text message

Note the “Go Back” button – the onPressed property expressly calls Navigator.pop(context). This has the same functionality as hitting the Back button in the app bar.

In our example thus far, the Navigator code only appears in onPressed properties of ElevatedButtons. However, you can call the Navigator code from just about anywhere. Let’s make it so that tapping a Card in our GridView launches the DetailScreen and displays a bigger image of the tapped painting.

Go to the MyCard widget. The root of this widget is a Card, which does not have any built-in onPressed functionality like an ElevatedButton. We need to wrap the Card with a GestureDetector to respond to taps and navigate to the DetailScreen.

Wrap the Card widget in a GestureDetector such that the beginning of MyCard’s build method looks like this:

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
        onTap: () {
          Navigator.of(context).push(MaterialPageRoute(
            builder: (context) => DetailScreen(painting: painting),
          ));
        },
        child: Card(
          // Rest of the Card code here

You will also need to add a ) before the semicolon near the end of the build method.

Notice now how we are passing an optional painting argument to the DetailScreen constructor. Our DetailScreen widget has an if-statement to try and load an image based on the contents of the painting parameter.

Navigate to your GridView screen and try tapping on one of the images. You should see the following:

Detail screen with text message

Your navigation stack is now three deep as well, and you can hit the Back button in the app bar multiple times.

Adding a Navigation Drawer

You may want to have a navigation menu in your app like you might find on a website – a list of static “links” to other parts of your app. In the Material Design language, this widget is called a Drawer.

You define and attach a Drawer widget to a Scaffold widget. The Drawer widget slides out from the side of the screen and displays its children, which is where the interaction takes place.

Let’s go to the HomeScreen widget and add a Drawer that displays the navigation options in a ListView. I could use a Column instead of a ListView, but the ListView will ensure that the user can scroll the Drawer’s contents if they don’t fit.

Add the following drawer property to the Scaffold on your HomeScreen:

      drawer: Drawer(
          child: ListView(
        padding: EdgeInsets.zero,
        children: [
          DrawerHeader(
            decoration: BoxDecoration(color: Colors.blueAccent),
            child: Text("Pick a place"),
          ),
          ListTile(
            leading: Icon(Icons.home),
            title: Text("Home Screen"),
            onTap: () {
              Navigator.of(context)
                  .push(MaterialPageRoute(builder: (context) => HomeScreen()));
            },
          ),
          ListTile(
            leading: Icon(Icons.list),
            title: Text("List Screen"),
            onTap: () {
              Navigator.of(context).push(MaterialPageRoute(
                  builder: (context) => ListScreen(data.paintingNames)));
            },
          ),
          ListTile(
            leading: Icon(Icons.grid_3x3),
            title: Text("Grid Screen"),
            onTap: () {
              Navigator.of(context).push(MaterialPageRoute(
                  builder: (context) => GridScreen(data.paintings)));
            },
          )
        ],
      )),

You should now be able to access the drawer from the “hamburger menu” in the top left and click on the tiles to navigate to your screens:

Closing the drawer

When you open the Drawer, Flutter adds it to the navigation stack. So if you navigate back to the HomeScreen, you will see that the drawer is still open. IF you want the drawer to still be open, no worries. If you want to close the drawer when someone taps on a ListTile, simply put Navigator.pop(context); on the line before the Navigator push(), e.g.,

onTap: () {
  Navigator.pop(context);
  Navigator.of(context).push(MaterialPageRoute(
      builder: (context) => GridScreen(data.paintings)));
},

Reusing the Drawer

Note that our Navigation drawer does not appear on the ListScreen, GridScreen, or DetailScreen. This is because the drawer must be added to each Scaffold widget separately. However, it is very likely that you want the same navigation drawer to appear on each screen. What can you do? Simply pull out the Drawer widget into your own custom Stateless widget, then reference that new widget in the Scaffold’s drawer property on each screen:

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

  @override
  Widget build(BuildContext context) {
    return Drawer(
        child: ListView(
      padding: EdgeInsets.zero,
      children: [
        DrawerHeader(
          decoration: BoxDecoration(color: Colors.blueAccent),
          child: Text("Pick a place"),
        ),
        ListTile(
          leading: Icon(Icons.home),
          title: Text("Home Screen"),
          onTap: () {
            Navigator.pop(context);
            Navigator.of(context)
                .push(MaterialPageRoute(builder: (context) => HomeScreen()));
          },
        ),
        ListTile(
          leading: Icon(Icons.list),
          title: Text("List Screen"),
          onTap: () {
            Navigator.pop(context);
            Navigator.of(context).push(MaterialPageRoute(
                builder: (context) => ListScreen(data.paintingNames)));
          },
        ),
        ListTile(
          leading: Icon(Icons.grid_3x3),
          title: Text("Grid Screen"),
          onTap: () {
            Navigator.pop(context);
            Navigator.of(context).push(MaterialPageRoute(
                builder: (context) => GridScreen(data.paintings)));
          },
        )
      ],
    ));
  }
}

You can then add drawer: MyNavDrawer(), to each Scaffold.

If you try this, you will get some errors because our ListScreen and GridScreen constructors require an argument (data from a BobRossData object), but MyNavDrawer doesn’t have that argument available. How do you fix this? Well, you could give the data to the MyNavDrawer and have it pass it along. That’s not ideal.

A better solution is to make each Screen widget as independent as possible. So instead of ListScreen(...) and GridScreen(...) requiring a parameter, have them load the BobRossData insider their build methods much like the HomeScreen does. Then you could remove that parameter from their constructors. It is good program design for your “top level” screen widgets to be as functionally-independent as possible. Making these changes is left as an exercise.

A word on Tabs and Bottom Navigation

A common visual paradigm in modern mobile apps is “tab-based” navigation where you have a small number of icons at the top or bottom of the screen that change the screen’s content, e.g.: tab navigation

Tab controls and bottom navigation can make for a smoother user experience, but the implementation is more complicated. These approaches do not use the Navigator at all, but instead require logic that replaces the content of the current screen with some different content. To accomplish this, you need to create a fairly complicated Stateful widget. We will cover tab controls in a future lab.

Other Resources