Understanding the Flutter Router API / Navigator 2.0 step by step

Posted by Elte on September 04, 2022 · 27 mins read

I’m not going to waste too much time on an intro: you’re here because you’re trying to use the Flutter Router API, and the documentation is full of boilerplate and hard to understand. I agree. I also think it’s not fundamentally all that complicated, so I’m going to try to explain it, by introducing the parts one at a time. I’m also going to avoid explaining anything that isn’t relevant. Maybe it’s a fool’s errand - I guess we’ll find out!

This post assumes you understand how to build a basic app in Flutter without the Router magic. Plenty of excellent resources on that are but one Google search away.

Step 1: An app from state

Fundamentally, a Flutter app is a function that turns a state into a set of widgets. For this guide, I’ll be creating an app with three screens:

  • A WelcomeScreen
  • A PostsScreen, showing a list of posts
  • A PostDetailsScreen showing details of a post.

The screens are not that relevant here, they’re just doing the bare minimum necessary for the example. Their code and all the final code for this article will be available in this repository.

Let’s get started by defining our app state:

class AppState {
  /// Show the list of posts if this value is true
  final bool showPosts;
  
  /// Show a specific post if this value is true
  final int? postId;

  const AppState({
    this.showPosts = false,
    this.postId,
  });
}

I believe this state speaks for itself. We either show our welcome screen (showPosts = false, postId = null), our posts screen (showPosts = true, postId = null) or our post details screen (postId != null). At the root of our app we put a StatefulWidget that manages this state, and shows the appropriate widget depending on it:

class _RouterGuideState extends State<RouterGuide> {
  AppState appState = const AppState();

  /// App state updater
  void changeAppState(AppState state) => setState(() => appState = state);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    Widget home;
    if (appState.postId != null) {
      final post = posts.firstWhere((element) => element.id == appState.postId);
      home = PostDetailsScreen(
        post: post,
        update: changeAppState,
      );
    } else if (appState.showPosts) {
      home = PostsScreen(update: changeAppState);
    } else {
      home = WelcomeScreen(update: changeAppState);
    }

    return MaterialApp(
      title: 'Flutter Router Step By Step',
      home: home,
    );
  }
}

This is a basic working app. There are no transitions, back buttons or URL state (and it’s ugly to boot!), but it works. The code for the app up to this step is available https://github.com/ElteHupkes/flutter-router-step-by-step/tree/step-1.

App after step 1

So far so good, this had nothing and also everything to do with routing.

Step 2: Routing

Now let’s pretend for a second that our app is a webapp. Actually, we don’t have to pretend, we can run this app as a web app right now. It has a static URL, but we want that URL to update. This is essentially where Flutter’s Router API comes in.

Step 2.1: From URL to AppState and back

The first thing we need is a way to represent our AppState as a URL, and a way to convert a URL to an AppState. The Router API is going to ask for a class that does this. It’s called a RouteInformationParser. To start, we can just put that functionality into our AppState:

class AppState {
  final bool showPosts;
  final int? postId;

  const AppState({
    this.showPosts = false,
    this.postId,
  });

  /// Converts this app state to a URI
  String toLocation() {
    if (postId != null) {
      return "/posts/$postId";
    }

    if (showPosts) {
      return "/posts";
    }

    return "/";
  }

  /// Converts a string location into an [AppState]. This is very sloppy
  /// with incorrect URLs of course, but that's not relevant here.
  static AppState fromLocation(String location) {
    final parts = location.split("/").where((l) => l.isNotEmpty).toList();
    if (parts.isNotEmpty && parts[0] == "posts") {
      final id = parts.length > 1 ? int.tryParse(parts[1]) : null;
      return AppState(postId: id, showPosts: id == null);
    }

    return const AppState();
  }
}

Now that we have that, there’s really nothing to the RouteInformationParser:

class RouteParser extends RouteInformationParser<AppState> {
  @override
  RouteInformation? restoreRouteInformation(AppState configuration) {
    return RouteInformation(location: configuration.toLocation());
  }

  @override
  Future<AppState> parseRouteInformation(RouteInformation routeInformation) {
    return SynchronousFuture(
        AppState.fromLocation(routeInformation.location ?? "/"));
  }
}

You can basically forget this exists now. It’s just a wrapper that parses app state to and from a URL.

Step 2.2: Updating when changes happen

We now have our app state represented in two ways:

  1. Externally, through the URL
  2. Internally, through our AppState.

If one of these changes, we might want to update the other one. We dont have to though, it’s quite conceivable you could make changes to your app state that you don’t care to be represented in the URL. But I said I wouldn’t go off on such tangents, so I won’t.

The Router API has a component responsible for communicating these changes in both directions: the RouterDelegate. This class is, in my humble opinion, where all the confusion happens. It does many things at once, but just shy of everything (which would probably be less confusing). Let’s implement its responsibilities one by one. Note that the code might not compile every step along the way, so bear with me.

