Chapters

Hide chapters

Flutter Apprentice

First Edition · Flutter 2.2.0 · Dart 2.13.0 · Android Studio 4.2.1

Section IV: Networking, Persistence and State

Section 4: 7 chapters
Show chapters Hide chapters

Appendices

Section 6: 2 chapters
Show chapters Hide chapters

7. Routes & Navigation
Written by Vincent Ngo

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

Navigation, or how users switch between different screens, is an important concept to master. Good navigation keeps your app organized and helps users find their way around your app without getting frustrated.

In the previous chapter, you got a small taste of navigation when you created a grocery list for users to manage what to buy. When the user taps an item, it shows the item details:

But this uses the imperative style of navigation, known as Navigator 1.0. In this chapter, you’ll learn to navigate between screens the declarative way.

You’ll cover the following topics:

  • Quick overview of Navigator 1.0.
  • Overview of Navigator 2.0 and how to use it.
  • How to drive navigation through state by using the provider package.
  • How to handle the Android system’s back button.

By the end of this chapter, you will know everything you need to navigate to different screens!

Note: If you’d like to skip straight to the code, jump ahead to the Getting Started section. If you’d like to learn the theory first, read on!

Introducing Navigator

If you come from an iOS background, you might be familiar with UINavigationController. This controller defines a stack-based scheme to manage and navigate between view controllers.

In Android, you use Jetpack Navigation to manage various fragments.

In Flutter, you use a Navigator widget to manage your screens or pages. You can think of screens or pages as routes.

Note: This chapter uses these terms interchangeably because they all mean the same thing.

A stack is a data structure that manages pages. You insert the elements last-in, first-out (LIFO), and only the element at the top of the stack is visible to the user.

For example, when a user views a list of grocery items, tapping an item pushes GroceryItemScreen to the top of the stack. Once the user finishes making changes, you pop it off the stack.

Here’s a top-level and a side-level view of the navigation stack:

Now, it’s time for a quick overview of Navigator 1.0.

Navigator 1.0 overview

Before the release of Flutter 1.22, you could only shift between screens by issuing direct commands like “show this now” or “remove the current screen and go back to the previous one”. Navigator 1.0 provides a simple set of APIs for you to navigate between screens. The most common ones include:

Pushing and popping routes

To show another screen to the user, you need to push a Route onto the Navigator stack. Here’s an example of that code:

bool result = await Navigator.push<bool>(
  context,
  MaterialPageRoute<bool>(
    builder: (BuildContext context) => OnboardingScreen()
  ),
);
Navigator.pop(context);

Navigator 1.0’s disadvantages

The imperative API may seem natural and easy to use but, in practice, it’s hard to manage and scale.

Navigator 2.0 overview

Flutter 1.22 introduced Navigator 2.0, a new declarative API that allows you to take full control of your navigation stack. It aims to feel more Flutter-like while solving the pain points of Navigator 1.0. Its main goals include:

Navigation and unidirectional data flow

The imperative API is very basic, forcing you to place push() and pop() functions all over your widget hierarchy — which couples all your widgets! To present another screen, you also have to place callbacks up the widget hierarchy.

Is Navigator 2.0 always better than Navigator 1.0?

If you have an existing project, you don’t have to migrate or convert your existing code to use the new API.

Getting started

Open the starter project in Android Studio, run flutter pub get, then run the app.

Changes to the project files

Before you dive into navigation, there are new files in this starter project to help you out.

What’s new in the screens folder

There are eight new changes in lib/screens/:

Changes to the models folder

There are a few changes to files in lib/models/.

Additional assets

assets/sample_data/ contains the following mock data:

New packages

There are two new packages in pubspec.yaml:

smooth_page_indicator: ^0.2.3
webview_flutter: ^2.0.7

Android SDK version

If you open android/app/build.gradle you will notice that the minSdkVersion is now 19, as shown below:

android {
    defaultConfig {
    	...
        minSdkVersion 19
        ...
    }
}

Looking over the UI flow

Here are the first three screens you show the user:

Managing your app state

The first step is to define your app state, how it can change and which components it notifies when a change occurs.

import 'dart:async';
import 'package:flutter/material.dart';

// 1
class FooderlichTab {
  static const int explore = 0;
  static const int recipes = 1;
  static const int toBuy = 2;
}

