Spotless Routing and Navigation in Flutter

As soon as you’ve finished studying the basics of Flutter and start working on your first projects, you’ll find yourself asking:

“What’s the best way I can handle routing and navigation?”

“Should I use named routes? Should I use a package?”

No, I’m not here to say “it depends on your needs.”

I work at a software service company, and as such, we develop many apps a year. That puts a demand on finding solutions that fit the largest number of different projects.

If you’ve done native mobile development, you know that routing always becomes a serious matter. Whenever I’m around iOSers, it’s five minutes before we’re discussing routers, coordinators, flows, and their pros and cons. With Androiders, it’s exactly the same — just replace the terms with Jetpack Navigation, Cicerone or FragNav. It seems like no one is ever completely satisfied with it.

If Flutter is your first foray into the mobile world, remember these words: This won’t be the last time you talk about routing strategies.

And before you get hopeless about it, just give me a chance to show you how I’ve been doing it.

I’m completely satisfied and I think you will be too.

Have you ever taken time to think about how navigation is typically made on the web?

I’m not talking about how it’s developed or anything like that — I’m interested in your experience as a website user or RESTful API consumer.

URLs are typically designed around resources, with a resource being a collection or a single item. For example:

Want to access a list of movies? Go to ${yourFavoriteMovieWebsite}/movies .

Want to see more details about a specific movie you found on the list? Go to ${yourFavoriteMovieWebsite}/movies/:picked_movie_id .

Did you notice the parametrized ID? That’s a path param or URL param.

What about seeing only the movies released in 2020? No problems, just pass in a query param called releaseYear to the first endpoint:

${yourFavoriteMovieWebsite}/movies?releaseYear=2020

By the way, that part of the URL containing the parameter, starting with the question mark, is what we call a query string.

Further reading: REST Resource Naming

What if We Could Do the Same With Our Apps?

“Why would I want such a thing?”

Apart from it being amazingly intuitive and organized, we’re just one step behind being ready for deep links.

“Deep links?”

Have you ever opened a link on your phone that, instead of opening a web page, opens a specific page of an app?

Another good example is when you receive a push notification from a messenger app and tapping on it, it takes you to that specific conversation screen.

That’s what deep linking is all about. That’s also all we’re saying about it for today — it will have an article of its own soon.

My point is this: If we’re going to think of URL paths to our pages sooner or later, why not do it from the beginning? Even if you don’t have any intention of supporting deep links in your project, this is still the most well-established way of organizing routes.

To showcase today’s article, I decided we needed to work on a real app. To make it possible, I spent some time looking for free HTTP APIs, that’s when I found the great Breaking Bad API.

It provides us with a collection of information on the Breaking Bad and Better Call Saul TV Shows. Our app, though, is restricted to Breaking Bad characters and its remarkable quotes.

That settled, I found myself with the most challenging task of it all. Deciding what the app’s name would be!

The Breaking Bapp Was Born

Don’t worry about my marketer skills — I don’t intend to follow that career! Also, I promise you I’ll do my best to keep the “breaking” part only in the name.

Breaking Bapp walkthrough

Thinking in Resources

We already stated that we should design our paths around the resources we’re providing. Can you imagine what the URLs would look like if we were talking about a website? I did that exercise:

List of characters: characters

Details of a single character: characters/:character_id

List of quotes: quotes

Details of a quote author: quotes/authors?name=:quote_author_name

The last one is the trickiest. Maybe you’re wondering why name is a query param instead of a path param, or why we aren’t using the quote’s author ID instead of their name.

To answer that, let me first show you a quote’s JSON object retrieved from the API:

{ "quote_id" : 1 , "quote" : "I am not in danger, Skyler. I am the danger!" , "author" : "Walter White" }

Notice that we don’t have the ID of the author, just its name. Since name isn’t a reliable unique identifier and path params are recommended for unique identifiers, the best thing we can do is thinking as if we’re querying the author’s resource.

And, we have the plus that by designing it like this, I can teach you how to work with query strings as we go.

It’s important to mention, for those of you who are not Breaking Bad fans, that a quote author is nothing but a character of the show. So, to obtain this info, we just need to use the character details endpoint — fortunately, the API allows us to find a character by its name instead of its ID.

