跳转至

7 Routing & Navigating

Flutter has two routing mechanisms:

  • Navigator 1: Imperative style
  • Navigator 2: Declarative style

Nav 1, which you’re probably most familiar with, is the oldest. It has a straightforward API and is very easy to understand. You can use Nav 1 in three different ways:

  1. Anonymous routes

  2. When creating the app widget:

  return MaterialApp(
    home: QuotesListScreen(),
  );
  • When pushing a new route:
  Navigator.push(
    context,
    MaterialPageRoute(
      builder: (context) => QuoteDetailsScreen(
        id: id,
      ),
    ),
  );
  1. Simple named routes

  2. When creating the app widget:

  return MaterialApp(
    initialRoute: '/quotes',
    routes: {
      '/quotes': (context) => QuotesListScreen(),
      '/quotes/details': (context) => QuoteDetailsScreen(
            id: ModalRoute.of(context)?.settings.arguments as int,
          ),
    },
  );
  • When pushing a new route:
  Navigator.pushNamed(
    context,
    '/quotes/details',
    arguments: 71, // The quote ID.
  );
  1. Advanced named routes

  2. When creating the app widget:

  return MaterialApp(
    initialRoute: '/quotes',
    onGenerateRoute: (settings) {
      final routeName = settings.name;
      if (routeName == '/') {
        return MaterialPageRoute(
          builder: (context) => QuotesListScreen(),
        );
      }

      if (routeName != null) {
        final uri = Uri.parse(routeName);
        if (uri.pathSegments.length == 2 &&
            uri.pathSegments.first == 'quotes') {
          final id = uri.pathSegments[1] as int;
          return MaterialPageRoute(
            builder: (context) => QuoteDetailsScreen(
              id: id,
            ),
          );
        }
      }

      return MaterialPageRoute(
        builder: (context) => UnknownScreen(),
      );
    },
  );
  • When pushing a new route:
  Navigator.pushNamed(
    context,
    '/quotes/71',
  );

Each of these has its pros and cons:

  • Anonymous routes are the easiest to learn but can give you a hard time when trying to reuse code — if two places in the app can open the same screen, for example.
  • Simple named routes solve the code reuse issue but still have the flaw of not allowing you to parse arguments from the route name. For example, if the app runs on the web, you can’t extract the quote ID from a link like /quotes/73.
  • Lastly, advanced named routes let you parse arguments from the route name but aren’t as easy to learn as their siblings.

As you can see, Navigator 1 has alternatives for all tastes. Why, then, did they have to come up with a Navigator 2?

Nav 1 — and all its variants — has a foundational flaw: It’s very hard to push or pop multiple pages at once, which is terrible for deep links or Flutter Web in general.

Deep linking is the ability to send the user a link — the deep link — that, when opened on a smartphone, launches a specific screen within the app instead of opening a web page. Pay attention to the fact that the link doesn’t simply launch the app; it launches a specific screen within the app — hence the “deep” in the name.

Deep links can be helpful to allow users to share links or enable your app’s notifications to take the user to particular content when tapped. You’ll leave the actual deep link implementation for the next chapter. The vital thing to have in mind now is: A solid routing strategy must be good at deep linking. Here enters Navigator 2.

Nav 2 completely nails any of the issues you can think of for Nav 1, but it comes with a cost: It’s dang hard to learn and use. Fortunately for you, that’s an easy problem to solve: The community has developed a plethora of packages that wrap over Nav 2 and make it easy to use. In this chapter, you’ll learn how to use Routemaster, a package that makes Nav 2 as straightforward as simple named routes. Along the way, you’ll also learn how to:

  • Quickly identify what the routing strategy of a codebase is.
  • Switch from Nav 1 to Nav 2.
  • Support nested navigation for tabs.
  • Manage and inject app-wide dependencies.
  • Connect your feature packages without coupling them.

Throughout this chapter, you’ll work on the starter project from this chapter’s assetsfolder.

Getting Started

Use your IDE of choice to open the starter project. Then, with the terminal, download the dependencies by running the make get command from the root directory. Wait for the command to finish executing, then build and run your app. For now, expect to see nothing but a giant X on the screen:

img

Note

If you’re having trouble running the app, it’s because you forgot to propagate the configurations you did in the first chapter’s starter project to the following chapters’ materials. If that’s the case, please revisit Chapter 1, “Setting up Your Environment”.