class AppStateManager extends ChangeNotifier {
  // 2
  bool _initialized = false;
  // 3
  bool _loggedIn = false;
  // 4
  bool _onboardingComplete = false;
  // 5
  int _selectedTab = FooderlichTab.explore;

  // 6
  bool get isInitialized => _initialized;
  bool get isLoggedIn => _loggedIn;
  bool get isOnboardingComplete => _onboardingComplete;
  int get getSelectedTab => _selectedTab;

  // TODO: Add initializeApp
  // TODO: Add login
  // TODO: Add completeOnboarding
  // TODO: Add goToTab
  // TODO: Add goToRecipes
  // TODO: Add logout
}

Initializing the app

Within the same file, locate // TODO: Add initializeApp and replace it with the following:

void initializeApp() {
  // 7
  Timer(const Duration(milliseconds: 2000), () {
    // 8
    _initialized = true;
    // 9
    notifyListeners();
  });
}

Logging in

Next, locate // TODO: Add login and replace it with the following:

void login(String username, String password) {
  // 10
  _loggedIn = true;
  // 11
  notifyListeners();
}

Completing the onboarding

Next, locate // TODO: Add completeOnboarding and replace it with the following:

void completeOnboarding() {
  _onboardingComplete = true;
  notifyListeners();
}

Setting the selected tab

Locate // TODO: Add goToTab and replace it with the following:

void goToTab(index) {
  _selectedTab = index;
  notifyListeners();
}

Navigating to the Recipes tab

Locate // TODO: Add goToRecipes and replace it with the following:

void goToRecipes() {
  _selectedTab = FooderlichTab.recipes;
  notifyListeners();
}

Adding the log out capability

Locate // TODO: Add logout and replace it with the following:

void logout() {
  // 12
  _loggedIn = false;
  _onboardingComplete = false;
  _initialized = false;
  _selectedTab = 0;

  // 13
  initializeApp();
  // 14
  notifyListeners();
}
export 'app_state_manager.dart';

Using the new AppStateManager

Open lib/main.dart, locate // TODO: Create AppStateManager and replace it with the following:

final _appStateManager = AppStateManager();
ChangeNotifierProvider(create: (context) => _appStateManager,),

Creating the router

Router configures the list of pages the Navigator displays. It listens to state managers and, based on the state changes, configures the list of page routes.

import 'package:flutter/material.dart';
import '../models/models.dart';
import '../screens/screens.dart';

// 1
class AppRouter extends RouterDelegate
    with ChangeNotifier, PopNavigatorRouterDelegateMixin {
  // 2
  @override
  final GlobalKey<NavigatorState> navigatorKey;

  // 3
  final AppStateManager appStateManager;
  // 4
  final GroceryManager groceryManager;
  // 5
  final ProfileManager profileManager;

  AppRouter({
    this.appStateManager,
    this.groceryManager,
		this.profileManager
  })
      : navigatorKey = GlobalKey<NavigatorState>() {
    // TODO: Add Listeners
  }

  // TODO: Dispose listeners

  // 6
  @override
  Widget build(BuildContext context) {
    // 7
    return Navigator(
      // 8
      key: navigatorKey,
      // TODO: Add onPopPage
      // 9
      pages: [
        // TODO: Add SplashScreen
        // TODO: Add LoginScreen
        // TODO: Add OnboardingScreen
        // TODO: Add Home
        // TODO: Create new item
        // TODO: Select GroceryItemScreen
        // TODO: Add Profile Screen
        // TODO: Add WebView Screen
      ],
    );
  }

  // TODO: Add _handlePopPage

  // 10
  @override
  Future<void> setNewRoutePath(configuration) async => null;
}

Handling pop events

Locate // TOOD: Add _handlePopPage and replace it with the following:

bool _handlePopPage(
  // 1
  Route<dynamic> route,
  // 2
  result) {
  // 3
  if (!route.didPop(result)) {
    // 4
    return false;
  }

  // 5
  // TODO: Handle Onboarding and splash
  // TODO: Handle state when user closes grocery item screen
  // TODO: Handle state when user closes profile screen
  // TODO: Handle state when user closes WebView screen
	// 6
  return true;
}
onPopPage: _handlePopPage,

Adding state listeners

Now, you need to connect the state managers. When the state changes, the router will reconfigure the navigator with a new set of pages.

