Why did we move away from Navigation Component?

Alizée Camarasa
Inside Aircall
Published in
10 min readFeb 14, 2020

When we started the new Aircall Android app, we took the decision to use Navigation Component from Android Jetpack.
Google did it in order to simplify the navigation between activities and fragments on Android.
But we had several issues with it, and that’s why we decided to build our own navigation system.
In this article, I will explain what were our problems with Navigation Component, how our own system is built and how it works, and also how you can use it too.

Why did we do that?

In our app, we were using Navigation Component as a root for our navigation, but we added a layer of abstraction on top of it.
To give you a bit of context, it’s a multi-module and multi-activities app, using clean architecture. So it was really important for us that the navigation was treated as a service, and was not mixed with the view or business logic.
All the “navigation” code is within the app module.
This was how our architecture looked like:

The only exposed component in our features modules was IRouter, the interface of our custom router. This router was responsible for starting the navigation with only a string, corresponding to a specific route (ex: “contact_detail”), and an optional set of data for extras.
This route was converted into a destination ID (which is the one you have in navigation.xml files) by a routeParser.
A navigator was in charge of creating the bundle for extras and calling navController.navigate(destinationId, bundle) to perform the navigation.
Each feature module has its navigation.xml (ex: contact_navigation.xml)

A very simplified version of the old routing. For the (almost) full version https://gist.github.com/alizeec/8f37b636ba31a0cbc60af504603f8291

We had several problems with that:

1- Because our app is multi-module, we needed to have several someFeature_navigation.xml. To enable navigation from Feature1Activity to Feature2Activity, we had to include

in feature1_navigation.xml.
In Aircall app, the navigation is not that simple. This is a telephony app, so the Activity that manages the on-going call could be started from anywhere and has to be in all someFeature_navigation.xml. If an activity is missing in the XML and we try to start it anyway, it will crash at runtime. Moreover, even if it does compile, the IDE was showing error in the XML because feature modules were independent.
TL;DR: Navigation Components is not designed for a multi-module project IMO.

2- We missed some basic features, like being able to send data back to the previous screen and launch activity with Flag such as FLAG_ACTIVITY_CLEAR_TASK.
We ended up adding special cases in the Navigator for startActivityForResult() , adding launch flags, etc…
At this point, it was a mix of Navigation component and old-style FragmentActivity API.

Only two methods use Navigation Component

3- In order to handle specific cases like startActivityForResult(), the routeParser wasn’t only returning a destination ID, but also the class of the destination (ex: ContactDetailActivity::class.java).
So the routeParser has a dependency on all features modules. This is why all the code related to the navigation is in app module.
This led to a module architecture we were not really satisfied with.

Those issues were causing bugs, sometimes crashes, and were slowing down a lot of the delivery.

Heard in sprint preparation: “From which screen do we have to open this new one? If it’s from screenA it’s gonna be easy, if it’s from screenB it’s gonna take 1 day.”

The navigation in an Android app is not supposed to be hard. We wanted something where the only question we have to ask ourselves is “Where do I need to go?” and not “Where am I?”.

This is why we decided to build a home-made navigation system.

For the sake of simplicity, the term “screen” will refer to “an activity, a fragment or a BottomSheet” because the goal is to navigate within the app, regardless of the implementation details.

The architecture of our new system

In the paragraph above, we saw that the first two problems were linked to Navigation Component specificities: the way we declare destinations in XML, and some features missing.
The third problem is only a dependency issue between modules, so we took this opportunity to fix it as well.

That’s why we basically kept the same concept as the old system, but without Navigation Component and relying only on the low-level methods.

1. A Navigator which contains all the platform-specific methods like startActivity()and fragment transactions. This class is in charge of the actual navigation.

2. A Router that contains all the logic.
Ex:
- Should I start the activity from the current activity context or the fragment context
- keep a track of all screens* in the back stack to dispatch event if needed
- prepare the data if I need to open an external app like camera or emails, etc…

