Navigation done right: a case for hierarchical routing with Flutter
Working in mobile development, I keep finding navigation to be one of the most challenging parts of project UI architecture. Sure, some apps contain really complex screens where we greatly benefit from the advantages of various approaches to splitting presentation and display logic like BLOC, MVP, MVI, etc., but integrating a new screen into the existing structure often becomes the bottleneck in new feature development. So what really determines how you cope with ever-growing complexity and changing requirements — is how routing between screens is implemented.
In this article, I’ll briefly describe navigation principles we’ve been applying on our Android project at Bolt that I found useful when building two Flutter apps on my own, what problems these principles solve, and how they can be implemented with minimum code amount using just the framework-provided Navigator.
There’s plenty of ways to mess up with navigation and here I want to address some of the most common ones together with possible solutions, which in my experience helped to manage the complexity of big and constantly evolving apps really well.
Breaking the tight coupling of individual screens
Let’s say we have an app where a user has to fill their profile before they can continue — there might be a sequence of steps where we collect different pieces of information like interests, location, languages they speak, etc., and in the end, all the collected information is sent to the backend.
I expect the most straightforward implementation of intermediate screens will have a “Continue” button click listener similar to this one:
And it will be the task of the last screen to submit the finalResult
.
The main point here is not how the input gets passed between screens — it might as well be saved to some repository before pushing the next route to Navigator, what’s far more important (and worse) here is that a screen knows what should happen after its goal has been fulfilled.
But what’s the downside? Let’s say users of our app will want to have an option to edit their profile later on, and because UX where you have to go through all the pages to change something on only one of them is complete nonsense, we’d want each of these screens to support a different mode — where after “Continue” is pressed, the collected input is immediately submitted and a partial update is made.
Well, sure thing we can keep everything as it was, but now, our listeners turn into something like:
Well, this became messier but it seems like the complexity is still manageable, right? Yeah, but apart from this being a toy made-up example (in reality there will definitely be more supporting code), there are many weak points of such an approach to user action handling.
Every screen gets extra dependencies: those responsible for forward-navigation become coupled with their successor and all screens depend on classes we use for submitting data. In big projects, it’s becoming a common practice to extract features to separate compilation units, and it’s much better for our build times if each one of these has as few dependencies as possible.
If steps get reordered or, more importantly, a new step is introduced —we’ll have to change existing screens.
If we later need to reuse the same selection type for some other information — let’s say a screen for typing a user’s motto looks the same as the one where we ask their location, differing only in the title — it will be harder to reuse the code we had if screens also contained pieces of business logic. Not once I had cases when we extracted individual screens into a library to share them between different projects.
The key takeaway of this section is — the fewer dependencies a screen has and the less it knows about why it was displayed the better.
Abstract your exit points
Luckily there’s a simple solution for breaking the tight coupling of individual screens. If we simply abstract all the exit points, we transfer the responsibility of deciding which action to take after the currently displayed screen has fulfilled its purpose. So let’s define a listener for location input screen from our example:
Adding this ScreenListener or InteractionListener suffix, because of how generic it is, allows us to combine seemingly unrelated exit points together. You might as well split it into more specific interfaces like OnLocationEnteredListener, OnBackPressedListener, but usually, I prefer to group all of them. We can now use the interface to handle continue button clicks like this:
The listener can either be passed to a widget constructor or I found it convenient to make use of context.findAncestorStateOfType method because it’s usually our ancestor who knows why their child was displayed. For this to work, we need to add a minor detail to our listener declaration:
Now we can access it like context.findAncestorStateOfType<LocationInputScreenListener>(), given one of our ancestors was declared as:
This interface now becomes some kind of a contract your screen demands from elements up in the widget tree to adhere to for being able to display it. It’s usually (but not necessarily) the direct parent who implements the interface, or it might as well be different parents if we decide it’s easier to handle different events this way and declare multiple interfaces. There’s an example of such split in the sample app: the logged-in flow controller takes care of profile editing but the root state handles logout action, even though two events are generated by the profile page.
In addition to points discussed above, abstracting exit points also simplify collaborative work on a project a great deal when a person assigned to build a feature doesn’t have to wait for the context this feature will be presented in to be ready, they just define an interface and build the feature in itself, and later it gets plugged into its final place.
Flow orchestrators
Having transferred part of the decision making from individual screens to their ancestors, we’re still probably mixing presentation and navigation logic. Let’s see how we can separate these with a flow controller/coordinator/orchestrator (you choose) design pattern.
We can think of our app as of a tree where leaf nodes are individual screens while other nodes represent abstract flows. Going back to our example, our imaginary app might be viewed as:
Not to anger computer scientists out there, I’ll note that it’s not actually a tree, but a rooted acyclic directed graph, because there’s at least one (but can be more) path from the root to any other vertex. In other words — nodes can be reused i.e. displayed in multiple contexts.
Such modeling is helpful because it allows us to see groups of related screens that represent a single flow. For each such flow, we can then create an “empty” ancestor whose sole responsibility is to orchestrate the flow, i.e. decide which screen should be displayed at any given moment. Let’s see how it helps to make navigation more manageable.
I think that the biggest advantage of this pattern is scoped navigation logic. Basically, for every flow, you have a place with a complete description of the flow including the order and conditions in which screens get displayed, the data that gets gathered during the flow, the animations that are played during screen transitions, etc. Not only does it provide a clear picture of what’s going on, but allows to easily share and tweak things, like for example adjust animation easing or time, reorder stages or introduce an intermediate step based on the provided input.
Another great thing is that it’s far simpler to manage a flow-scoped navigation stack than an app-wide one —there are only widgets related to the flow in our stack. This decreases the probability of a mistake and its cost when some less trivial stack manipulations are performed, like popUntil.
Flow controllers are also a good place for keeping some common logic or UI components shared by multiple screens within a flow. In a Flutter project, I worked on we also kept the logic for displaying dialogs and bottom sheets in the base flow controller class to provide fast and easy access to these components.
Single stack BaseFlowController in Flutter
Before showing how a base single stack flow controller might be implemented, I’d like to make a few notes.
Firstly, this is just an example and a pretty barebone one. You can take it as a basis and extend it in any way your project requires.
Secondly, I chose a single stack “empty” flow controller because it’s probably the most common one, but more complex apps might, for example, contain multi-stack flows where several components are displayed at the same time and this too can be implemented — your flow controller should just have multiple containers and some kind of container key to pass to stack-mutator methods, and that’s basically it.
Lastly, flow controllers might contain some visible UI elements shared by multiple stages of a flow — like a bottom sheet or maybe some banner used for popup notifications, but I decided to leave the implementation of such things as an exercise for the reader.
Here I will demonstrate code snippets of the most important implementation bits, but you can also check out the GitHub repository I’ve set up for this article, it contains the complete source code. And now… let’s see the code:
Yep, that’s basically it. Well, the bare minimum. Now let’s break it down into parts and see what’s there and why.
Extend navigator functionality
_navStack is optional but might give us better control allowing to provide methods like:
Hide the implementation
_navKey is used to access and manipulate the navigator. It’s better to avoid exposing it to our descendants and provide state mutator methods instead:
This way we can easily add extra functionality like logging and ensure correct _navStack state. We can even swap Navigator for some other abstraction if the need ever arises.
Allow screens to know when they are displayed
_routeObserver is not used in the flow controller but is nice to have for implementing lifecycle aware states, the ones that perform some actions based on their visibility — like turning polling on and off when the app goes to background and back:
For example, in the sample, I used it to refresh the profile page, as we could’ve returned from the profile editing screen after changing some of the user data.
Handle back presses
The last and maybe the trickiest part is handling back presses. If we just wrap our Navigator into a WillPopScope widget, the topmost widget will receive all the back presses, hiding them from child flow controllers.
findAncestorStateOfType is cheap because at worst it visits the number of nodes equal to the height of our widget tree. Contrast this with searching for a descendant willing to handle a back press where at worst we will be traversing the whole tree (perhaps failing to find a single candidate).
To avoid this I decided to have only one WillPopScope and a list of states that are interested in back presses. It’s the responsibility of a flow controller to register and unregister itself and the responsibility of a WillPopScope container to dispatch events to the registered handlers.
That’s how a helper class might look like:
We can then use it in our flow controller or any other state that wants to handle back presses like this:
The root state has to become a PopScopeHost, simply delegating to the onWillPop method declared in the mixin:
Putting things to work
Finally, let’s look at how a profile setup flow controller implementation for our example might look if we use the created abstraction:
High cohesion and low coupling, just like textbooks teach. Profile editing flow presents a more complicated case as there’s an extra child (the Edit screen) and after the user makes a selection the controller needs to update a repository and navigate to the first screen in the flow.
Parting thoughts
I am not a professional flutter developer and maybe the code I presented might be rewritten in a better way, but the concepts described won’t change and this is what I think gives value to the article.
Mobile development presents us with many challenges no matter which tool or framework is used, and the described approaches have proved themselves to scale and allow teams of developers to manage the complexity of constantly evolving apps.
I’d like to thank you for reading up to this point (or at least clicking on the article link for those who’ve just skipped to the end). I hope you’ve enjoyed the article and ideally found something valuable in it.
The full source can be found on GitHub. It’s by no means an out-of-the-box solution, but a stripped copy of what I’ve used in two flutter projects. Please feel free to copy and modify it to adapt it to your needs.
Thanks to Diogo Dantas for the illustration I used as a cover image.