How Does Navigation Work in Flutter?

Navigating is nothing but the simple act of transitioning to a new full-screen widget, which we call a page or a screen. To do so, we need to wrap this new widget inside a Route object, that describes how the transition should happen, and push it to a Navigator . There are two ways to do this, by default.

First method

Give the Navigator a Route object.

Bringing it to the Breaking Bapp, this is how it would look like for the onTap callback of the character’s list.

final destinationRoute = MaterialPageRoute( builder: (context) => CharacterDetailPage( id: character.id, ), ); Navigator.push( context, destinationRoute, );

Extremely easy to learn;

Strongly typed;

Leads to lots of code duplication if you need to navigate to the same screen in many parts of your app;

Second method, a.k.a. named routes

Give the Navigator a route name and a generic argument (optional), which will be delivered to a function that maps these two to a Route .

You need to write that function by yourself and use it as the onGenerateRoute of the MaterialApp .

// This is how we push a new page using named routes. It could be called // when the user selects an item from the character list. void pushNewPage(BuildContext context, CharacterSummary character) { Navigator.pushNamed( context, 'character-details', // Since we use the same Navigator.pushNamed // function to every page we wanna push, the below `arguments` // parameter type is `Object`, and there's nothing // forbidding me to give it a `boolean` where we would // actually be expecting an int, i.e., the character's id. arguments: character.id, ); } // This is the definition of the MyApp Widget, which can be found // at the main.dart file. Notice the MaterialApp's onGenerateRoute constructor // parameter. class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( title: 'Named Routes', theme: ThemeData( primarySwatch: Colors.green, ), home: HomeScreen(), // It's a function that receives a RouteSettings object and returns // a concrete Route. The RouteSettings contains information about the // route we're intending to navigate to, such as its name and any // passed arguments. If we return nothing here, it will call the // onUnknownRoute function, which we're not implementing here, but // could be used for showing a default page, for example. onGenerateRoute: (routeSettings) { if (routeSettings.name == 'character-details') { final int characterId = routeSettings.arguments; return MaterialPageRoute( builder: (context) => CharacterDetailPage( id: characterId, ), ); } return null; }, ); }

Not as easy to learn as the first method.

Although it looks more verbose, it is actually the recommended approach, because we keep our page’s instantiation centralized, avoiding code duplication.

If we take a closer look, the named route approach is relatively close to the web-thing we’re trying to accomplish. The only difference is that on the web, we wouldn’t need this argument object (the third param of the Navigator.pushNamed ), as we would be able to pass every parameter we need inside the route name, either as path param or as a query param.

We want to do this: Navigator.of(context).pushNamed('characters/${character.id}');

Instead of this: Navigator.of(context).pushNamed('character-details', arguments: character.id);

However, if you try to do this right away, it will give you a headache. Inside your onGenerateRoute function, you would need a way to wildcard the character’s ID, so that characters/11 and characters/22 would take to the same route, just with different arguments. Another problem is having to strip and parse whatever comes after the question mark as query parameters.

Fluro Package to the Rescue

According to them (and I’m inclined to agree), Fluro is:

The brightest, hippest, coolest router for Flutter.

Among several features, I would highlight:

Wildcard parameter matching Querystring parameter parsing

We found our guy!

For our starting point, I built a version of the Breaking Bapp using the first routing method (without named routes), from which we’ll evolve to an elegant solution using Fluro.

Don’t worry about how I made the HTTP calls or what is the state management approach. I’ve managed to keep things as simple as possible so that it won’t take the focus of the navigation and routing.

To cover a scenario with multiple Navigator s, we have a bottom navigation menu structure.

I suggest you check out the starting-point branch of the Breaking Bapp’s GitHub repository and get relatively comfortable with it for a better learning experience.

Step-by-step on Migrating to Fluro

1. Install Fluro

The article is based on version 1.6.3.

Please, don’t use a newer version until you’re finished with all the steps. I can’t state how important that is.

https://pub.dev/packages/fluro#-installing-tab-

2. Set up your routes

The main Fluro’s class is called Router . We’ll use it for everything we need to do, except for pushing pages, as we’ll continue to use the Navigator for that. You don’t need to instantiate it by yourself, as Fluro already made an instance available to us as a static variable, simply accessed by Router.appRouter .

