DEV Community

loading...

Learn Navigator 2.0 by Building a Simpler API !

Arnel Enero
Updated on ・8 min read

If you are reading this you probably have an opinion about Flutter's Navigator 2.0 being either:

  • Too complicated, making it difficult to comprehend
  • Too verbose, requiring a lot of boilerplate to do the job

But despite this, it offers quite a number of features that were not possible with the simpler 1.0.

Well, there's probably no better way to finally decipher Navigator 2.0 than to write our own simple wrapper API on top of the beast. And we'll be solving 2 problems in one go!

Don't worry, we will try to make sense of the whole thing, piece by piece, starting from the general concept down to the nitty gritty, and will be presented in the simplest way possible.

So if you have some time, join me in this exciting challenge. First up, let's give our library a name: ➡️ Flipbook ⬅️.

But hasn't the Flutter team already written a lengthy article explaining 2.0? Yes, but I promise you we can do simpler than that approach. 🤓

Note on 1.0: If you have been writing Flutter apps for some time now, most likely you've already used the original Navigator 1.0. There are tons of materials on 1.0 online, so I won't waste your time on reviewing it here.

What Navigator 2.0 is for

Let's forget about the whole thing about 2.0 being declarative vs. imperative first. If it didn't make it any simpler (many say it got much more complicated) then there is no point in talking too much about this aspect.

Nav 2.0 works in a reactive kind of way, much like how Flutter widgets work. So let's focus on 3 major things that 2.0 tries to achieve here:

  • Have the navigation react to the app state.
  • Have the URL update with the app state.
  • Have the app state react to URL changes.

As you can see above, 2.0 syncs up the navigation with the app state. And with Web now a first-class citizen in Flutter world, there is increased demand to sync the URL with the app, too.

We'll dive into each one of those 3 areas as we look at how Nav 2.0 achieves those goals, and as we build out our simple API.

What our API should look like

Let's ideate a little bit on how we want our Flipbook API to look from developer experience perspective.

A drop-in replacement for MaterialApp could be a good entry point because it immediately feels familiar. So here's a look at an example build method of a top-level App widget:

  @override
  Widget build(BuildContext context) {
    return Flipbook(
      title: config.appName,
      routes: {
        '/login': (context) => LoginScreen(),
        '*': (context) => Shell(),
      },
    ),
  }
Enter fullscreen mode Exit fullscreen mode

Flipbook plays the role of a router. The routes are matched by path, and are enumerated by top-to-bottom priority, with the last one typically being a wildcard to indicate "default" fallback.

To switch screens, we just need to update the navigator state as follows:

  context.route.setState('/login');
Enter fullscreen mode Exit fullscreen mode

Although this looks similar to the pushNamed() approach from 1.0, here we no longer need to worry about calling push, pop or replace. We just set the nav state to the correct path, and Flipbook should do the rest.

Now that we have laid out the basic concept for our API, let's start building...

Linking navigation to app state

The main enabler for this functionality is the router delegate, which is the mediator of all the pieces of our router (the Flipbook widget). The RouterDelegate class is the core of what Navigator 2.0 is all about, so let's carefully dissect this down to the essential pieces.

We'll start by creating our subclass of the router delegate:

class FlippinRouterDelegate extends RouterDelegate<RoutePath>
    with ChangeNotifier { 

  final RouteMap routes;
  final RouteState routeState = RouteState.instance;
  final GlobalKey<NavigatorState> navigatorKey;

  FlippinRouterDelegate(this.routes)
      : navigatorKey = GlobalKey<NavigatorState>() {
    routeState.addListener(notifyListeners);
  }
}
Enter fullscreen mode Exit fullscreen mode

So we can observe above that:

  • The delegate has a routes property which is the map of routes that we specify when we instantiate Flipbook.
  • It has a routeState property that stores the current route.
  • It listens to changes in the routeState.
  • It is also a ChangeNotifier, so it can also relay the changes that it detects in routeState.
  • The router delegate uses a generic type <RoutePath> to indicate the data model it uses to represent route URLs internally. Since we like simplicity, here RoutePath is just a type alias for String.

The router delegate is responsible for building the navigator, which controls the switching of our UI screens. So inside our delegate class, let's add this build method:

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: _getPages(),
      onPopPage: (route, result) => route.didPop(result),
    );
  }
Enter fullscreen mode Exit fullscreen mode

Let's focus our attention on the pages property for a bit. This is a list of pages which represents the navigation history "stack". Initially the default screen is the only value in this list, then as you open screens on top of the last one (stack vs. replace), the additional screens are appended to the pages list.

So let's say you have a MainScreen and from there you navigate to a DetailScreen that opens on top of it, the pages stack will look like this:

      pages: [
        MaterialPage(
          key: ValueKey('/main'),
          child: Builder(
            builder: (context) => MainScreen(),
          ),
        ),
        MaterialPage(
          key: ValueKey('/detail'),
          child: Builder(
            builder: (context) => DetailScreen(),
          ),
        ),
      ],
Enter fullscreen mode Exit fullscreen mode

Our _getPages() method will be responsible for dynamically generating this list based on the valid routes we specified when we instantiated Flipbook, and the Navigator's state.

Let's add our implementation of _getPages(). Remember we're still on our router delegate class.

  PageList _getPages() {
    PageList pages = [];

    for (String key in routes.keys) {
      if (_matchesRoutePath(key) || key == '*') {
        pages.add(FadeAnimationPage(
          key: ValueKey(routeState.path),
          child: Builder(
            builder: routes[key]!,
          ),
        ));
        break;
      }
    }
    return pages;
  }
Enter fullscreen mode Exit fullscreen mode

