Back to basics: Navigation

Niek Haarman
7 min readOct 25, 2018

--

Previously I talked about the unfortunate design of the Activity class, and I went back to basics regarding screens in an application. This time, we’re going back to basics regarding in-app navigation.

What is meant by ‘navigation’?

In a typical mobile application, a user can navigate from one screen to another. Traveling through an application builds up a navigational state; in Android this is traditionally done with a back stack. Activities can start new Activities which get pushed upon a stack. When the user presses back, the top Activity gets popped off the stack, to reveal the previous Activity.
You can modify how Activities are created and pushed upon the stack with several flags and attributes, which are described at length on the Understand Tasks and Back Stack page.

The back stack

Flows

In a non-trivial application, there are often multiple ‘flows’ that can be defined: there might be a login flow, an onboarding flow, or a flow that takes you through a payment process. In the latter case, you might have a series of screens that start with a description of the user’s shopping cart, followed by forms to enter shipping and payment information. Such flows are usually completely self contained, and can be reused when necessary.
It may be possible to start a specific flow from anywhere in the app, allowing for dozens of paths through your application to exist.

An example payment flow for a shopping application.

Conditional navigation

In your typical application, there is no strict sequence of screens that will appear in a fixed order. Depending on user input, the user can travel through different paths through your application. A dashboard screen for example can have multiple buttons that all lead the user to a different screen.

Another thing to consider is that the next state of the navigation state is dependent on the some conditional state. If the user already has entered shipping and payment info a previous time, we may want to skip these forms and go straight to the confirmation screen. Likewise, if the user has only provided shipping info we may want to jump to the payment info screen.

Responsibilities

In traditional Android development, the Activity decides to go to a new Activity, using startActivity, with the details of the new Activity to go to. With Fragments, often the Fragment calls FragmentManager.beginTransaction with the details of the new Fragment to go to. This entails that the Activity or Fragment has the responsibility of navigation, next to everything else it is managing as well.

Taking back control

Managing navigation in the screens themselves can lead to a messy situation very quickly. If we take our payment flow example and put our navigation logic in the screens themselves, we end up with having to check whether the user has already provided his information multiple times. This leads to duplicated code that is hard to test and maintain.

Screens

In the previous article I already mentioned that for screens to be truly modular, they shouldn’t bear the responsibility of navigating to another screen. Instead, they can publish events that tell the dedicated navigation components something has happened.

One way to do this, is to accept a callback interface that can be implemented by the navigator.

Implementing screens like that makes for modular and self contained blocks of code that can be reused in multiple flows in the application. You can imagine a screen that asks the user to select a picture from a list for example, or you might need to be able to access a shopping cart screen in multiple flows. Not having to deal with navigation inside screens makes it a whole lot easier to reuse them.

Navigators

Pulling navigational logic out of screens means that it must be handled elsewhere. For this, dedicator Navigator instances can listen to events that screens publish, and react accordingly by changing their internal state. This internal state is then translated to a new screen that becomes active; the UI layer (the Activity for example) listens to these screen changes and shows the proper UI for that new active screen.

State representation
Traditionally in Android, navigational state is modeled like a stack: you can push screens onto a stack, and pop them off. Using a stack ensures that the user’s mental model easily corresponds to what your app is doing: you can go forward through a couple of screens, and pressing the back button takes you back along the same path.
More complex scenario’s however do not necessarily follow this pattern. You can imagine a sort of onboarding flow that takes you through a couple of permission screens. Once a specific permission has been granted, it makes no sense to be able to navigate back to that screen. Navigators are free in deciding how to model their navigational state. They can still use a stack, but they also could use a state machine for example.

Multiple flows
Navigators can be composed. That is, you can create small navigators that make up several small flows of your application, and combine them into a ‘main’ navigator. This main navigator behaves the same as all the other navigators, but instead of working with screens in its internal state, it can now work with child navigators. And just as with ‘normal’ navigators, these composed navigators can use any data structure they like to model their internal state.

An example application with multiple flows, which are represented by multiple Navigators. The top (blue) navigator represents an initial onboarding flow, which is only initiated on initial app launch. The middle (green) navigator is the ‘main’ application flow, initiated on all subsequent app launches. Finally the lower (yellow) navigator represents our payment flow and can be started at any time from the main flow.

Dependency scopes
Usually, a screen has its own ‘dependency scope’: a set of class instances that follow are created when the screen is created, and should be destroyed when the screen is destroyed. The same goes for navigation flows: you could imagine there is a sequence of screens for a particular product, and ideally these screens share the same class instances related to that product. When the flow is finished, it’s scope can be disposed of.

With navigators and screens, these scopes come naturally. Screens have their own scopes that are garbage collected automatically with the screen. For very simple cases where the screen has a single dependency, this ‘scope’ is just instantiating passing the dependency through the screen constructor.
Navigators have their own scopes as well, and screen scopes are built from the navigator scope. Navigators outlive screens, so when a navigator gets garbage collected, its scope gets garbage collected as well. Thus, when multiple screens need the same instance of class X , that instance is tied to the navigator scope.

State restoration
With mobile applications and especially for Android, process death is a serious phenomenon that needs to be dealt with. When the user gets a phone call while navigating your application for example, it would be a shame if he or she later came back to the application and the entire application state was lost. To overcome this, state such as view state, screen state and navigation state needs to be saved to be able to restore it after a process death.

Since navigation basically is just state, navigators should be able to save their state as well. This state consists of its current navigational state — for example in case of a back stack the screens that currently live on the stack — and the state of the screens themselves.

Translating to code

To translate the above story to code, we can use the Screen class defined in the previous article:

The Navigator interface, which in the basis just provides Screens to the UI layer, could be defined as follows¹:

Interested parties such as the Activity could register a listener with the Navigator and be notified when a screen change has occurred. In response, they update the UI and provide it as a Container to the screen.

Given an abstract StackNavigator implementation of the Navigator interface which utilizes a stack for its state, we can define our PaymentFlowNavigator from before as follows:

A simplified implementation of a payment flow. This class uses a StackNavigator to model its representation as a stack, and reacts to events of its Screens to make transitions in its navigational state.

Closing remarks

If you want to be able to create fully reusable screens, you must make sure they know nothing about the navigational flow they’re in. The same goes for these flows themselves: a flow can be a fully reusable component that groups a certain set of screens.
Now, screens and navigators become fully composable building blocks to build up the entire presentation layer of your application. As a bonus, these blocks become fully testable on the JVM, since no Android code is involved. That means that you can do integration tests at lightning speed that actually test the flows in your application without ever spinning up an Android machine!

Next up

I’ve talked about screens and navigation and how you can create fully composable building blocks with them. These blocks are fully Android-agnostic, so at some point you will have to go to battle with the Android framework to actually be able to do something.
Since we’ve taken up large parts of the responsibilities of the Activity, we’re pretty much only left with the UI. In fact, one of the few responsibilities the Activity still has is to provide a window and inflate Views that can be attached to screens.
In a next article I will be digging into this, and show the insane amount of control you get with it. I talk about this in Back to basics: Plugging in the Activity.

¹ To be able to fully implement a Screen’s lifecycle, the Navigator also needs various onStart, onStop and onDestroy callbacks which are left out here for simplicity.

--

--