First things first, we’ll start using a Router ’s function called define , which serves the purpose of, guess what, defining a route. It takes in three arguments:

A String representing the route’s path along with its expected path parameters. The query ones don’t need to be specified ahead. A Handler object which holds the page’s widget builder. An optional TransitionType , just in case you want to change the default route transition.

I did all my routing set up inside the main function ( main.dart ), and I suggest you do the same.

Read the Gist below carefully. I made an effort to comment everything you need to know.

void main() { Router.appRouter // The '..' syntax is a Dart feature called cascade notation. // Further reading: https://dart.dev/guides/language/language-tour#cascade-notation- ..define( // The '/' route name is the one the MaterialApp defaults to as our initial one. '/', // Handler is a custom Fluro's class, in which you define the route's // widget builder as the Handler.handlerFunc. handler: Handler( handlerFunc: (context, params) => HomeScreen(), ), ) ..define( 'characters', handler: Handler( handlerFunc: (context, params) => CharacterListPage(), ), ) ..define( // The ':id' syntax is how we tell Fluro to parse whatever comes in // that location and give it a name of 'id'. This is called a Path Param // or URI Param. 'characters/:id', transitionType: TransitionType.native, handler: Handler( handlerFunc: (context, params) { // The 'params' is a dictionary where the key is the name we gave to // the parameter ('id' in this case), and the value is an array with // all the arguments that were provided (just a single `int` in this // case). Fluro gives us an array as the value instead of a single // item because when we're working with query string parameters, // we're able to pass an array as the argument, such as // '?name=Jesse,Walter,Gus'. final id = int.parse(params['id'][0]); return CharacterDetailPage( id: id, ); }, ), ) ..define( 'quotes', handler: Handler( handlerFunc: (context, params) => QuoteListPage(), ), ) ..define( // This route will accept a Query Param, but notice that, unlike we did // with Path Params, we don't need to pre-define our expected Query // Params in the path String. 'quotes/authors', // You can customize the transition type for every route. transitionType: TransitionType.nativeModal, handler: Handler( handlerFunc: (context, params) { // We extract an expected Query Param just as we did with // the 'id' in the third route definition. The only difference being // that with Query Params we didn't need to specify it in the // 'quotes/authors' route path. final name = params['name'][0]; return CharacterDetailPage( name: name, ); }, ), ); runApp( MyApp(), ); }

3. Link your router to your navigators

Now we have a special little guy called Router.appRouter that knows how to handle all of our web-like named routes. But we haven’t plugged it anywhere. Because of that, when we Navigator.pushNamed something, it still won’t use all the setup we just did in the previous step.

Do you remember the MaterialApp.onGenerateRoute constructor parameter? We’ve talked about it briefly when discussing named routes. We need to provide it with a function that receives the pushed route settings and uses it to build a concrete Route . Guess who has one of these? Yes, the Router.appRouter .

class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( title: 'Routing, navigation and deep linking sample', theme: ThemeData( primarySwatch: Colors.green, ), onGenerateRoute: (settings) => Router.appRouter .matchRoute(context, settings.name, routeSettings: settings) .route, ); }

Now, every time we navigate using our root Navigator , everything works just fine.

“What do you mean by root Navigator?”

By default, inside MaterialApp , we’re given a Navigator to push and pop our pages widgets, and this is the one we’ve just linked with our Router . But if your navigation structure is a little more complicated than that, for example if you use a bottom navigation component, you probably have more than one Navigator . We need to link those to Fluro as well. Fortunately that’s the Breaking Bapp’s scenario!

“How can I make Fluro work with multiple Navigator s?”

Short answer: The Navigator ’s constructor also has an onGenerateRoute . Just give it the same [ Router.appRouter.matchRoute(context, settings.name, routeSettings: settings).route ] everywhere you instantiate a new Navigator in your code, use the initialRoute parameter for changing its initial route, and skip to our fourth step.

Long answer: For this, I’m going to show you how I did it in the Breaking Bapp’s bottom navigation.

If you haven’t followed my latest article, it won’t be easy for you to understand what’s going on. If that’s the case, I strongly suggest you be satisfied with the short answer and skip to the fourth step. I promise you there will be no losses.