3. A RouteParser in charge of converting a route (as a String) and an optional map of <String,Any> into the Activity, Fragment or BottomSheet that we should start, checking that all required extras are present, and defining transitions for each route.

Pre-requisites

Before changing everything in our app, we did a PoC in a small sample app.

The list of mandatory features was:

  • Start an activity from another activity, a fragment or a push notification (with or without extras), and possibly with a specific fragment already added in it
Open a new activity from a fragment, with a specific transition
  • startActivityForResult()from an activity or a fragment, and get the result where it has been called
  • Replace the fragment in the current activity
Switch between fragments within the same activity
  • Handle transition animation between activities and fragments
  • Handle launch mode (FLAG_ACTIVITY_SINGLE_TOP, FLAG_ACTIVITY_CLEAR_TOP, FLAG_ACTIVITY_CLEAR_TASK or FLAG_ACTIVITY_NEW_TASK) seamlessly
  • Handle data persistency between fragments
  • Finish activity (with or without result)
  • Be compatible with UI elements like ViewPager, TabBar, BottomBar
  • Open a BottomSheet, and navigate between fragments inside a BottomSheet
Open a bottomsheet
Navigate between fragments inside a bottomsheet
  • Deeplinking is a bonus feature, not implemented yet

We wanted our new navigation system to be as testable as possible. Everything is fully tested except for the implementation of the Navigator because it is where we call Android methods like startActivity().

It should be totally independent of our app. The goal was to be able to export it as an external library. No other third-party is required. In the Aircall app, we inject the router with Dagger, but it’s not mandatory.
Only the implementation of the RouteParser is defined within the app because it defines which screen* is associated with each route.

How does it work?

Step 1
Start navigation from your class by calling

It can be any type of class, you just need to have IRouter as a dependency.

Parameters are just a simple string and a map of <String, Any>. There is also a non-mandatory parameter for the LaunchScreenFlags.

LaunchScreenFlags : At this point, we shouldn’t have any reference to an Android constant.
LaunchScreenFlags are not the usual FLAG_ACTIVITY_SINGLE_TOP, FLAG_ACTIVITY_CLEAR_TOP, FLAG_ACTIVITY_CLEAR_TASK or FLAG_ACTIVITY_NEW_TASK, but :
NO_DUPLICATE_ON_TOP:
A-B (launch B) -> A-B
BACK_ON_LAST_INSTANCE_AND_CLEAR_BACKSTACK:
A-B-C (launch B) -> A-B
CLEAR_WHOLE_BACKSTACK:
A-B (launch C) -> C
The goal was to abstract the complexity of the framework.

Step 2
The navigator will call the routeParser.parse(my_route, extras) to determine what this route is.

Step 3
The routeParser can return a ScreenRoute for a Fragment or an Activity, or a ModalRoute for a BottomSheet or a Fragment in a BottomSheet.

Both contain extras as a bundle and transitions. The routeParser is also in charge of the validation of the extras, otherwise, it throws an UnknownDestinationException.

Step 4
The router always has an instance of the current activity, which is automatically binded when it’s created and unbinded when it’s destroyed.
It also has the ID of the fragmentHost if there is one.
What we call fragmentHost is the fragment in the XML layout.
Please read How to use it section to know how it’s done.

A ScreenRoute contains an activityType : Class<out Activity> and a FragmentType: Class<out Fragment>?

  • If the activity in the ScreenRoute is the same as the current one, we don’t open it again.
    If fragmentType is not null, we call the Navigator to create the fragment and replace it in fragmentHost.
  • If it’s not the same, we call the Navigator to prepare the intent and start the activity.
    If fragmentType is not null, we store the resulting fragment in destinationFragment to add it once the new activity is ready.

We also store this route in the local destinationStack and call all navigationChangedListener if there is any.

Step 5
In DefaultNavigator, this is where the actual startActivity()is called.

Other cases

Start activity for result
On Android, if you call activity.startActivityForResult(), the onActivityResult() of the Activity will be called.
If you call fragment.startActivityForResult(), the onActivityResult()of both the parent activity AND the fragment will be called.

