This chapter covers
When I was planning this chapter, I was trying to answer these questions: “Why?” or “Who cares?” These are standard questions that Manning encourages their authors to think about before writing a chapter. And, well, this time, these were pretty easy to answer: everyone who doesn’t want to make an app with a single page. Thus, a chapter on routing.
Routing can be a real pain on many platforms (but it shouldn’t have to be!). This point is all too clear in the web world. There are countless libraries that solely implement routing for different frameworks. And, speaking of the web world, I think the folks behind React Router nailed the solution. It’s easy to use, and it’s flexible. It matches the reactive and composable UI style of React.
According to their docs, they’re in the business of “dynamic routing,” rather than static. Historically, most routing was declarative, and routes were configured before the app rendered. The creators of React Router (https://reacttraining.com/react-router/) explained it well in their docs:
“When we say dynamic routing, we mean routing that takes place as your app is rendering. Not in a configuration or convention outside of a running app.”
I’m talking about React Router right now, because the mental-model needed for routing in Flutter is the same. And, to be candid, I didn’t know how to approach this topic, so I looked to people who are much smarter than me. (Thanks, React Router team).
The advantage to dynamic routing is that it’s flexible. You can have a super complicated app without ever declaring a route, because you can create new pages on the fly. The Flutter Navigator also gives you the option of declaring routes and pages, if you want to take the static approach. And you can (and probably will) mix and match static routes and “on-the-fly” routes.
Routing in Flutter is never really static, but you can declare all your routes up front, so the mental model is the same.
In Flutter, pages are just widgets that we assign to routes. And routes are managed by the Navigator, which is (you guessed it) just a widget! A Navigator widget is an abstraction over a widget that lays its children out in a stack nature. Because they’re widgets, you can nest Navigators up and down your app, willy-nilly. (Routers in routers in routers ....) But before we get into the how-tos, let’s take a look at the app that I’m using as reference for the next couple of chapters.
I live in Portland, Oregon, where people love Farmers Markets. I mean deeply love them. In an unnatural way. So, I thought I’d get rich by breaking into that market. Figure 7.1 shows the app I made for people to buy veggies and other treats from farmers.
The routing structure of the app isn’t too complicated. It’s only four pages. Our job in this chapter is to wire up the menu with some routes and create a few on-the-fly routes. Then, we’re going to make it fancy with some page transition animations.
The interesting thing about the routes in this app, in my very biased opinion, is that all the pages share the same structural elements. The app bar, the cart icon, the menu, and some of the functionality and the scaffold are all written once, and I pass in the configuration based on the route. This is possible for two reasons: the way you compose an UI in Flutter and the fact that the Navigator is a widget that doesn’t have to be top-level.
In the Git repository, you’ll find the relevant files for this book and, in particular, this app. The following listing shows these files.
lib blocs 1 app_bloc.dart cart_bloc.dart catalog_bloc.dart user_bloc.dart menu 2 app_menu_drawer.dart page 3 base page_background_image.dart page_base.dart page_container.dart cart_page.dart catalog_page.dart product_detail_page.dart user_settings_page.dart utils 4 material_route_transition.dart styles.dart widget 5 add_to_cart_bottom_sheet.dart appbar_cart_icon.dart catalog.dart product_detail_card.dart app.dart 6 main.dart
If you’ve built web apps or mobile apps on nearly any other platform, you’ve likely dealt with declarative routing. On the other application platforms that I’ve used (such as Ruby on Rails, Django, and front-end libraries of the not-very-distant past), routes are defined in their own “routes” file, and what you declare is what you get. In AngularDart, your routes page might look like the next listing.
static final routes = [ new RouteDefinition( routePath: new RoutePath(path: '/user/1'); 1 component: main.AppMainComponentNgFactory), 2 new RouteDefinition( routePath: new RoutePath(path: '/404'); component: page_not_found.PageNotFoundComponentNgFactory) //... etc. ];
Understanding Angular code isn’t important. This is an example of up-front route declarations written in Dart. The point is that you tell your app explicitly which routes you want to exist and which views they should route to. Each RouteDefinition has a path and a component (which is probably a page). This is generally done at the top level of an app. Pretty standard stuff here.
Flutter supports this. Although the routes and pages are still built while the app is running, the mental model that you can approach this with is that these are static. Mobile apps often support tens of pages, and it’s perhaps easier to reason about if you define them once and then reference them by name, rather than creating unnamed routes all over the app.
Flutter routes follow the path conventions of all programming, such as /users/1/ inbox or /login. And as you’d expect, the route of the home page of your app is / (a single forward slash).
There are two parts to using named routes. The first is defining the routes. In the e-commerce app you’re building in this chapter, the named routes are set up in the lib/app.dart file. If you navigate to that file, you’ll see a MaterialApp widget with the routes established, and in the utils/e_commerce_routes.dart file, you’ll see the static variables with the actual route names. (This is just so I can safely use routes without fearing typos in the strings.)
// e_commerce/lib/app.dart -- line ~ 51 // ... return MaterialApp( debugShowCheckedModeBanner: false, theme: _theme, routes: { 1 ECommerceRoutes.catalogPage: (context) => PageContainer(pageType: PageType.Catalog), ECommerceRoutes.cartPage: (context) => PageContainer(pageType: PageType.Cart), ECommerceRoutes.userSettingsPage: (context) => PageContainer(pageType: PageType.Settings), ECommerceRoutes.addProductFormPage: (context) => PageContainer(pageType: PageType.AddProductForm), }, navigatorObservers: [routeObserver], 2 ); // e_commerce/lib/utils/e_commerce_routes.dart class ECommerceRoutes { 3 static final catalogPage = '/'; static final cartPage = '/cart'; static final userSettingsPage = '/settings'; static final cartItemDetailPage = '/itemDetail'; static final addProductFormPage = '/addProduct'; }
Navigating to named routes is as easy as using the Navigator.pushNamed method. The pushNamed method requires a BuildContext and a route name, so you can use it anywhere that you have access to your BuildContext. It looks like this:
final value = await Navigator.pushNamed(context, "/cart");
Pushing and popping routes is the bread and butter of routing in Flutter. Recall that the Navigator (figure 7.2) lays its children (the pages) out in a “stack” nature. The stack operates on a “last in, first out” principle, as stacks do in computer science. If you’re looking at the home page of your app, and you navigate to a new page, you “push” that new page on top of the stack (and on top of the home page). The top item on the stack is what you see on the screen. If you pushed another route, we’ll call it page three, and wanted to get back to the home page, you’d have to “pop” twice.
The Navigator class has a bunch of helpful methods to manage the stack. I find myself using only a handful of the methods:
One important note about pushing named routes is that they return a Future. If you’re not familiar with the await keyword, it’s used to mark expressions that return an asynchronous value. We’re not going to get into async Dart quite yet. But the quick version is that when you call Navigator.pushNamed, it immediately returns a Future object, which says, “Hey, I don’t have a value you for you yet, but I will as soon as this process finishes.” In the specific context of routing, this means: “As soon as they navigate back to here from the page they’re on now, I’ll give you whatever value they pass back from that page.” Later in this chapter, we’ll explore passing values between routes more in-depth.
In the e-commerce project repository, you can find an example of using Navigator .pushNamed in the AppBarCartIcon widget found in the lib/widget/appbar_cart_icon .dart file. Figure 7.3 shows the widget.
This icon has a little bubble on it that keeps track of how many items are in the user’s cart. And the widget is actually an IconButton widget. It’s wired up to navigate to the cart page when it’s tapped and the onPressed callback is called:
onPressed: () { return Navigator.of(context).pushNamed("/cartPage"); },
That’s all there is to it. For another example of using named routes, you can find a tutorial in the official docs at https://flutter.dev/docs/cookbook/navigation/named-routes.
You might notice that Navigator.of(context).pushNamed(String routeName) function signature isn’t the same as the previously mentioned Navigator.pushNamed(BuildContext context, String routeName) signature. These are interchangeable.
If you’ve seen a Material Design app, you’re probably familiar with the type of app drawer shown in figure 7.4.
Because this chapter is about routing, I think now’s a good time to explore how to build that menu. First, let’s think about what we actually want it to do:
This particular menu drawer is a combination of only five required widgets, all of which are built into Flutter:
The Drawer is the widget that houses this menu. It takes a single widget in its child argument. You can pass it whatever you want (because everything is a widget). A Drawer will most likely be passed to a Scaffold in its drawer argument.
If you also have an AppBar in a scaffold with a drawer, then Flutter automatically sets the right-side icon on the app bar to a menu button, which opens the menu on tap. The menu will animate out nicely, and close when you swipe it left or tap the overlay to the right.
You can override the automatic menu button by setting Scaffold .automaticallyImplyLeading to false.
Next, the ListView. A list view is a layout widget that arranges widgets in a scrollable container. It arranges its children vertically by default, but it’s quite customizable. We’ll talk about scrolling and scrollable widgets in-depth in the next chapter. For now though, you can use this widget just like you’d use a Column. You only need to pass it some children, which are all widgets.
A ListTile is a widget that has two special characteristics: they’re fixed-height, making them ideal for menus, and they’re opinionated. Unlike other generalized widgets, which expect an argument called child, the ListTile has properties like title, subtitle, and leading (figure 7.5). It also comes equipped with an onTap property.
There are some other special Flutter widgets that are used specifically to enhance a ListView into a more “material-esque” menu drawer. Before looking at those directly, look at the structure of the menu in the code. (This code has some details removed as it’s meant to be a high-level overview of the Drawer’s widgets.)
@override Widget build(BuildContext context) { _activeRoute ??= "/"; return Drawer( child: ListView( children: <Widget>[ StreamBuilder( 1 // ... builder: ( 2 BuildContext context, AsyncSnapshot<ECommerceUser> ) => UserAccountsDrawerHeader(), 3 ), // StreamBuilder ListTile( 4 leading: Icon(Icons.apps), title: Text("Catalog"), selected: _activeRoute == ECommerceRoutes.catalogPage, onTap: () => _navigate(ECommerceRoutes.catalogPage), ), ListTile(...), 5 ListTile(...), AboutListTile(...), 6 ], ), ); }
First, I’d like to discuss these various widgets within the drawer from a high level, starting with the two special widgets, as their use cases are specific. Then, I’ll move into ListTile, which is more generic. In a couple pages, we’ll look at the nitty-gritty code details.
The UserAccountsDrawerHeader is a Material widget that’s used to display crucial user information. Imagine a Google app like GMail, which lets you switch between user accounts with the tap of the button. That GMail-like UI is built into Flutter, using the UserAccountsDrawerHeader. We don’t need this for this app. There’s no lesson here. It’s just a great example of how much Flutter gives you for free.
Finally, the AboutListTile. This widget can be passed into the ListView.children list, and configured in just a few lines of code, as the following listing shows.
// e_commerce/lib/menu/app_menu_drawer.dart -- line ~81 AboutListTile( icon: Icon(Icons.info), applicationName: "Produce Store", aboutBoxChildren: <Widget>[ Text("Thanks for reading Flutter in Action!"), ], ),
With that small amount of Flutter code, you get a fully functional menu button that displays a modal on tap, complete with a button to close the modal. Figure 7.6 shows what it looks like.
With all that in mind, I think this brings our requirements list to this:
This is slightly over-exaggerated. Of course, you have to actually write those five lines of code for the AboutListTile. But that’s a heck of a lot easier than writing the logic to show a modal and then writing the layout for the modal itself.
With all this widget knowledge in mind, most of the work is done for you. The bulk of implementing this menu is in the routing. This works well, because this is a chapter about routing. Most of this work is done in the lib/menu/app_menu_drawer.dart file.
For starters, menu items, when tapped, should route to a new page. In the build method, there is a ListTile for each of these menu items. One ListTile looks like the next listing.
// e_commerce/lib/menu/app_menu_drawer.dart -- line ~63 ListTile( leading: Icon(Icons.apps), title: Text("Catalog"), selected: _activeRoute == ECommerceRoutes.catalogPage, 1 onTap: () => _navigate(ECommerceRoutes.catalogPage), 2 ),
Tapping that list item calls AppMenu._navigate, which looks like the following code.
void _navigate(String route) { Navigator.popAndPushNamed(context, route); 1 }
You can see another example of adding a page to the stack in the UserAccountsDrawerHeader widget in this same build method.
UserAccountsDrawerHeader( currentAccountPicture: CircleAvatar( 1 backgroundImage: AssetImage("assets/images/apple-in-hand.jpg"), ), accountEmail: Text(s.data.contact), 2 accountName: Text(s.data.name), 3 onDetailsPressed: () { Navigator.pushReplacementNamed( 4 context, ECommerceRoutes.userSettingsPage); }, ),
Another interesting aspect of the Flutter router is observers. You won’t get far into Dart programming without using observers and streams and emitting events. There’s an entire chapter devoted to that later in this book, but for now, I’m going to focus specifically on a NavigatorObserver. A navigator observer (figure 7.7) is an object that tells any widget that’s listening, “Hey, the Navigator is performing some event, if you’re interested.” That’s really its only job—but it’s an important one.
A subclass of NavigatorObserver, and the one we’re interested in here, is RouteObserver. This observer specifically notifies all its listeners if the current active route of specific type changes (for instance, PageRoute).
This RouteObserver is how you’re going to keep track of which page is active, and use it to highlight the correct menu item in the menu. For many cases, you’ll only need one RouteObserver per Navigator. Which means there’ll likely be only one in your app. In this app, the route observer is built in the app file.
final RouteObserver<Route> routeObserver = RouteObserver<Route>(); 1 class ECommerceApp extends StatefulWidget { @override _ECommerceAppState createState() => _ECommerceAppState(); } // ... rest of file
I created the observer in the global scope, not protected by the safety of any class, because I needed it to be visible through out the whole app. Also, if it was added to a class, it would be the singular member on that class, which is unnecessary. This is common for RouteObserver objects. After this, you need to tell your MaterialApp about it, in the same file, as shown in this listing.
// e_commerce/lib/app.dart -- line ~51 return MaterialApp( debugShowCheckedModeBanner: false, theme: _theme, home: PageContainer(pageType: PageType.Catalog,), routes: { ... } navigatorObservers: [routeObserver], 1 );
That’s all the set up; now you can listen to that observer on any State object. It has to be stateful though, because you’ll need to use some state lifecycle method. For our purposes, this is going to happen back in the AppMenu widget you’ve been working with in this section. First, the state object needs to be extended with the mixin RouteAware, as shown here:
// e-commerce/lib/menu/app_menu_drawer.dart -- line ~14 class AppMenu extends StatefulWidget { @override AppMenuState createState() => AppMenuState(); } class AppMenuState extends State<AppMenu> with RouteAware { ... } 1
This RouteAware mixin is an abstract class that provides an interface to interact with a route observer. Now, your state object has access to the methods didPop, didPush, and a few others.
In order to update the menu with the correct active route, we need to be notified when there’s a new page on the stack. There are two steps to that: first, listen to the changes from the route observer, and second, listen to the observer to be notified when the route changes.
// e_commerce/lib/menu/app_menu_drawer.dart -- line ~19 class AppMenuState extends State<AppMenu> with RouteAware { String _activeRoute; 1 UserBloc _bloc; @override void didChangeDependencies() { 2 super.didChangeDependencies(); routeObserver.subscribe( 3 this, ModalRoute.of(context), ); _bloc = AppStateContainer.of(context) .blocProvider.userBloc; 4 } // ... rest of class }
A ModalRoute is a route that covers the screen, even if it isn’t opaque on the whole screen. In other words, any route that disables interaction with any route underneath it. Examples would be pages, popups, and drawers.
Now that this widget is aware of route changes, it needs to update its active route variable when any Navigator activity happens. This is done in the didPush method that it inherited from RouteAware:
// e_commerce/lib/menu/app_menu_drawer.dart -- line ~30 @override void didPush() { 1 _activeRoute = ModalRoute.of(context).settings.name; 2 }
Using the navigation observer and route aware is, in my opinion, the most complicated topic in this chapter. This list of resources might help if you want to read through more examples:
Routing on the fly is the idea that you can route to a page that doesn’t exist until it’s generated in response to an event. For example, you may want to navigate to a new page when the user taps a list item. You don’t have to establish this route ahead of time, because routes are just widgets. Here’s some example code.
void _showListItemDetailPage() async { await Navigator.push( 1 context, MaterialPageRoute( 2 builder: (context) => SettingsPage( settings: settings, ), fullscreenDialog: true, 3 ), ); }
In Flutter, everything that seems like a new widget on the route stack is a route. Modals, bottom sheets, snack bars, and dialogs are all routes, and these are perfect candidates for routing on the fly.
The first important place that we route on the fly in this app is when we tap a product in the catalog page and navigate to a product detail page.
On this page, there are a lot of widgets and concepts not yet covered in this book, like StreamBuilder and Slivers and all kinds of goodies. We will cover those later in the book.
Around line 93 in the lib/widget/catalog.dart file, I build the ProductDetailCard (figure 7.8), which is listening for a tap to navigate to another page.
The code for the product detail page looks like this:
// e_commerce/lib/widget/catalog.dart -- line ~93 return ProductDetailCard( key: ValueKey(_product.imageTitle.toString()), onTap: () => _toProductDetailPage(_product), 1 onLongPress: () => _showQuickAddToCart(context, _product), product: _product, );
When that item is tapped, the next listing shows the method that will execute.
// e_commerce/lib/widget/catalog.dart -- line ~37 Future _toProductDetailPage(Product product) async { await Navigator.push( 1 context, MaterialPageRoute( 2 builder: (context) => ProductDetailPageContainer( 3 product: product, ), ), ); }
That’s it for navigating to new pages that aren’t established. Routes that don’t cover the whole screen are similar. This includes modals, dialogs, bottom sheets, and more.[2]
For a super simple second example of routing without named routes, check out this page in the official documentation: https://flutter.dev/docs/cookbook/navigation/navigation-basics.
Flutter has widgets and logic to make it super easy to use routes that aren’t pages, like modals and snackbars. These are technically routes, but under the hood, they just don’t render like a whole page would. They’re still widgets that get added on the stack of the Navigator, rather than being attached to a page (figure 7.9).
In this app, we make use of the bottom sheet (which is common in iOS apps) and a snackbar. These are similar in the fact that they appear from the bottom of the screen and only cover a portion on the screen. They’re different, though, because a bottom sheet is a ModalRoute. Meaning that when it’s in view, you cannot interact with the page beneath it. A snack bar doesn’t obstruct the app, so you can still interact while it’s in view.
The bottom sheet is implemented in the same Catalog widget, and kicked off by pressing and holding a ProductDetailCard via the ProductDetailCard.onLongPress method:
// e_commerce/lib/widget/catalog.dart -- line ~93 return ProductDetailCard( key: ValueKey(_product.imageTitle.toString()), onTap: () => _toProductDetailPage(_product), onLongPress: () => _showQuickAddToCart(_product), 1 product: _product, );
This method, _showQuickAddToCart, is in the business of showing the BottomSheet (figure 7.10), waiting for the user to interact with it, and listening to the data that the bottom sheet passes back.
The _showQuickAddToCart method looks like this:
// e_commerce/lib/widget/catalog.dart -- line ~48 void _showQuickAddToCart(BuildContext context, Product product) async { CartBloc _cartBloc = AppState.of(context).blocProvider.cartBloc; int qty = await showModalBottomSheet<int>( 1 context: context, 2 builder: (BuildContext context) { 3 return AddToCartBottomSheet( key: Key(product.id), ); }); _addToCart(product, qty, _cartBloc); }
To complete this implementation, we need to look at the AddToCartBottomSheet code. Within that widget, there’s a RaisedButton, which corresponds to this button on the screen (figure 7.11).
This AddToCartBottomSheet method is nearly 100 lines of code, but much of it is basic Flutter widget building. I would like to point out some pieces to complete this specific lesson. (Hint: the most important part for this lesson is the RaisedButton near the bottom of the code sample.)
// e_commerce/lib/widget/add_to_cart_bottom_sheet.dart class AddToCartBottomSheet extends StatefulWidget { 1 const AddToCartBottomSheet({Key key}) : super(key: key); @override _AddToCartBottomSheetState createState() => _AddToCartBottomSheetState(); } class _AddToCartBottomSheetState extends State<AddToCartBottomSheet> { int _quantity = 0; // ... @override Widget build(BuildContext context) { return ConstrainedBox( 2 constraints: BoxConstraints( minWidth: MediaQuery.of(context).size.width, minHeight: MediaQuery.of(context).size.height / 2, ), child: Column( children: <Widget>[ Padding( // ... child: Text("Add item to Cart"), ), Padding( // ... child: Row( 3 children: <Widget>[ IconButton(...) // decrease qty button Text(...) // current quanity IconButton(...) // increase qty button ], ), ), RaisedButton( 4 color: AppColors.primary[500], textColor: Colors.white, child: Text( "Add To Cart".toUpperCase(), ), onPressed: () => Navigator.of(context).pop(_quantity), 5 ) ], ), ); }
This is a pretty run-of-the-mill button. The part we’re interested in is its onPressed callback. When the button is pressed, it pops the top route off the Navigator (which is the bottom sheet itself), and then passes that variable (_quantity), back to code that added this route onto the stack. Recall that this route was added to the stack by the Catalog._showQuickAddToCart method. Let’s revisit that code as shown in the next listing.
// e_commerce/lib/widget/catalog.dart -- line ~48 void _showQuickAddToCart(BuildContext context, Product product) async { CartBloc _cartBloc = AppState.of(context).blocProvider.cartBloc; int qty = await showModalBottomSheet<int>( 1 context: context, builder: (BuildContext context) { return AddToCartBottomSheet( 2 key: Key(product.id), ); }); _addToCart(product, qty, _cartBloc); 3 }
The point of this whole section is that bottom sheets and the like behave exactly like routes. And when you pop these routes off the Navigator, you can pass data back to the previous screen, as we’re doing with the qty variable.
Other pop-up style widgets work just as easily. For example, to show a snackbar, you can call showSnackbar, which lives on a scaffold: Scaffold.of(context).showSnackBar (Widget content);.
The final piece of the routing puzzle is making it pretty, which is my favorite part about writing apps. Believe it or not, though, there’s almost no work involved in adding custom route transitions. The real work, actually, is in writing the animation, if you want to do it super fancy. But as you saw in the previous app, Flutter gives you quite a few animations out of the box.
Before we dive in, consider how page transitions work in Flutter, by default. The following facts are important:
With that in mind, lets dive in. Transitions are handled by PageRoute, or in our case, MaterialPageRoute, which extends PageRoute, which extends ModalRoute, which extends TransitionRoute. Somewhere in this mess, there’s a method called buildTransitions, which, among other things, takes two animations as arguments. One is for itself as it exits, and the second to coordinate with the route that’s replacing it. MaterialPageRoute already implements transitions, which means you can override MaterialPageRoute.buildTransitions.
All this method has to do is return a widget. And by overriding it, you don’t have to worry about writing the AnimationController or Tween—that’s all taken care of in the superclass. Which means you can simply return a page that’s wrapped in an animated widget, and it will animate accordingly. Take a look at the code to make that less abstract. This can be found in lib/util/material_route_transition.dart.
// e_commerce/lib/util/material_route_transition.dart class FadeInSlideOutRoute<T> extends MaterialPageRoute<T> { 1 FadeInSlideOutRoute({WidgetBuilder builder, RouteSettings settings}) : super(builder: builder, settings: settings); @override Widget buildTransitions( BuildContext context, Animation<double> animation, 2 Animation<double> secondaryAnimation, Widget child, ) { if (settings.isInitialRoute) return child; if (animation.status == AnimationStatus.reverse) { return super.buildTransitions( context, animation, secondaryAnimation, child, ); } return FadeTransition( opacity: animation, child: child, ); 3 } }
There’s a bit of abstraction in that example, to be sure. But the most important line, by far, is the return statement. This buildTransitions method overrides the same method on the superclass, MaterialPageRoute, and does all the hard work of calculating how to animate between pages and implementing those transitions.
FadeTransition tells buildTransitions specifically to build a transition that fades the new page in. And buildTransitions is called internally. We never explicitly call it in the code. If you wanted to create something highly customized, you would need to call buildTransitions on your own, and you would likely be using WidgetsApp rather than a MaterialApp at the root of your application.
With all that in mind, FadeTransition is what we, the developers, really care about. You could also use one of the many other transitions that Flutter’s material library provides:
These are all provided by the framework, and implementing them is just as straight forward.
To use this, all you need to do is go to Catalog._toProductDetailPage and replace MaterialPageRoute with FadeInSlideOutRoute. Now when you tap a product detail card, the routes will fade. I guess it’s worth saying that you could get as fancy and wild with this as you wanted. You’re limited only by the animation you write. Finally, if you’d like to read another explanation, this article written by Divyanshu Bhargava on Medium is quite good: https://medium.com/flutter-community/everything-you-need-to-know-about-flutter-page-route-transition-9ef5c1b32823.
3.16.66.206