3.1. Linking your router to your additional navigators

Our code has a class called BottomNavigationTab that holds all the dependencies for building our app’s bottom navigation flows. That includes the item to be displayed in the menu, the GlobalKey it should use for that specific tab’s Navigator , and the initial page builder for each. The initialPageBuilder was useful once when we were instantiating the routes ourselves (by using the first navigation method). But now that we’re using named routes, we don’t like page builders anymore, just route names, as Fluro is the one building things now.

Here’s the diff:

class BottomNavigationTab { const BottomNavigationTab({ @required this.bottomNavigationBarItem, @required this.navigatorKey, - @required this.initialPageBuilder, + @required this.initialRouteName, }) : assert(bottomNavigationBarItem != null), assert(navigatorKey != null), - assert(initialPageBuilder != null); + assert(initialRouteName != null); final BottomNavigationBarItem bottomNavigationBarItem; final GlobalKey<NavigatorState> navigatorKey; - final WidgetBuilder initialPageBuilder; + final String initialRouteName; }

As we just changed the BottomNavigationTab ’s definition, we need to fix the place we’re instantiating it:

class HomeScreen extends StatefulWidget { @override _HomeScreenState createState() => _HomeScreenState(); } class _HomeScreenState extends State<HomeScreen> { final List<BottomNavigationTab> _bottomNavigationTabs = [ BottomNavigationTab( bottomNavigationBarItem: BottomNavigationBarItem( title: const Text('Characters'), icon: Icon(Icons.people), ), navigatorKey: GlobalKey<NavigatorState>(), - initialPageBuilder: (_) => CharacterListPage(), + initialRouteName: 'characters', ), BottomNavigationTab( bottomNavigationBarItem: BottomNavigationBarItem( title: const Text('Quotes'), icon: Icon(Icons.format_quote), ), navigatorKey: GlobalKey<NavigatorState>(), - initialPageBuilder: (_) => QuoteListPage(), + initialRouteName: 'quotes', ) ]; @override Widget build(BuildContext context) => AdaptiveBottomNavigationScaffold( navigationBarItems: _bottomNavigationTabs, ); }

There are two more places left, both inside the material_bottom_navigation_scaffold.dart , as that’s where we’re subclassing and instantiating the above BottomNavigationTab .

class _MaterialBottomNavigationTab extends BottomNavigationTab { const _MaterialBottomNavigationTab({ @required BottomNavigationBarItem bottomNavigationBarItem, @required GlobalKey<NavigatorState> navigatorKey, - @required WidgetBuilder initialPageBuilder, + @required String initialRouteName, @required this.subtreeKey, }) : assert(bottomNavigationBarItem != null), assert(subtreeKey != null), - assert(initialPageBuilder != null), + assert(initialRouteName != null), assert(navigatorKey != null), super( bottomNavigationBarItem: bottomNavigationBarItem, navigatorKey: navigatorKey, - initialPageBuilder: initialPageBuilder, + initialRouteName: initialRouteName, ); final GlobalKey subtreeKey; }

void _initMaterialNavigationBarItems() { materialNavigationBarItems.addAll( widget.navigationBarItems .map( (barItem) => _MaterialBottomNavigationTab( bottomNavigationBarItem: barItem.bottomNavigationBarItem, navigatorKey: barItem.navigatorKey, subtreeKey: GlobalKey(), - initialPageBuilder: barItem.initialPageBuilder, + initialRouteName: barItem.initialRouteName, ), ) .toList(), ); }

Finally, it’s time to ensure that our additional navigators delegates its route generation to Fluro, as well as using the newest initialRouteName of the BottomNavigationTab . I’m using different bottom navigation menus for Android and iOS, so I needed to make that change in two files:

material_bottom_navigation_scaffold.dart