appStateManager.addListener(notifyListeners);
groceryManager.addListener(notifyListeners);
profileManager.addListener(notifyListeners);
@override
void dispose() {
	appStateManager.removeListener(notifyListeners);
  groceryManager.removeListener(notifyListeners);
	profileManager.removeListener(notifyListeners);
  super.dispose();
}

Using your app router

The newly created router needs to know who the managers are, so you’ll now connect it to the state, grocery and profile managers.

import 'navigation/app_router.dart';
AppRouter _appRouter;
@override
void initState() {
  _appRouter = AppRouter(
    appStateManager: _appStateManager,
    groceryManager: _groceryManager,
    profileManager: _profileManager,
  );
  super.initState();
}
home: Router(
	routerDelegate: _appRouter,
  // TODO: Add backButtonDispatcher
),
import 'screens/splash_screen.dart';

Adding screens

With all the infrastructure in place, it’s now time to define which screen to display according to the route. But first, check out the current situation. Build and run on iOS. You’ll notice an exception in the Run tab:

Showing the Splash screen

You’ll start from the beginning, displaying the Splash screen.

import 'package:provider/provider.dart';
import '../models/models.dart';
static MaterialPage page() {
  return MaterialPage(
    name: FooderlichPages.splashPath,
      key: ValueKey(FooderlichPages.splashPath),
      child: const SplashScreen(),);
}
Provider.of<AppStateManager>(context, listen: false).initializeApp();
if (!appStateManager.isInitialized) SplashScreen.page(),

Displaying the Login screen

You’ll now implement the first step of the routing logic: displaying the Login screen after the Splash screen if the user isn’t logged in.

import 'package:provider/provider.dart';
import '../models/models.dart';
static MaterialPage page() {
  return MaterialPage(
      name: FooderlichPages.loginPath,
      key: ValueKey(FooderlichPages.loginPath),
      child: const LoginScreen());
}
if (appStateManager.isInitialized && !appStateManager.isLoggedIn)
LoginScreen.page(),

Provider.of<AppStateManager>(context, listen: false)
  .login('mockUsername', 'mockPassword');

Transitioning from Login to Onboarding screen

When the user is logged in, you want to show the Onboarding screen.

import 'package:provider/provider.dart';
import '../models/models.dart';
static MaterialPage page() {
  return MaterialPage(
      name: FooderlichPages.onboardingPath,
      key: ValueKey(FooderlichPages.onboardingPath),
      child: const OnboardingScreen(),);
}
if (appStateManager.isLoggedIn &&
    !appStateManager.isOnboardingComplete)
OnboardingScreen.page(),

Handling the Skip and Back buttons in Onboarding

When the user taps the Skip button rather than going through the Onboarding guide, you want to show the usual home screen.

Provider.of<AppStateManager>(context, listen: false)
  .completeOnboarding();
if (route.settings.name == FooderlichPages.onboardingPath) {
  appStateManager.logout();
}

Transitioning from Onboarding to Home

When the user taps Skip, the app will show the Home screen. Open lib/screens/home.dart and add the following imports:

import 'package:provider/provider.dart';
import '../models/models.dart';
static MaterialPage page(int currentTab) {
  return MaterialPage(
      name: FooderlichPages.home,
      key: ValueKey(FooderlichPages.home),
      child: Home(
        currentTab: currentTab,
      ),);
}
if (appStateManager.isOnboardingComplete)
Home.page(appStateManager.getSelectedTab),

Handling tab selection

Open home.dart, locate // TODO: Wrap Consumer for AppStateManager and replace it with the following:

return Consumer<AppStateManager>(
  builder: (context, appStateManager, child) {
  },);
Provider.of<AppStateManager>(context, listen: false)
  	.goToTab(index);

Handling the Browse Recipes button

Now, you want to add that tapping the Browse Recipes button brings the user to the Recipes tab.

import 'package:provider/provider.dart';
import '../models/models.dart';
Provider.of<AppStateManager>(context, listen: false)
  .goToRecipes();

Showing the Grocery Item screen

Next, you’ll connect the Grocery Item screen. Open lib/screens/grocery_item_screen.dart. Locate // TODO: GroceryItemScreen MaterialPage Helper and replace it with the following:

static MaterialPage page(
    {GroceryItem item,
    int index,
    Function(GroceryItem) onCreate,
    Function(GroceryItem, int) onUpdate}) {
  return MaterialPage(
      name: FooderlichPages.groceryItemDetails,
      key: ValueKey(FooderlichPages.groceryItemDetails),
      child: GroceryItemScreen(
        originalItem: item,
        index: index,
        onCreate: onCreate,
        onUpdate: onUpdate,
      ),);
}

Creating a new grocery item

Open lib/screens/grocery_screen.dart and locate // TODO: Create New Item. Replace it with the following:

Provider.of<GroceryManager>(context, listen: false).createNewItem();
// 1
if (groceryManager.isCreatingNewItem)
// 2
GroceryItemScreen.page(
  onCreate: (item) {
    // 3
    groceryManager.addItem(item);
  },),

Editing an existing grocery item

Open grocery_list_screen.dart, locate // TODO: Tap on grocery item and replace it with the following:

manager.groceryItemTapped(index);
// 1
if (groceryManager.selectedIndex != null)
// 2
GroceryItemScreen.page(
  item: groceryManager.selectedGroceryItem,
  index: groceryManager.selectedIndex,
  onUpdate: (item, index) {
    // 3
    groceryManager.updateItem(item, index);
  },),

Dismissing the Grocery Item screen

Sometimes, a user starts to add a grocery item, then changes their mind. To cover this case, open app_router.dart, locate // TODO: Handle state when user closes grocery item screen and replace it with the following:

if (route.settings.name == FooderlichPages.groceryItemDetails) {
	groceryManager.groceryItemTapped(null);
}

Navigating to the Profile screen

The user can’t navigate to the Profile screen yet. Before you can fix that, you need to handle the state changes.

Provider.of<ProfileManager>(context, listen: false)
.tapOnProfile(true);
Provider.of<ProfileManager>(context, listen: false)
  .tapOnProfile(false);
static MaterialPage page(User user) {
  return MaterialPage(
      name: FooderlichPages.profilePath,
      key: ValueKey(FooderlichPages.profilePath),
      child: ProfileScreen(user: user),);
}
if (profileManager.didSelectUser)
ProfileScreen.page(profileManager.getUser),

if (route.settings.name == FooderlichPages.profilePath) {
  profileManager.tapOnProfile(false);
}

Navigating to raywenderlich.com

Within the Profile screen, you can do three things:

Transitioning from Profile to WebView

Return to profile_screen.dart, locate // TODO: Open raywenderlich.com WebView and replace it with the following:

Provider.of<ProfileManager>(context, listen: false)
  .tapOnRaywenderlich(true);
import '../models/models.dart';
static MaterialPage page() {
  return MaterialPage(
      name: FooderlichPages.raywenderlich,
      key: ValueKey(FooderlichPages.raywenderlich),
      child: const WebViewScreen(),);
}
if (profileManager.didTapOnRaywenderlich)
WebViewScreen.page(),

if (route.settings.name == FooderlichPages.raywenderlich) {
  profileManager.tapOnRaywenderlich(false);
}

Logging out

To handle logging out the user, go to profile_screen.dart and locate // TODO: Logout user. Replace it with the following:

// 1
Provider.of<ProfileManager>(context, listen: false)
    .tapOnProfile(false);
// 2
Provider.of<AppStateManager>(context, listen: false).logout();

Handling the Android system’s Back button

If you have been running the project on iOS, stop the app in your existing device or simulator. Now, build and run your app on an Android device or emulator. Do the following tasks:

backButtonDispatcher: RootBackButtonDispatcher(),

Key points

  • You can wrap another router in a containing widget.
  • Navigator 1.0 is useful for quick and simple prototypes, presenting alerts and dialogs.
  • Navigator 2.0 is useful when you need more control and organization when managing the navigation stack.
  • In Navigator 2.0, the navigator widget holds a list of MaterialPage objects.
  • Use a router widget to listen to navigation state changes and configure your navigator’s list of pages.
  • Setting the router’s Back button dispatcher lets you listen to platform system events.

Where to go from here?

You’ve now learned how to navigate between screens the declarative way. Instead of calling push() and pop() in different widgets, you use multiple state managers to manage your state.

Other libraries to check out

Navigator 2.0 can be a little hard to understand and manage on its own. The packages below wrap around the Navigator 2.0 API to make routing and navigation easier:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now