The logic we chose is:

  • If there is a visible fragment, identified with fragmentHostId, we call startActivityForResult() from the fragment. The result can be used in the fragment AND in the activity.
  • If not, we call startActivityForResult()from the activity. The result will be used in the activity.

All this logic is directly done in DefaultRouter, you don’t have to think about it when you call router.startActivityForResult() in your app.

Listen to navigation change
You can attach a listener which will be fired when a change in the navigation occurs: a new screen is opened or back action.
It can be useful for example if you have a custom BottomBar and you want to un-highlight the tab previously selected.

Call a method linked to a screen in the back stack
Use case example: My user name is displayed in ProfileActivity and setup in onCreate(). From there I can open an EditProfileModal, and change my name.

If I open a new activity where the name is displayed, it will be my new name. But the current activity ProfileActivity won’t be updated. Same for all the activities in the back stack when I click on the back button because I don’t pass again in onCreate().

The solution here is to use ViewDelegate.

A ViewDelegate can be any type of class that follows the lifecycle of your screen*. In our case, it’s a ViewModel, but it can also be a presenter created and destroyed by your activity.

For the example above, let’s say I have an interface UpdateNameViewDelegate which extends ViewDelegate

and ProfileViewModel, which implements UpdateNameViewDelegate.

And when the new name is saved in EditProfileModal, I can call this line from anywhere router.findViewDelegateByType<UpdateNameViewDelegate> { it.updateName(newName) }

This type of issue can sometimes be solved with startActivityForResult(), but the ViewDelegate pattern allows us to impact all the kind of screen, even if they are six screens back in the back stack.

At Aircall we use WebSockets so data can change at any time, and this pattern is very useful to update UI in this case.

You can find an example of this feature for the ThemeModal in Pokedex sample.

Open an external app

Opening some external apps like email, sharing or even the gallery and camera is as easy as:

(of course, you still need to manage permissions in some cases)

Ask a user action before navigating back
In some cases, when the user clicks on the back button without saving his changes, we need to warn him before actually performing the finish() action.

In the Router, there is a shouldBlockNavigateBack(meetCondition: Boolean, action: () -> Unit) method. You can find the code in the Podekex sample.

Here, if shouldDisplayWarning is true, the router will execute the lambda on the UI thread and prevent the content of navigateBack()to be executed.
Once shouldAllowNagigateBack() is called, navigateBack() can be executed normally.

You need to override onBackPressed() in your Activity for it to work.

How to use it

1st step: include the navigation module in your project. Don’t forget to add it in your settings.gradle and to add the dependency in the build.gradle of your app. You can find it in this Pokedex sample app https://github.com/alizeec/Kotlin-Pokedex.

2nd step: implement your RouteParser. You can find an example in the Pokedex app.

3rd step: create an instance of IRouter and provide it as you want (singleton, dependency injection, etc…)

4th step: All your activities need to call router.bindActivity(this, fragmentHostId) in onCreate() and onRestart().
If you have a fragment in your activity, fragmentHostId is the ID of this fragment. Otherwise, fragmentHostId can be null.

What we did in our app is that we have a BaseActivity calling router.bindActivity(this, getFragmentHostId()) and an open method

@IdRes
open fun getFragmentHostId(): Int? = null

All activities inherit from this and override getFragmentHostId() when needed.

That’s it, you are ready to go! Don’t hesitate if you have any questions.

We use it in production since August 2019, we only had a few issues, such as:
- synchronize a custom bottom bar with the back stack. It’s fixed now.
- we still have very few crashes because of fragment transaction. Maybe a commitAllowingStateLoss() could solve the issue

It considerably increased our velocity.
The next steps are now to add more use cases in the Pokedex sample app, implement deep linking and extract it as an external library.

You can find here a Pokedex sample app showcasing our navigation module in action https://github.com/alizeec/Kotlin-Pokedex 🙂

--

--