Building a Flutter app, part 3: navigating between app screens using routes and Navigate.to

Welcome to part 3 of my Flutter App development journal!

In this post: Building more pages for the app, linking them together so the user can “click around” and navigate between the different screens.

To view the project repo as it was during this step, click here.

Making the list items respond to taps

In Part 2 we left off with a main screen that displays some mock data and can be scrolled, but nothing really goes anywhere or does anything yet. Making the list items interactive is the next step. The ListTile class already supports an onTap property, so adding that is easy.

Here’s some basic “wiring” that causes tapping on any of the list items to trigger a function I added called goToStub.

main_screen_list.dart

...
goToStub(destination) {
  print(destination);
}

@override
Widget build(BuildContext context) {
 
...

itemBuilder: (BuildContext context, int index) {
  if (index == list.length) {
    return ListTile(
        title: Text("Add new " +  listType  + "..."),
        onTap: () => goToStub("Going to: Add new " + listType),
    );
  } else {
  ...

Be sure to give the onTap an anonymous function structured like this:

onTap: () => goToStub("string here") 

instead of a function call this:

onTap: goToStub("string here")

If you do the latter, it’ll cause goToStub(...) to get called immediately on page load. This doesn’t “do” anything yet, it’s just a print statement that proves the wiring works. Now that we know it works, we can build a second page and make it so the onTap takes you there.

To see the complete code example for the onTap and stub function wiring, take a look at this commit.

Navigating between different pages in the app

In this step, I’ll make it so tapping a list item opens a different page in the app.

There are at least four different user flow “paths” out of this main page:

  • Tap a shopping list => open that shopping list
  • Tap a store => open that store’s management page
  • Tap “Add a new shopping list…” => take the user to a form where they can add a new shopping list
  • Tap “Add a new store…” => open a form where the user can enter a new store

I’m going use mock data for these UI pages; I think building the UI will make it easier to determine the data structures.

Building the ‘Add new shopping list’ page UI

First, I created a views folder and added a new file within: views/new_shopping_list.dart

import 'package:flutter/material.dart';

class NewShoppingList extends StatefulWidget {

  static const routeName = 'newShoppingList';

  NewShoppingList({Key key});

  @override
  _NewShoppingListState createState() => _NewShoppingListState();
}

class _NewShoppingListState extends State<NewShoppingList> {

  void saveList(BuildContext context) {
    print("Saving new list");
    Navigator.of(context).pop();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Add new shopping list"),
      ),
      body: Center(
        child: Padding(
          padding: EdgeInsets.all(20),
          child: Column(
            children: [
              TextFormField(
                autofocus: true,
                decoration: InputDecoration(
                    labelText: 'Shopping list name',
                    border: OutlineInputBorder()),
              ),
              RaisedButton(
                onPressed: () => saveList(context),
                child: Text('Save'),
              ),
            ],
          ),
        ),
      )
    );
  }
}
  • It is a stateful widget because the user is going to interact with it and it has a very simple “saveList” method that doesn’t do anything except send the user back to the main screen.
  • I defined the name of this route near the top, look for static const routeName = 'newShoppingList';
  • I wrapped everything in a Padding widget because without it, the text field was right up against the screen edges.
  • There’s a lot more to do to make the form actually work, but I just wanted to build the UI and wire it all together before getting into that stuff

Next, I went back to main.dart and above the MaterialApp return added a routes object. This is the same pattern Flutter’s docs teach.

class MyApp extends StatelessWidget {

  @override
  Widget build(BuildContext context) {

    var routes = {
      NewShoppingList.routeName: (context) => NewShoppingList(),
    };

    return MaterialApp(
      routes: routes,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Grocery Go!'),
    );
  }
}

Finally, in main_screen_list.dart I changed the onTap to call a new method I added to the file called goToNew.

onTap: () => goToNew(context, listType),
goToNew(context, destination) {
  if (destination == 'shopping list') {
    Navigator.pushNamed(context, 'newShoppingList');
  }
}

I pass it listType, which isn’t ideal because it’s just a string that looks like “shopping list”, but for this first pass implementation I didn’t want to do anything more than just what was needed to illustrate how this works. We will refactor this soon.

You can see all of those changes in this commit.

Here’s what we have now. Note that it doesn’t actually save “New list”, it just sends me back to the main page.

Building the ‘Add new store’ page UI

The ‘add a new store’ page is very similar to the ‘add a new shopping list’ page, so I copied and pasted the contents of new_shopping_list.dart into a newly created file, new_store.dart and built it out from there. I also updated the routes object to contain the new route in main.dart:

var routes = {
NewShoppingList.routeName: (context) => NewShoppingList(),
NewStore.routeName: (context) => NewStore(),
};

You can see all of the code for new_store.dart in this commit.

The result:

We will come back to this page later to hook it up to a mapping API for choosing the store location, but for now this is enough to get the route wired in.

Building the ‘Shopping list’ page

This page is where the user sees what’s on a specific shopping list, adds and removes items to the list, and manages the list itself. Unlike the “create new…” routes I made earlier, this route is going to need some data passed into it when the user navigates to it. The Flutter documentation has a great guide for passing parameters into a route.

My new route takes a listID and a listName. (I may refactor this to be a single list object later on that contains these things as properties but for now I’ll just go with passing in these two things separately.)

views/existing_shopping_list.dart

class ExistingShoppingListArguments {
  final String listID;
  final String listName;
  ExistingShoppingListArguments(this.listID, this.listName);
}

class ExistingShoppingList extends StatelessWidget {

  static const routeName = '/existingShoppingList';

  @override
  Widget build(BuildContext context) {

    final ExistingShoppingListArguments args = ModalRoute.of(context).settings.arguments;

    // now I can do stuff with args.listID, args.listName

    return Scaffold(
      ...

The “existing shopping list” page is similar to the main page in that it’ll be two lists in a column together, where the top list is the “active” part of the list, sorted by category and the bottom list is crossed off items.

return Scaffold(
  appBar: AppBar(
    title: Text(args.listName + ", id: " + args.listID),
  ),
  body: LayoutBuilder(
    builder: (BuildContext context, BoxConstraints viewportConstraints) {
    return SingleChildScrollView(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        mainAxisAlignment: MainAxisAlignment.start,
        children: <Widget>[
          ItemListHeader(text: "Category/Aisle here"),
          ItemList(list: list, listType: 'item'),
          ItemListHeader(text: "Crossed off"),
          ItemList(list: crossedOff, listType: "crossedOff"),
        ],
      ),
    );
  }),
);

Refactor notes: I renamed MainScreenList to ItemList and MainScreenListHeader to ItemListHeader, added an Item model, and expanded the listType handling to vary what is displayed for a single Item based on what kind of item it is.

Here’s where we’re at now, after this code was merged in:

I also added two new libraries to the project for date parsing:

These refactors and additions can be found in this pull request. (I’m switching to pull requests instead of individual commits now that the work for each step is getting more complex.)

Building the ‘Store details’ page UI

This is the page where the user edits an existing store.

I rolled the code for this one into the previous pull request, look for views/existing_store.dart to see how I built this one and how I pass in an existing store for editing.

(Editing a store doesn’t do anything yet, this is just more UI work.)

Now the user can tap around and change what page of the app they are on!

Routes and UI for adding and editing list items

There are just a few major routes left to build:

  • adding a new list item
  • editing an existing list item
  • user account stuff – I’ll save this stuff for later

Adding a new item

Items are individual things (“eggs”, “bread”, etc.) on the shopping lists. To keep the adding of new items quick and easy, the user only needs to enter the item’s name to add it to the list.

For now, the new_item.dart page is simple:

Later this will be expanded to running a real-time search on the user’s existing items so the user can quickly pick an existing item with a similar name instead of entering a brand new item every time they want to add “milk” to the list.

Something to note on this route is that the listID is passed as an argument. This is so the newly created item can be assigned a list when it is created. It uses the same “how to pass an argument to a route” pattern described here in the Flutter docs.

Editing an existing item

When viewing an item in the list, the little “i” icon on the right can be tapped to open up the item’s details.

(For now, tapping the item name itself will cross the item off the list. I don’t know if I like this particular UX – I keep tapping the item name hoping to edit it, only to remember I need to tap the “info” icon to edit it. This is good enough for now, though.)

Tapping the “i” opens up existing_item.dart where the user can modify any of the item’s properties, including:

  • name (such as “eggs” or “cut frozen green beans”).
  • quantity, which is an int for now but may be expanded into a more descriptive set of options later on.
  • private (boolean), which makes it so this item only shows to the person who added it to the list, so something like “cake for surprise party” isn’t visible to everyone else who has access to this list.
  • “okay to substitute” (boolean), which flags whether the shopper should attempt to buy a different item instead
  • a list of suitable substitutes, as well as an option for “any” (or some other way to communicate “not picky”)
  • urgent (boolean) for marking items as high priority
  • an item belongs to one user-defined category
  • an item belongs to at least one list

Most of this data is optional. The intention is that the user will create an item quickly then refine it over time, especially for recurring items. The substitution information is to help people in the same household communicate brand preferences to whoever is actually at the store to help cut down on real-time texting and confirming of item choices.

Now we have this:

Passing the item data into existing_item.dart is handled around line 97 in item_list.dart:

gotoExistingItemManagement(context) {
  if (listType == 'shopping list') {
    print("opening page: manage existing shopping list");
  ...
  } else if (listType == 'item') {
    Navigator.pushNamed(context, ExistingItem.routeName, arguments: ExistingItemArguments(item));
  } ...
}

ExistingItem is stateful, so the initial code structure should look familiar by now:

existing_item.dart

class ExistingItemArguments {
  final Item item;
  ExistingItemArguments(this.item);
}

class ExistingItem extends StatefulWidget {
  static const routeName = '/existingItem';
  ExistingItem({Key key});

  @override
  _ExistingItemState createState() => _ExistingItemState();
}

class _ExistingItemState extends State<ExistingItem> {
...
}

The tricky part was getting this stateful widget to use the item parameters that were passed to it.

This page is for editing an existing item, so I want to populate the form fields with its existing data, but I also want the user to be able to edit that data.

The first thing I added was “local” copies of the variables that will come in with the “args”.

class _ExistingItemState extends State<ExistingItem> {

  String _name = 'ERROR: ARGS NOT LOADED!';
  int _quantity = 0;
  bool _subsOk = false;
  bool _urgent = false;
  bool _private = false;
  ExistingItemArguments args;

Then, inside the Widget build(...) stuff, if args is null (they haven’t been loaded in yet), set args equal to the args that came in from the route.

@override
Widget build(BuildContext context) {
  if (args == null) {
    args = ModalRoute.of(context).settings.arguments;
    _setStartingValues(args.item);
  }

(See the official docs for more info on ModalRoute.of(context).settings.arguments)

Now we have a bunch of pages that are wired together, passing and displaying mock data:

Here is the pull request that finishes up this work by making the “edit existing item details” page elements functional.

You may also like to browse the repo at this point in development.

Up next in Part 4: refining the mock data, building components for repeated code, and improving the ways data gets passed around.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.