First our delegate is going to take charge of everything that our _RouterGuideState (the state of our previous root widget) did. It’s going to be the source of truth for our AppState and provide a way to update it. It’s also going to build the page that needs to be displayed depending on that state.

class AppRouterDelegate extends RouterDelegate<AppState> {
  var _state = const AppState();

  void changeAppState(AppState state) {
    _state = state;
  }
  
  @override
  Widget build(BuildContext context) {
    if (_state.postId != null) {
      final post = posts.firstWhere((element) => element.id == _state.postId);
      return PostDetailsScreen(
        post: post,
        update: changeAppState,
      );
    }
    
    if (_state.showPosts) {
      return PostsScreen(update: changeAppState);
    }

    return WelcomeScreen(update: changeAppState);
  }
}

This is convenient, because our delegate gets notified by the system when the URL changes. It doesn’t just give us the new URL (which, like I said, I think would’ve been less confusing), but it passes it through that little route parser we defined just now. Ignoring that, we just get a call if there’s a new external state update:

class AppRouterDelegate extends RouterDelegate<AppState> {
  .....

  @override
  Future<void> setNewRoutePath(AppState configuration) {
    changeAppState(configuration);
    return Future.value(null);
  }
  
  ....
}

If the state changes, the platform needs to be notified of these changes, so that it can rebuild the displayed widgets (i.e. call build(context)) and, in case of an internal change, synchronize the URL. To do this, it subscribes to our class using the ChangeNotifier interface. We can implement ChangeNotifier ourselves, but it’s easiest to just use the available mixin:

class AppRouterDelegate extends RouterDelegate<AppState> with ChangeNotifier {
   ....
}

So now, if our AppState changes, we notify the platform of these changes:

  void changeAppState(AppState state) {
    _state = state;
    notifyListeners();
  }

If a change happens, the router is going to fetch the latest state from the currentConfiguration property:

  @override
  AppState? get currentConfiguration => _state;

If you don’t care about the URL state being synchronized (if you don’t have a webapp, for instance), you can leave this unimplemented, or return null.

For this class to compile, there is one more method to be implemented, namely popRoute(). We leave this empty and ignore it for now.

This is the complete AppRouterDelegate:

class AppRouterDelegate extends RouterDelegate<AppState> with ChangeNotifier {
  var _state = const AppState();

  void changeAppState(AppState state) {
    _state = state;
    notifyListeners();
  }

  @override
  Widget build(BuildContext context) {
    if (_state.postId != null) {
      final post = posts.firstWhere((element) => element.id == _state.postId);
      return PostDetailsScreen(
        post: post,
        update: changeAppState,
      );
    }

    if (_state.showPosts) {
      return PostsScreen(update: changeAppState);
    }

    return WelcomeScreen(update: changeAppState);
  }

  @override
  Future<void> setNewRoutePath(AppState configuration) {
    changeAppState(configuration);
    return Future.value(null);
  }

  // Pay no mind to this for now
  @override
  Future<bool> popRoute() {
    return Future.value(false);
  }
}

Step 2.3: replacing our MaterialApp

So far we added two things:

  • Something that parses our AppState from and to a URL
  • Something that builds our app based on our AppState and gets notified when it changes, internally or externally

This is really all we need to do to have the app from Step 1 synchronize its URL state. We update our root widget:

class _RouterGuideState extends State<RouterGuide> {
  final delegate = AppRouterDelegate();
  final parser = RouteParser();

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routeInformationParser: parser,
      routerDelegate: delegate,
      routeInformationProvider: PlatformRouteInformationProvider(
        initialRouteInformation: const RouteInformation(location: "/"),
      ),
    );
  }
}

There is one unfamiliar entry here, which is the routeInformationProvider. This defines how the URL is communicated from and to the host platform. In a webapp, it will read and write the URL bar. The only thing we tell it is what the initial state is we want it to assume if none is provided.

That’s it for step 2. The code up to this step can be found here.

App after step 2

Our URL is updating, but there are still no page animations or back buttons (except for the browser’s back button, which works by updating the URL). That’s because there’s one glaringly absent part: a Navigator. That’s right, in order to implement the basic functionality of “Navigator 2.0”, you don’t need a Navigator. “Router API” is definitely a more appropriate term here. Anyway, let’s add a Navigator.

Step 3: The Navigator

