Flutter Navigator 2.0 for web dev: Url handling

Lulupointu
6 min readJan 21, 2021

--

This article is part of a series aiming at making you a master of Navigator 2.0 while showing you that it is not as hard as people think. The other articles of this series are:

Url handling was one of the biggest promise of navigator 2.0. And by “url handling” I mean:

  • Changing the state of your app updates the url
  • Changing your url changes your app state
  • The forward and backward button on a browser work

In short: bringing the expected web experience to our app.

What you already know is that this is now possible, what you don’t know is how simple this is.

Note that this article is NOT about routing. This is really important because flutter introduced us to routing and url handling at the same time and this made things messy. I personally believe that you should think about them as two different things.

However, you must understand what Router and RouteDelegate are, so if you don’t, check my article Flutter Navigator 2.0: 101 for mobile dev.

Now, let’s look at a very clear schema of what is happening:

Just kidding, here is the real deal:

The theory

When you enter a new url:

  • parseInformationRouter is called, which should convert your url into an Navigation state (wee above what this is).
  • Then, setNewRoutePath is called, and should convert the navigation state into your app state.

When your app state change:

  • Call routerDelegate.notifyListeners() to warn flutter that the url should be synchronized
  • currentConfiguration is called which should convert your app state into a navigation state.
  • restoreRouteInformation is called which converts your navigation state into a url.

What is the navigation state? It is a class which should hold all the information about the navigation. Why does this exist instead of just having the url as a navigation state? I don’t know, maybe because parsing the url separately (ie in RouteInformationParser is safer). Don’t bother yourself with that, just accept it.

What is RouteInformationParser? If you already understand routing, the routerDelegate should sound familiar, however the RouteInformationParser should not. It is just a class which we will implement and override the methods parseRouteInformation and restoreRouteInformation .

Our example app

You can find the code at the end of this article

This is the basic counter app and the url is equal to 10*count .

Has we saw, we will need to create a RouteInformationParser and a RouterDelegate. To use the, as when routing, we will need to pass them to a Router widget. We also need a class to hold our navigation state.

Navigation State

Here we just need to keep track of the counter so our class is simple:

The RouteInformationParser

parseRouteInformation:

Called when the url is updated. Its job is to convert the url (contained in routeInformation.location) into the navigation state. Here the url is /counter to we remove the first character and parse the rest. Also note that if the url is not valid, navigationState.value is null.

restoreRouteInformation:

This is the inverse function of parseRouteInformation, it takes a NavigationState and convert it into a url. This is called after routerDelegate.currentConfiguration which we will see next.

The RouterDelegate

The build is not really interesting, it is like any RouterDelegate. Just don’t forget that even if we don’t use routing, having a navigator is interesting because if you don’t have any in you app it can break things (such as overlays).

The interesting part are: setNewRoutePath, currentConfigurationand increase:

setNewRoutePath

Called right after routeInformationParser.parseRouteInformation. It’s job it to convert the navigation state into your app state. This is where all your logic should take place: verifying that the user can access what he/she is trying to access, redirecting if needed. In our case, we check if navigationState.value is null (remember that this is what happens if the url is not valid). If it’s null, we don’t modify our state but call notifyListener so that our url changes back to a correct one. If it is not null, we compute navigationState.value/10 and take the int part. Then we check if the url is in sync with the new counter, if not we call notifyListener .

currentConfiguration

This is called when notifyListener is called. It’s job is to change the app state into the navigation state. When this function ends, routeInformationParser.restoreRouteInformation is called with this new navigation state.

The Router

We just need to put everything into a router and we are done! However note that you can’t use MaterialApp and a Router which handles the url at the same time for now. This is because MaterialApp belongs to Navigator 1 and Router is navigator 2 so their is a conflict. To prevent this, use MaterialApp.router which create a MaterialApp and a Router. Also remember to not use MaterialApp down your widget tree since the same issue will occur. This forces us to have our router really up the widget tree but with most state management libraries this is not an issue.

That’s it

Go back to the theoretical schema that I show you, and you will see that you understand everything and that you can generalize this to any app!

Here is the entire code for those who just want to copy/paste 😉

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

void main() {
runApp(UrlHandler());
}

class UrlHandler extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerDelegate: UrlHandlerRouterDelegate(),
routeInformationParser: UrlHandlerInformationParser(),
);
}
}

class NavigationState {
final int value;
NavigationState(this.value);
}

final GlobalKey<NavigatorState> _urlHandlerRouterDelegateNavigatorKey =
GlobalKey<NavigatorState>();
class UrlHandlerRouterDelegate extends RouterDelegate<NavigationState>
with ChangeNotifier, PopNavigatorRouterDelegateMixin {
int count = 0;

@override
Widget build(BuildContext context) {
return Navigator(
pages: [
MaterialPage(child: MyHomePage(count: count, increase: increase)),
],
onPopPage: (_, __) {
// We don't handle routing logic here, so we just return false
return false;
},
);
}

@override
GlobalKey<NavigatorState> get navigatorKey => _urlHandlerRouterDelegateNavigatorKey;

// Navigation state to app state
@override
Future<void> setNewRoutePath(NavigationState navigationState) {
// If a value which is not a number has been entered,
// navigationState.value is null so we just notifyListeners
// without changing the app state to change the value of the url
// to its previous value
if (navigationState.value == null) {
notifyListeners();
return null;
}

// Get the new count, which is navigationState.value//10
count = (navigationState.value / 10).floor();

// If the navigationState.value was not a multiple of 10
// the url is not equal to count*10, therefore the url isn't right
// In that case, we notifyListener in order to get the valid NavigationState
// from the new app state
if (count * 10 != navigationState.value) notifyListeners();
return null;
}

// App state to Navigation state, triggered by notifyListeners()
@override
NavigationState get currentConfiguration => NavigationState(count*10);

void increase() {
count++;
notifyListeners();
}
}

class UrlHandlerInformationParser extends RouteInformationParser<NavigationState> {
// Url to navigation state
@override
Future<NavigationState> parseRouteInformation(RouteInformation routeInformation) async {
return NavigationState(int.tryParse(routeInformation.location.substring(1)));
}

// Navigation state to url
@override
RouteInformation restoreRouteInformation(NavigationState navigationState) {
return RouteInformation(location: '/${navigationState.value}');
}
}

class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.count, this.increase}) : super(key: key);
final int count;
final VoidCallback increase;

@override
_MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Counter App'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'${widget.count}',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
widget.increase();
},
tooltip: 'Counter',
child: Icon(Icons.add),
),
);
}
}

--

--