Learn Navigator 2.0 by Building a Simpler API !
Tags: flutter, tutorial, dart
Published: 2021-05-31 14:06:03

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. 🤓 

<p></p>

> __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:

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

`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:

```dart
  context.route.setState('/login');
```

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:

```dart
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);
  }
}
```

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:

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

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:

```dart
      pages: [
        MaterialPage(
          key: ValueKey('/main'),
          child: Builder(
            builder: (context) => MainScreen(),
          ),
        ),
        MaterialPage(
          key: ValueKey('/detail'),
          child: Builder(
            builder: (context) => DetailScreen(),
          ),
        ),
      ],
```

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.

```dart
  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;
  }
```

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:

```dart
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();
}
```

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:

```dart
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);
  }
}
```

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:

```dart
  RoutePath get currentConfiguration => routeState.path;
```

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`:

```dart
  @override
  Future<void> setNewRoutePath(RoutePath path) async {
    routeState.setState(path, _getParams(path));
  }
```

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:

```dart
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();
}
```

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:

```dart
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,
    );
  }
}
```

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)`:

```dart
extension RouteContext on BuildContext {
  RouteState get route => RouteState.instance;
}
```

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.