Widget _buildPageFlow( BuildContext context, int tabIndex, _MaterialBottomNavigationTab item, ) { final isCurrentlySelected = tabIndex == widget.selectedIndex; // We should build the tab content only if it was already built or // if it is currently selected. _shouldBuildTab[tabIndex] = isCurrentlySelected || _shouldBuildTab[tabIndex]; final Widget view = FadeTransition( opacity: _animationControllers[tabIndex].drive( CurveTween(curve: Curves.fastOutSlowIn), ), child: KeyedSubtree( key: item.subtreeKey, child: _shouldBuildTab[tabIndex] ? Navigator( // The key enables us to access the Navigator's state inside the // onWillPop callback and for emptying its stack when a tab is // re-selected. That is why a GlobalKey is needed instead of // a simpler ValueKey. key: item.navigatorKey, - // Since this isn't the purpose of this sample, we're not using - // named routes. Because of that, the onGenerateRoute callback - // will be called only for the initial route. - onGenerateRoute: (settings) => MaterialPageRoute( - settings: settings, - builder: item.initialPageBuilder, - ), + initialRoute: item.initialRouteName, + // RouteFactory is nothing but an alias of a function that takes + // in a RouteSettings and returns a Route<dynamic>, which is + // the type of the onGenerateRoute parameter. + // We registered one of these in our main.dart file. + onGenerateRoute: Router.appRouter + .matchRoute(context, settings.name, routeSettings: settings) + .route, ) : Container(), ), ); if (tabIndex == widget.selectedIndex) { _animationControllers[tabIndex].forward(); return view; } else { _animationControllers[tabIndex].reverse(); if (_animationControllers[tabIndex].isAnimating) { return IgnorePointer(child: view); } return Offstage(child: view); } }

cupertino_bottom_navigation_scaffold.dart

Widget build(BuildContext context) => CupertinoTabScaffold( // As we're managing the selected index outside, there's no need // to make this Widget stateful. We just need pass the selectedIndex to // the controller every time the widget is rebuilt. controller: CupertinoTabController(initialIndex: selectedIndex), tabBar: CupertinoTabBar( items: navigationBarItems .map( (item) => item.bottomNavigationBarItem, ) .toList(), onTap: onItemSelected, ), tabBuilder: (context, index) { final barItem = navigationBarItems[index]; return CupertinoTabView( navigatorKey: barItem.navigatorKey, - onGenerateRoute: (settings) => CupertinoPageRoute( - settings: settings, - builder: barItem.initialPageBuilder, - ), + onGenerateRoute: (settings) { + // The [Navigator] widget has a initialRoute parameter, which + // enables us to define which route it should push as the initial + // one. See [MaterialBottomNavigationScaffold] for more details. + // + // The problem is that in the Cupertino version, we're not + // instantiating the [Navigator] ourselves, instead we're + // delegating it to the CupertinoTabView, which doesn't provides + // us with a way to set the initialRoute name. The best + // alternative I could find is to "change" the route's name of + // our RouteSettings to our BottomNavigationTab's initialRouteName + // when the onGenerateRoute is being executed for the initial + // route. + var routeSettings = settings; + if (settings.name == '/') { + routeSettings = + settings.copyWith(name: barItem.initialRouteName); + ); + } + return Router.appRouter + .matchRoute( + context, + routeSettings.name, + routeSettings: routeSettings, + ) + .route; + }, ); }, );

4. Navigate

Everything we’ve done by now was about being prepared to handle any incoming web-like named routes, but we’re still not using them. We need to stop using Navigator.push and start using Navigator.pushNamed . There are two places for changing that:

When selecting a character on the list.

character_list_page.dart

- Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CharacterDetailPage( - id: character.id, - ), - ), + Navigator.of(context).pushNamed( + 'characters/${character.id}', );

When selecting a quote’s author on the list.

quote_list_page.dart

Navigator.of( context, rootNavigator: true, - ).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (context) => CharacterDetailPage( - name: quote.authorName, - ), - ), + ).pushNamed( + 'quotes/authors?name=${quote.authorName}', );

We’re all done!

Side note

I once read someone arguing that with Fluro, we’re not able to pass complex objects directly from one page to another. Although we can find obscure ways to do that, I don’t advise on doing so.

I firmly believe that we shouldn’t be passing complex objects around. An alternative I prefer is giving the object an id and storing it in your data layer. That way, we can pass only its id between pages.

Bonus

You may have noticed that the route names are duplicated across the code. This isn’t a recommended practice at all. It served the purpose of improving the article’s readability, but I don’t want you to do that anywhere else. For inspiration on how to extract it, check out the master branch.

Like What You Read? If you want more in-depth articles like this, leave your e-mail below and stay up to date with what I write.