Navigating Conductor and the Navigation Architecture Component

By Tevin Jeffrey, Android Engineer

Engineering @ Prolific Interactive
Prolific Interactive
10 min readJun 7, 2018

--

The Android Architecture Navigation Component’s documentation has a section that suggests it is possible to add “new destination types” outside of Activities and Fragments. The same was suggested on episode 92 of the Android Developers Backstage podcast and at Google IO 2018. What could they possibly mean? A View-based architecture? There is no shortage of libraries or frameworks utilizing Views as the basis for navigation. Assuming the implementation of the Navigation Architecture Component is generic enough, could it be possible to integrate one of these View-based frameworks with Navigation Component? So far, one has stood above the rest (if Github stars are any indication of success): Conductor.

This article should be accessible to those who have no experience with Conductor, so I’ll try my best to explain some important concepts at a high level. Conductor has a few components: a Controller, which for all intents and purposes is your "View" or "Fragment", and aRouter, which handles navigation and tracks the back stack Controllers. You can think of theRouter as a FragmentManager. A ControllerChangeHandler is responsible for swapping the View for one Controller with the View of another, which at its core is just calling addView and removeView on some ViewGroup. If this is your first introduction to the Navigation Architecture Component, I would recommend starting with the A problem like Navigation series by Maria Neumayer.

What does the world of the Navigation Components look like with Conductor and Controllers instead of Fragments? My minimal navigation graph would look something like this:

Much like you would have with Fragments, I would like a graph that can contain one or more <controller>, each mapping to some Conductor Controller in code via the android:name attribute.

Since both Conductor and the Navigation Component lean towards a single Activity architecture, my ideal activity layout would look something like this:

There is a single fullscreen View that has an attribute which references my navigation graph. This doubles as the View which will use the swap Controllers in and out. Again, this is remarkably similar to the setup of a Fragment-based approach.

For navigation, I would simply use the Navigation Component’s own NavController instead of Conductor's Router.

Now that we have a high-level overview of the interface we want to interact with Conductor through, we can get to implementation. I would like to note the implementation details below are from a naive perspective, ignoring sad paths and exceptions.

Inflating the NavHost

Navigation begins as soon as our Activity has been launched. This is true for any application and is especially true for the Navigation Architecture Component. The navigation graph contains crucial information about where to first navigate and the Navigation Architecture Component encodes some of these navigation decisions into the navigation resource file in res/navigation. The navigation resource file needs to be transformed into code and the first step is inflation.

Let’s start with implementing our custom view, NavHostLayout. We defined the navigation graph resource in app:navGraph so let's grab that first during inflation.

We have the resource id of the graph, what’s next? Let’s look at the documentation one more time for some clues. It says:

To be able to navigate to any other type of destination, one or more additional Navigator objects must be added to the NavController. For example, when using fragments as destinations, the NavHostFragment automatically adds the FragmentNavigator class to its NavController.

What are Navigators and NavControllers?

Navigator

Navigator defines a mechanism for navigating within an app.

Navigators should be able to manage their own back stack when navigating between two destinations that belong to that navigator.

Sounds suspiciously like Conductor’s Router’s responsibilities.

NavController

NavController manages app navigation within a NavHost.

Apps will generally obtain a controller directly from a NavHost, or by using one of the utility methods on the Navigation class rather than create a controller directly.

Navigation flows and destinations are determined by the navigation graph owned by the controller.

Breaking it down, it looks like we’ll need to:

  1. Create a NavController that will live inside of our NavHost.
  2. Provide a way for apps to obtain that NavController.
  3. Implement our own Navigator object. This object would in theory just contain a Router to handle navigation and the back stack.
  4. Add our Navigator to the NavController.
  5. Give the NavController a graph.
  1. Create a NavController that lives inside of our NavHost.
    The constructor of the NavController also initializes a NavGraphNavigator and ActivityNavigator which inflate <navigation> and <activity> destinations, respectively.
  2. Navigation.setViewNavController is the utility method in the Navigation Component which sets our NavController in the View's tag so that we can find it later with Navigation.findNavController(view). This enables us to find the NavController from any View in our hierarchy.
  3. createControllerNavigator() creates our custom Navigator to be able to navigate to <controller> destinations.
  4. Adds the navigator we created to the NavController.
  5. Sets the navigation resource id onto the navigation graph.

The NavHost is an interface with one method getNavController() returning, you guessed it, a NavController. So let’s also implement that.

Now let’s circle back to createControllerNavigator(). What are we creating? If you recall from earlier, the Navigator has some responsibilities which closely match those of Conductor's Router. We should be able to compose the two by passing an instance of the Router to our Navigator object. But first, we need an instance of a Router:

Conductor has a static method for creating a Router. Strangely, it needs a reference to the activity for some internal lifecycle management, but that’s an implementation detail for the library itself. It also needs a ViewGroup, the container that addView and removeView will be called on. The last param is a Bundle for state restoration, but let's worry about that later. Now that we have our Router, create our Navigator with it.

The Navigator

Our bare bonesControllerNavigator starts to look like this after we extend Navigator<NavDestination> .

What’s a @Navigator.Name?! Simply put, this identifies the name of our Navigator when registering it with the NavigatorProvider. NavigatorProvider is an object that holds all the Navigators for destinations, whether it be <activity>, <fragment> or our newly registered <controller>.

What’s a NavDestination?! I swear this is the last class you should know about. It turns out that, in addition to a Navigator, you also need a NavDestination. A NavDestination represents one node within an overall navigation graph. Each destination is associated with a Navigator that knows how to navigate to this particular destination.

Inflating Controllers

So it looks like this is where we get all the information about our custom XML element, <controller>. Taking a peek at other implementations, ActivityNavigator.Destination constructs an Intent from android:name, action, data , dataPattern attributes. Similarly, FragmentNavigator.Destination creates Fragment instances from data in the android:name attribute. Given the similarities of Fragments and Controllers thus far, it follows that our ControllerNavigator.Destination will also create Controller objects from the android:name attribute. Each destination is associated with a Navigator which knows how to navigate to this particular destination. Since we're using Conductor, every destination is a Controller. Our implementation of a NavDestination would look like this:

NavDestination is an abstract representation of some destination in your app when navigating. These destinations have to be created, whether it be an Activity, Fragment, Controller or even another navigation graph.
<activity> destinations are created via intents with the actual instantiation of the Activity being delegated to the framework. <fragment> are Fragment destinations created through reflection.

Following the fragment’s example, we too can create our Controller instances via reflection. Conductor Controllers require a constructor that is either empty or takes a Bundle as the only argument, presumably as a mechanism to pass or restore instance data to new Controllers. This aligns nicely with NavController#navigate(int resId, Bundle args) use of Bundle as the primary means of passing data while navigating to destinations.

Let’s remind ourselves of what needs to be parsed:

Observant readers will notice that there is a problem here. Remember, the name of the destination is the name of the class to instantiate. The name is then used to load the Class via reflection. On release builds, Proguard typically obfuscates class names we haven’t explicitly kept with -keepnames. We'd be attempting to instantiate a class named com.prolificinteractive.DemoController which in all likelihood will be renamed to something like a.b.a. Fragments are not immune to this obfuscation issue. There is some good news: the folks on the Navigation Architecture Components team are aware of the issue and it has the highest priority (P0) on the issue tracker. The current official recommendation is to keep the names of your Fragments, or in our case, Controllers.

Who knows what they’ll come up with to solve this issue. I personally hope R8 will gain the ability to process both Java bytecode and Android resources in order to make more informed decisions while obfuscating. Unfortunately, this would leave Proguard users in the cold. Maybe they’ll sprinkle some factory pattern in the library to delegate inflation — we’ll see.

To recap, the parser encounters a <controller> element, calls NavigatorProvider#getNavigator("controller") to find the ControllerNavigator we previously set in NavHostLayout#init(). It then creates the destination with navigator.createDestination(), then calls onInflate on our ControllerNavigator.Destination. The android:id and android:label were automatically parsed and stored in the NavDestination super class. For brevity, I'm not touching on the parsing of any potential <deeplink>, <argument>, <action> or nested <include> elements you may have between your <controller> </controller> elements.

At the end of the NavController#setGraph call, the NavController has an instance of a NavGraph, a collection of NavDestination instances parsed from the navigation resource or added at runtime. Let's start navigating!

Navigating

Earlier I mentioned that creating a new NavController also initializes a NavGraphNavigator and ActivityNavigator which inflate <navigation> and <activity> destinations, respectively. What's a <navigation> tag really? The elements <activity>, <fragment> and <controller> are known to have implementations in ActivityNavigator.Destination, FragmentNavigator.Destination and our very own ControllerNavigation.Destination, respectively. Does that mean <navigation> also represents some NavDestination that is created by a NavGraphNavigator? Yes! With <navigation> as the root of the graph, we have to know where to go when the app starts. Funnily enough, the implementation of NavDestination associated with NavGraphNavigator is a NavGraph, the very same object created after calling NavController#setGraph. Handily, the NavGraph parses the app:startDestination to define the resource id of the first Controller to navigate to.