So, what is this Navigator? It’s a widget that takes a stack of pages and animates between the visible ones. Those pages also have routes, which define some other interactions like animations. There’s a lot of nuance here that I don’t understand myself, but that’s essentially it. Before the Router API, you only interacted with this the Navigator by push()ing and pop()ing pages. Actually, that API still works, and that’s part of the confusion. Let’s add a Navigator to our delegate’s build() method:

  @override
  Widget build(BuildContext context) {
    return Navigator(
      pages: [
        // The welcome screen is always there, at the bottom of the stack
        MaterialPage(
          child: WelcomeScreen(
            update: changeAppState,
          ),
          name: "welcome",
        ),

        // Display the post list if a post is showing, or if we
        // requested the post list.
        if (_state.showPosts || _state.postId != null)
          MaterialPage(
            child: PostsScreen(
              update: changeAppState,
            ),
            name: "posts",
          ),

        // Finally a Post if the state says so
        if (_state.postId != null)
          MaterialPage(
            child: PostDetailsScreen(
              update: changeAppState,
              post: posts.firstWhere((element) => element.id == _state.postId),
            ),
            name: "posts",
          ),
      ],
    );
  }

An exception will be thrown if we run this, because Flutter now forces us to add an onPopPage callback (it’s not a big fan of this “one step at a time” approach, apparently). This is because the Navigator introduces back buttons on the app bar, which use the old API to pop the page at the top of the stack. When it does this, it needs to tell us that it did that, so that we can in turn update the AppState. This is essentially another external state change like a URL change, but through a different mechanism. Let’s implement this method:

  /// Called when the navigator pops a page
  bool _onPopPage(Route<dynamic> route, result) {
    if (!route.didPop(result)) {
      return false;
    }

    final name = route.settings.name;
    
    if (name == "post_details") {
      // If we were in the post details page, return to the posts page
      changeAppState(const AppState(showPosts: true));
    } else if (name == "posts") {
      // If we were in the posts page, return to the welcome page
      changeAppState(const AppState());
    }

    return true;
  }

The top of this method once again provides a nice source of confusion. The Route that is passed might have some internal logic that allows it handling a pop (like a nested navigator), in which case didPop() will return false, and we should ignore the pop. I’ve yet to encounter a situation where this is relevant personally, but I guess it might be useful somewhere. Either way, we check if the name of the route that’s being popped is one we recognize, and alter our AppState accordingly. Note that changeAppState() calls notifyListeners(), which is in turn going to update the URL again. It actually also rebuilds the Navigator, so we could theoretically not change our state and end up where we started, if we wanted to cause confusion.

We just add the callback to our Navigator:

  @override
  Widget build(BuildContext context) {
    return Navigator(
      onPopPage: _onPopPage,
      ....
    );
  }

That’s it for step 3! The code for the app up to this step is available here.

App after step 3

We now have a synchronized URL state, multiple stacked pages, transition animations, and working app bar back buttons. There’s one last thing to do though.

Step 4: Finishing touches: device back button

Remember that pesky popRoute() method we were forced to implement earlier? You might be wondering what the hell that’s doing now that we have a working Navigator that successfully pops pages. Well, besides an updating URL and an app bar back button, there’s actually a third way our state can be updated externally, one that’s particularly common on Android devices: a device back button. If we launch our current app on Android and press the back button, it just closes the app, regardless of where we are in the route stack. Not good. To remedy this, we first add one line to our MaterialApp.router() call:

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      backButtonDispatcher: RootBackButtonDispatcher(),
      routeInformationParser: parser,
      routerDelegate: delegate,
      routeInformationProvider: PlatformRouteInformationProvider(
        initialRouteInformation: const RouteInformation(location: "/"),
      ),
    );
  }

That RootBackButtonDispatcher listens to the platform back button and allows the router to subscribe to its events. When it receives one, it will call popRoute() in our delegate. Currently, this does nothing. Something that might be convenient for it to do is call Navigator.pop(), since that’s essentially the action it’s performing, and we already have that logic wired up. Because this is most likely what we want, there’s a mixin with a totally straightforward and easy to remember name that does just that:

class AppRouterDelegate extends RouterDelegate<AppState>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin {
  ...

  // We remove this method, the mixin provides its implementation now
  // @override
  // Future<bool> popRoute() {
  //   return Future.value(false);
  // }
}

This mixin looks up our Navigator by it’s key and calls its pop() method when popRoute() is called. In order for it to do so, we need to supply it with the key of our Navigator:

class AppRouterDelegate extends RouterDelegate<AppState>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin {
  @override
  final GlobalKey<NavigatorState>? navigatorKey = GlobalKey(debugLabel: "Root navigator");
  
  ....
  
  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey, // Add the key to our Navigator
      ....
    );
  }
}

And now the platform back button behaves the way we want it to.

Our final app

That’s it! all the most important parts of the Router API implemented.

Recap

So in summary, in this article we:

  • Created an app that decides which widget to display depending on its state
  • Added the Router API to synchronize this state with the platform URL
  • Added a Navigator to get neatly stacked pages, app bar back buttons and transition animations
  • Made sure the platform back button behaved the way we wanted in that Navigator

The app’s code after the final step can be found here.

Fundamentally the Router API is not that difficult, but there are a lot of moving parts and boiler plate code to write. I really hope this clears up the confusion for somebody. If not… well, there’s always GoRouter. I hear it’s managed by the Flutter team now.