You can tell what routing strategy an app uses just by looking at how it instantiates MaterialApp:

  • Anonymous routes: Characterized by MaterialApp(home:).
  • Simple named routes: Characterized by MaterialApp(routes:).
  • Advanced named routes: Characterized by MaterialApp(onGenerateRoute:).
  • Navigator 2: Characterized by MaterialApp.router(routerDelegate:, routeInformationParser:).

Open the starter project’s lib/main.dart file. You’ll see that the code is currently using the anonymous route form to set a Placeholder widget as the app’s home screen — which explains the X you’re seeing. Starting with the next section, you’ll work on migrating your app to Navigator 2 and using it to display and connect all the screens you have in your feature packages.

img

Switching to Navigator 2

As you can see in the diagram below, Navigator 2 has quite a few moving parts:

img

Image recreated from Learning Flutter’s new navigation and routing systemand used under a Creative Commons Attribution 4.0 International License

Complicated, huh? But don’t worry. You’re only in charge of providing the two pieces in pink:

  • routerDelegate (an object of the type RouterDelegate): Takes calls like setInitialRoutePath(T configuration) and setNewRoutePath(T configuration) and reacts to them by rebuilding an actual Navigator widget with all your screens piled up.
  • routeInformationParser (an object of the type RouteInformationParser): Does the back-and-forth between URLs and that configuration object that the RouterDelegate takes in.

The Routemaster package eases your life by giving you a fully baked RouteInformationParser class and a half-baked RouterDelegate class — which you just need to supplement with your own routing table before being able to use it. In the end, the whole process will be pretty similar to using simple named routes, where you just need to provide a Map that defines all your routes.

Before you replace that X in your app with some actual screens, you’ll first switch your navigation system to Nav 2 without changing anything else. To start the work, open lib/main.dart if you haven’t done this yet.

img

Find // TODO: Instantiate the RouterDelegate., and replace it with:

// 1
late final _routerDelegate = RoutemasterDelegate(
  // 2
  routesBuilder: (context) {
    return RouteMap(
      routes: {
        // 3
        '/': (_) => const MaterialPage(
          child: Placeholder(),
        ),
      },
    );
  },
);

Here’s what’s happening:

  1. You’re creating a late property to hold a RoutemasterDelegate object — you’ll understand why the late later on. RoutemasterDelegate is Routemaster’s implementation of the Nav 2’s RouterDelegate class you read about a few paragraphs ago. You’re able to import this class because the Routemaster package is already listed as a dependency in your pubspec.yaml.
  2. To instantiate a RoutemasterDelegate, you have to supply the routesBuilder parameter. routesBuilder takes in a function that receives a BuildContext and returns a RouteMap object.
  3. To instantiate a RouteMap, you have to supply the routes parameter. Here’s where Routemaster’s approach gets close to simple named routes. The routes parameter receives a Map<String, PageBuilder>, which links every path you want to support in your app to a function that builds the corresponding Page object.

In Nav 2, you have to envelop your screen widgets around Page objects because that’s what the Navigator class manages. It’s similar to how, in Nav 1, you had to wrap your screens around Route objects. Besides holding the widget to be displayed, a Page also contains information about how you want to display that widget. The code above uses the MaterialPage class, which uses different transition animations for iOS and Android.

Now, to use the delegate you just created, continue on the same file and scroll down until you find the following line:

child: MaterialApp(

Then, replace the entire line with:

child: MaterialApp.router(

This is what characterizes the migration from Nav 1 to Nav 2, going from the MaterialApp default constructor to the MaterialApp.router one. Now, a little lower down, replace home: const Placeholder() with:

routeInformationParser: const RoutemasterParser(),
routerDelegate: _routerDelegate,

That’s it! A few paragraphs ago, you read that Navigator 2 requires you to provide two pieces of the gear: a RouterDelegate and a RouteInformationParser. This is how you supply both. The first one — RouterDelegate — you built with the Routemaster package’s help. The second one — RouteInformationParser — was a complete gift to you; you didn’t have to worry about customizing anything.

Your app is now officially using Navigator 2 — too bad it doesn’t show anything cool yet, but at least you’re on the right path. Build and run the app to make sure the migration went smoothly. Expect to see the same thing as before — just a Placeholder on the screen.

img

Next on your plate is replacing that Map you passed on to the routes property with another one containing some actual screens.

Supporting Bottom Tabs With Nested Routes

Alongside deep linking, one of the most common challenges when approaching routing in mobile apps is the ability to have nested routes. But what are nested routes?

Notice that when you tap a quote in WonderWords, part of the screen stays in place: the bottom navigation bar.

img

But that isn’t always the case. For example, when you go from the profile to the sign-in screen, the new screen completely covers what was on the screen before.

img

That means you have three navigation stacks:

  1. An external one that controls the entire window.
  2. A nested one that holds what’s above the bottom navigation bar when the Quotes tab is selected.
  3. A nested one that holds what’s above the bottom navigation bar when the Profile tab is selected.

The good news is that, with the Routemaster package, achieving nested routes is easier than you might think. To see it with your own eyes, open lib/tab_container_screen.dart.

img

Now, replace return Container(); with:

final l10n = AppLocalizations.of(context);
// 1
final tabState = CupertinoTabPage.of(context);

// 2
return CupertinoTabScaffold(
  controller: tabState.controller,
  tabBuilder: tabState.tabBuilder,
  tabBar: CupertinoTabBar(
    items: [
      BottomNavigationBarItem(
        // 3
        label: l10n.quotesBottomNavigationBarItemLabel,
        icon: const Icon(
          Icons.format_quote,
        ),
      ),
      BottomNavigationBarItem(
        label: l10n.profileBottomNavigationBarItemLabel,
        icon: const Icon(
          Icons.person,
        ),
       ),
    ],
  ),
);

Here’s what’s going on with the code above:

  1. CupertinoTabPage is a class that comes from the Routemaster package. As you can see a few lines below, CupertinoTabPage gives you the two pieces you need — a controller and a tabBuilder — to set up the tabbed layout structure using Flutter’s CupertinoTabScaffold. tabBuilder is responsible for building the inner screens you want to display for each tab. Meanwhile, controller controls the state of the bottom bar — which index is selected and such.
  2. The simplest way to implement bottom-tabbed screens is using this CupertinoTabScaffold from the cupertino library. Notice this is the first time you’re using a widget from cupertino instead of material. A nice historical background to have in mind is that bottom-tabbed layouts were first popularized by iOS apps — the opposing standard on Android used to be navigation drawers. Bottom tabs quickly became as popular on Android as they were on iOS. Even Google apps started adopting them — YouTube is a great example.
  3. This is how you retrieve a localized String in WonderWords. Don’t worry about this for now; you’ll learn all about it in Chapter 9, “Internationalizing & Localizing”.

The code above won’t work out of the gate. You first need to do some setup to connect this TabContainerScreen widget to the rest of the app and ensure there will be a CupertinoTabPage available when the CupertinoTabPage.of(context) call executes. To address this, open routing_table.dart.

img

Now, replace // TODO: Define the app's paths. with:

class _PathConstants {
  const _PathConstants._();

  static String get tabContainerPath => '/';

  static String get quoteListPath => '${tabContainerPath}quotes';

  static String get profileMenuPath => '${tabContainerPath}user';

  static String get updateProfilePath => '$profileMenuPath/update-profile';

  static String get signInPath => '${tabContainerPath}sign-in';

  static String get signUpPath => '${tabContainerPath}sign-up';

  static String get idPathParameter => 'id';

  static String quoteDetailsPath({
    int? quoteId,
  }) =>
      '$quoteListPath/${quoteId ?? ':$idPathParameter'}';
}

This is a class you’re creating to centralize all your screens’ paths. So, for example, the quotes list screen is /quotes, while the quote details screen is /quotes/:id, where :id is the placeholder for the actual quote ID. Notice the quoteDetailsPath() function can be used in two ways:

  • If you pass null for the quoteId parameter, it’ll return the path with the :id placeholder, which is useful for when you’re declaring the route.
  • If you pass a value for the quoteId parameter, the generated path will have an actual ID instead of the placeholder, which is useful for when you’re using the _PathConstants class to navigate to the quote details screen.

You’ll see these two use cases shortly. For now, you’ll get back to the bottom-tabbed layout. Now that you have your path constants, you have everything you need to continue from where you left. Still in the same routing_table.dart file, replace // TODO: Create the app's routing table. with:

// 1
Map<String, PageBuilder> buildRoutingTable({
  // 2
  required RoutemasterDelegate routerDelegate,
  required UserRepository userRepository,
  required QuoteRepository quoteRepository,
  required RemoteValueService remoteValueService,
  required DynamicLinkService dynamicLinkService,
}) {
  return {
    // 3
    _PathConstants.tabContainerPath: (_) => 
      // 4
      CupertinoTabPage(
          child: const TabContainerScreen(),
          paths: [
            _PathConstants.quoteListPath,
            _PathConstants.profileMenuPath,
          ],
        ),
    // TODO: Define the two nested routes homes.
  };
}

Here, you’re creating a function you’ll call shortly from the main.dart file to replace the fake routes map you have in there right now. Here’s what you have in the function so far:

  1. The return type is a Map<String, PageBuilder>. As you’ve seen, this maps each path you want to support — the String — to the function that builds the corresponding page — the PageBuilder.
  2. Most of the dependencies you’ll need to instantiate your screens are already available on lib/main.dart, so you’re asking them to be passed onto this function so you can reuse them.
  3. The first path you declared is your app’s entry point: the /. The screen you’re assigning to this path is the TabContainerScreen you’ve created two code snippets above. This is the outermost screen that will hold the bottom tab along with the two nested navigation stacks.
  4. To achieve the nested navigation layout, you wrapped your TabContainerScreen widget inside a CupertinoTabPage class. You then leveraged its paths parameter to define which two routes should be the entry point for each internal flow.

To ensure you understand what’s going on, go back to that tab_container_screen.dartfile you were working on. Observe how the code you wrote there links to what you’ve done now: You use a CupertinoTabPage.of(context) call to retrieve the current state of the tab. That call only works because you’ve now wrapped your TabContainerScreen inside a CupertinoTabPage class.

Now, to finish the nested routing setup, you have to link your inner paths — quoteListPath and profileMenuPath — to the actual widgets that represent them. To do this, replace // TODO: Define the two nested routes homes. with:

_PathConstants.quoteListPath: (route) {
  return MaterialPage(
    // 1
    name: 'quotes-list',
    child: QuoteListScreen(
      quoteRepository: quoteRepository,
      userRepository: userRepository,
      onAuthenticationError: (context) {
        // 2
        routerDelegate.push(_PathConstants.signInPath);
      },
      onQuoteSelected: (id) {
        // 3
        final navigation = routerDelegate.push<Quote?>(
          _PathConstants.quoteDetailsPath(
            quoteId: id,
          ),
        );
        return navigation.result;
      },
      remoteValueService: remoteValueService,
    ),
  );
},
_PathConstants.profileMenuPath: (_) {
  return MaterialPage(
    name: 'profile-menu',
    child: ProfileMenuScreen(
      quoteRepository: quoteRepository,
      userRepository: userRepository,
      onSignInTap: () {
        routerDelegate.push(
          _PathConstants.signInPath,
        );
      },
      onSignUpTap: () {
        routerDelegate.push(
          _PathConstants.signUpPath,
        );
      },
      onUpdateProfileTap: () {
        routerDelegate.push(
          _PathConstants.updateProfilePath,
        );
      },
    ),
  );
},
// TODO: Define the subsequent routes.

Observe how this code fills in the gaps. In the previous code snippet, you defined that the two pages you want to display for each tab are _PathConstants.quoteListPathand _PathConstants.profileMenuPath. Now, you’re telling Routemaster how to actually build these two pages. This is what should be new to you with the code above:

  1. Assigning a name to your page isn’t mandatory but will be helpful when you’re writing analytics code in Chapter 12, “Supporting the Development Lifecycle With Firebase”.
  2. Here, you’re using the RoutemasterDelegate you created on main.dart to navigate to a new screen. You can use it in this file because you asked for it as a parameter of this buildRoutingTable function.
  3. The navigation here is a bit more complicated. The quoteDetailsPath route may return a result: the updated Quote object if the user interacted with the quote while on that screen — by favoriting it, for example. You then return that Quote object to the onQuoteSelected callback just so your QuoteListScreen can update that quote’s list item if something changed.

The snippet above is very representative of a vital piece of WonderWord’s architecture: Feature packages don’t execute navigation. Notice all integration between the screens happens here through the callbacks. The only package that imports Routemaster, and thus can navigate, is the main package, the one you’re working on right now.

Now, before you can run your code, go back to the lib/main.dart file. Inside the declaration of the _routerDelegate property, replace:

return RouteMap(
  routes: {
    '/': (_) => const MaterialPage(
      child: Placeholder(),
    ),
  },
);

With:

return RouteMap(
  routes: buildRoutingTable(
    routerDelegate: _routerDelegate,
    userRepository: _userRepository,
    quoteRepository: _quoteRepository,
    remoteValueService: widget.remoteValueService,
    dynamicLinkService: _dynamicLinkService,
  ),
);

Here, you’re finally replacing the fake routes map with the real one you just created inside the routing_table.dart file.

Build and run your code, and watch the Placeholder disappear. You’ll now see the bottom navigation bar along with the root Quotes and Profile screens working just fine.

img

Finally, try tapping a quote so you can see an error on the screen. It should say that the route isn’t defined yet. That’s because you’ve only defined your root routes — the container screen and its two inner screens. You’ll fix that now.

Defining the Subsequent Routes

Go back to the lib/routing_table.dart file, find // TODO: Define the subsequent routes., and replace it with:

_PathConstants.updateProfilePath: (_) => MaterialPage(
  name: 'update-profile',
  child: UpdateProfileScreen(
    userRepository: userRepository,
    onUpdateProfileSuccess: () {
      routerDelegate.pop();
    },
  ),
),
_PathConstants.quoteDetailsPath(): (info) => MaterialPage(
    name: 'quote-details',
    child: QuoteDetailsScreen(
      quoteRepository: quoteRepository,
      // 1
      quoteId: int.parse(
        info.pathParameters[_PathConstants.idPathParameter] ?? '',
      ),
      onAuthenticationError: () {
        routerDelegate.push(_PathConstants.signInPath);
      },
      // 2
      shareableLinkGenerator: (quote) =>
          dynamicLinkService.generateDynamicLinkUrl(
        path: _PathConstants.quoteDetailsPath(
          quoteId: quote.id,
        ),
        socialMetaTagParameters: SocialMetaTagParameters(
          title: quote.body,
          description: quote.author,
        ),
      ),
    ),
  ),
_PathConstants.signInPath: (_) => MaterialPage(
  name: 'sign-in',
  fullscreenDialog: true,
  child: Builder(
    builder: (context) {
      return SignInScreen(
        userRepository: userRepository,
        onSignInSuccess: () {
          routerDelegate.pop();
        },
        onSignUpTap: () {
          routerDelegate.push(_PathConstants.signUpPath);
        },
        onForgotMyPasswordTap: () {
          showDialog(
            context: context,
            builder: (context) {
              return ForgotMyPasswordDialog(
                userRepository: userRepository,
                onCancelTap: () {
                  routerDelegate.pop();
                },
                onEmailRequestSuccess: () {
                  routerDelegate.pop();
                },
              );
            },
          );
        },
      );
    },
  ),
),
_PathConstants.signUpPath: (_) => MaterialPage(
  name: 'sign-up',
  child: SignUpScreen(
    userRepository: userRepository,
    onSignUpSuccess: () {
      routerDelegate.pop();
    },
  ),
),

As big as this code snippet is, you should already understand most of it. The only two new things are:

  1. This info.pathParameters[_PathConstants.idPathParameter] is how you extract a path parameter from a route. For example, when the user taps a quote on the quote list screen, you push a route with that quote’s ID embedded within the path, such as /quotes/13. Here, you’re extracting that 13 and passing it to the QuoteDetailsScreen. The reason you had to wrap it in an int.parse() call is because all path parameters come to you as Strings.
  2. This is just using Firebase to generate a shareable link for a quote. You’ll learn all about this in the next chapter, “Deep Linking”.

That’s all. Build and run your app for the last time, and now you should be able to navigate to inner screens just fine. For example, tap a quote and watch the details screen open just fine while still keeping the bottom navigation bar.

img

Key Points

  • Navigator 1 is flexible and easy to use but not good at deep linking.
  • A solid routing strategy must support deep linking.
  • Navigator 2 is very good at deep linking but comes with a cost: It’s very hard to learn and use.
  • The best way to cope with Navigator 2 is to use wrapper packages, such as Routemaster.
  • With Routemaster, working with Nav 2 becomes almost as simple as using simple named routes from Nav 1.
  • When architecting an app with feature packages, consider handling all integration between the features — i.e., the navigation — inside a package that’s hierarchically above all of them.