The _matchesRoutePath() function checks if the current route path matches one of those listed in the delegate's routes property. I'll leave the implementation of this function to you, as it is pretty much an exercise of logic, rather than Nav 2.0.

So we've covered the navigation part, and our router delegate class is almost complete. Let's set it aside for a while and take care of the other important piece.

At the other end of the link we're connecting is the part of the app state that, when updated, will trigger a navigation. Let's refer to this as the route state.

Let's define our RouteState class:

class RouteState extends ChangeNotifier {
  String _path = '';
  PathParams _params = const {};

  String get path => _path;
  Map<String, String>? get params => _params;

  void setState(String newPath, [PathParams newParams = const {}]) {
    if (_path == newPath) return;

    _path = newPath;
    _params = newParams;
    notifyListeners();
  }

  static final RouteState instance = RouteState();
}
Enter fullscreen mode Exit fullscreen mode

Notice that:

  • The RouteState is also a ChangeNotifier, so that the router delegate can listen to it.
  • It has a path property which stores the current URL of the current route
  • It also has a params property which is a map of URL parameters (if any). For example, if the route path looks something like /items/:id, and the matched URL is /items/1234, then our map will look like this: {'id': '1234'}.
  • Changes to these properties must be done by calling setState() which notifies listeners of all such changes.

With that we've completed the connection between our navigator and the app state. Up next, we'll link up the app state to the route URL...

Updating the URL when app state changes

Although unnecessary most of the time, Nav 2.0 provides flexibility to "translate" URLs into a format that your app can use internally, sort of like a serialize/deserialize thing. It allows us to extend a RouterInformationParser class for this purpose. (What a long name, I know! 😂)

But for us who prefer simple solutions, let's just keep the URLs as plain strings, so here's our implementation of the parser:

typedef RoutePath = String;

class RouteParser extends RouteInformationParser<RoutePath> {
  @override
  Future<RoutePath> parseRouteInformation(
      RouteInformation routeInformation) async {
    return routeInformation.location ?? '/';
  }

  @override
  RouteInformation restoreRouteInformation(RoutePath data) {
    return RouteInformation(location: data);
  }
}
Enter fullscreen mode Exit fullscreen mode

It's just the same string value through and through. Nothing to see here.

Now let's enable auto-updating our URL on every change to the pertinent app state (i.e. the RouteState). Again it's our route delegate that will mediate the whole process. Here are the steps:

  • listen for state change (✅ we already implemented this).
  • Router delegate notifies our router (yes, Flipbook) that the URL needs updating, then the router picks up the active route path from the delegate.
  • The router uses the parser's restoreRouteInformation to rewrap the URL in "route information" format. (✅ already covered above)

So what's still missing is the piece that handles that second step. Because we chose to use good ol' string format for route path, it's actually as simple as adding one line (a getter) to our FlippinRouteDelegate class:

  RoutePath get currentConfiguration => routeState.path;
Enter fullscreen mode Exit fullscreen mode

This one is called by the router (Flipbook) to fetch the route path from the delegate.

Now the inverse of this process: let's enable RouteState to change whenever the URL changes (e.g. for Flutter Web, when user manually types a deep-linking URL)...

Updating the app state when the URL changes

This time here's what's happening:

  • The URL (manually) changes
  • The router/Flipbook uses the parser's parseRouteInformation to translate the URL info.
  • The router calls a method in the delegate to inform of this update. (👈 This is what we still need to add to our delegate class).

The method we're talking about is the setNewRoutePath(). Let's add that to our FlippinRouterDelegate:

  @override
  Future<void> setNewRoutePath(RoutePath path) async {
    routeState.setState(path, _getParams(path));
  }
Enter fullscreen mode Exit fullscreen mode

We're done linking the URL to the app state. And with that, our FlippinRouterDelegate implementation is now complete! Time to finally assemble our Flipbook router widget...

Putting it all together

If you're still with me, kudos for the patience and enthusiasm! Take a bathroom break or something. 🤣 This is the final stretch.

The last big piece of the puzzle is our Flipbook widget. It makes use of everything we've built so far.

Let's go straight to the code, looking at the widget class first:

class Flipbook extends StatefulWidget {
  final String title;
  final ThemeData? theme;
  final RouteMap routes;

  Flipbook({
    required this.routes,
    this.title = '',
    this.theme,
  });

  @override
  _FlipbookState createState() => _FlipbookState();
}
Enter fullscreen mode Exit fullscreen mode

This is a simplified version, but normally this should include all the properties of MaterialApp that you need to define. Now on to the matching state class:

class _FlipbookState extends State<Flipbook> {
  final _routeParser = RouteParser();
  late final FlippinRouterDelegate _routerDelegate;

  @override
  void initState() {
    super.initState();
    _routerDelegate = FlippinRouterDelegate(widget.routes);
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: widget.title,
      theme: widget.theme,
      routerDelegate: _routerDelegate,
      routeInformationParser: _routeParser,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Now one more little piece to complete the package. We need to create a BuildContext extension so that widgets can easily update the route state, i.e. context.route.setState(path):

extension RouteContext on BuildContext {
  RouteState get route => RouteState.instance;
}
Enter fullscreen mode Exit fullscreen mode

That's it. Congratulations for finishing this challenge with me!

Further exploration...

We've built enough of our API to cover what we intended to build out, and hopefully it also gave us enough understanding of how Navigator 2.0 works.

However there is always room for our API to grow, unfortunately we don't have time/space to cover everything here. These are some of the things that you may want to further pursue, for learning, utility or just having fun:

  • nested routers
  • handling of the Android Back button
  • custom handling/animation of transitions

I have implemented these already, but I'll let you take the challenge first, and then I'll post a gist on GitHub soon. 🤓

Thank you for reading. I hope you gained something from this long journey.

Discussion (0)