Navigating Conductor and the Navigation Architecture Component
By Tevin Jeffrey, Android Engineer
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
.
findNavController(view).navigate(R.id.firstController, someArgs)
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:
- Create a
NavController
that will live inside of our NavHost. - Provide a way for apps to obtain that NavController.
- Implement our own
Navigator
object. This object would in theory just contain a Router to handle navigation and the back stack. - Add our
Navigator
to theNavController
. - Give the NavController a graph.
- Create a
NavController
that lives inside of our NavHost.
The constructor of theNavController
also initializes a NavGraphNavigator and ActivityNavigator which inflate<navigation>
and<activity>
destinations, respectively. 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 withNavigation.findNavController(view)
. This enables us to find theNavController
from anyView
in our hierarchy.createControllerNavigator()
creates our custom Navigator to be able to navigate to<controller>
destinations.- Adds the navigator we created to the NavController.
- 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
:
val router: Router = Conductor.attachRouter(context as Activity, this, null)
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.
fun createControllerNavigator() = ControllerNavigator(router)
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:
<controller
android:id="@+id/firstController"
android:name="com.prolificinteractive.DemoController"/>
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.
<action
android:id="@+id/confirmationAction"
app:destination="@id/confirmationFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" />
Implementation of animations on fragments:
androidx/navigation/fragment/FragmentNavigator.java...
ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim);
Implementation of animations on activities:
androidx/navigation/ActivityNavigator.java ...
mHostActivity.overridePendingTransition(enterAnim, exitAnim);
Moreover, the offerings for adding animations to destinations when using custom Navigators are also limited:
val transaction = RouterTransaction.with(controller)
.pushChangeHandler(AnimatorChangeHandler(navOptions.enterAnim))
.popChangeHandler(AnimatorChangeHandler(navOptions.exitAnim))
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.
router.pushController(RouterTransaction
.with(myController)
.pushChangeHandler(HorizontalChangeHandler())
.popChangeHandler(FadeChangeHandler()))
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.