Sparing some details, after the NavGraph is created, the NavController then triggers NavGraph#navigate on the root NavGraph destination, which in turn searches a collection of NavDestinations for the matching app:startDestination.

Ignoring the sad paths, the code looks something like this:

In the NavGraphNavigator, the starting destination resource id is retrieved from the root NavGraph. This resolves to @id/firstController , the value of our app:startDestination. Then a lookup is performed on the collection of NavDestination in the NavGraph to find a NavDestination with a getId() matching @id/firstController. When the instance of ControllerNavigation.Destination is found, the NavGraphNavigator delegates navigation to our destination. Our ControllerNavigation.Destination itself does not know how to navigate, but the ControllerNavigation we previously passed to its super class does. With just one more hop, our ControllerNavigator#navigate is called.

With that, let’s revisit the implementation of ControllerNavigator#navigate and ControllerNavigator#popBackStack methods.

This is the bare minimum we need in order to build our own custom Conductor Navigation Component integration. Whenever findNavController(view).navigate(R.id.someController) is called, the associated Controller is inflated and pushed into the Router.

Back Stack

The NavController itself maintains a back stack of NavDestinations which we are responsible for keeping up to date with calls to dispatchOnNavigatorNavigated(@IdRes int destId, @BackStackEffect int backStackEffect) in the ControllerNavigator. @BackStackEffect has three possible values:

BACK_STACK_UNCHANGED: the navigation event should not change the NavController's back stack.

BACK_STACK_DESTINATION_ADDED: the navigation event has added a new entry to the back stack.

BACK_STACK_DESTINATION_POPPED: the navigation event has popped an entry off the back stack.

It is odd that there’s a need for two separate back stacks. One is maintained by the Router, in the case of Fragments, the FragmentManager, and another in the NavController. From what I can gather from the source code, the NavController’s back stack informs when, how often, and what navigation decisions need to be made when developers call popBackStack, navigateUp and navigate on a NavController. These three methods invariably result in calls to popBackStack or navigate on the Navigator. For instance, findNavController().popBackStack(R.id.root) would result in any number of calls to ControllerNavigator#popBackStack() in order to return to the NavDestination associated with R.id.root. Syncing these two back stacks can be tricky, but Conductor does provide a way to listen to navigation events on theRouter .

State Saving/Restoration

The NavHostLayout also needs to manage state saving and restoration for the NavController and Router. Depending on how we implement it, this will allow us to restore the navigation state across configuration changes and process death.

Limitations

The Navigation Component is in alpha and does not cover every possible navigation use case, nor does it attempt to do so. So far we have implemented basic pushing and popping navigation behavior with little thought about how to further customize our navigation.

One such customization is animations. The Navigation Component provides very little in the way of customizing animations between Fragments. Currently, animations can be defined for Activity and Fragment with R.anim.* or R.animator.* resources, but there isn’t a way to define shared element transitions between destinations.

Implementation of animations on fragments:

Implementation of animations on activities:

Moreover, the offerings for adding animations to destinations when using custom Navigators are also limited:

Navigation Component offers options for making additional data available during navigation via Bundle and NavOptions. You can pass a Bundle with arbitrary metadata on every call to NavController#navigate(), then pull that data out in Navigtor#navigate(). For example, pass the class of your custom ChangeHandler or Transition into the Bundle, then instantiate them in Navigator#navigate(int, Bundle, NavOptions). There are too many issues with this approach to seriously consider it. This makes the NavOptions object less useful than a Bundle because there is no way to pass arbitrary data to it. It contains some options for setting the launch mode and enter/exit R.anim resources, but that's it.

There’s little benefit to using Conductor with the Navigation Component. It is a wrapper around Conductor’s Router which significantly reduces the API surface and consequently, the functionality of the Router.

As it stands, it is almost impossible to cleanly and consistently replicate this interaction in the Navigation Component without resorting to using reflection and it will no doubt require architectural changes to the Navigation Component or even your navigation library.

Conclusion

The implementation of the Conductor Navigation Component can be found on Github here. It is not intended to be used anywhere near production code as the underlying library itself is in alpha. I am a fan of the Go community’s experience reports; it is a proposal process where the Go maintainers ask:

(1) what you wanted to do, (2) what you actually did, and (3) why that wasn’t great, illustrating those by real concrete examples, ideally from production use.

It is my hope that the Android community will submit similar reports to the issue tracker. The Navigation Architecture Component is very clearly still in alpha and will require the input and support of Android developers to evolve into something we would all love to add to our apps.

--

--

Engineering @ Prolific Interactive
Prolific Interactive

Our team partners with leading brands to create incredible mobile products. Here, we share ideas about engineering, mobile, and tech culture.