Avoiding Conditional Navigation Pitfalls When Implementing the Android Navigation Library

Avoiding Conditional Navigation Pitfalls When Implementing the Android Navigation Library

Navigation between mobile application screens is a core part of the user experience. Without a framework in place, building smooth and predictable navigation takes a lot of time and effort. While rewriting the DoorDash Android Consumer app, we decided not to reinvent the wheel and instead used the Navigation library from Android Jetpack to streamline our app’s complex navigation. 

However, integrating the Navigation library can be tricky if you have strict functional requirements. DoorDash had this exact problem and had to work around some constraints in the Android Navigation library’s conditional navigation. In this article we will discuss issues around integrating the Android Navigation library, our approach to solve the problem, and how we ended up fixing it. 

Navigation library integration problems 

The Android Navigation library (ANL) suggests handling conditional navigation at the final destination. A destination is the screen that opens when we navigate to it. At DoorDash, our app’s destinations should only be accessible by authorized users, customers who have created an account. If we apply the ANL recommended approach, an unauthorized user will first be directed to a homepage screen, then to a login screen if they attempt to interact with the homepage screen, as shown in Figure 1, below:

Using the Android Navigation library’s recommended conditional navigation, users without an account or who are not logged in are redirected to the login page whey they try to interact with the homepage.
Figure 1: Using the Android Navigation library’s recommended conditional navigation, users without an account or who are not logged in are redirected to the login page whey they try to interact with the homepage.

For our purposes, this navigation requirement is not ideal because:

  • The homepage screen should not be created at all if users are not authorized. From the user’s perspective, seeing the homepage and then being redirected to login is not a friendly user experience. It also wastes network bandwidth by downloading the data to populate the homepage that users might never see if they don’t login. 
  • If there is deep linking into the app, the condition checking logic spreads to multiple places. For example, if users enter the app with a deep link that takes them to a store screen, this screen will also check authentication status, as shown in Figure 2, below. Spreading the condition checking logic in this manner violates the “single-responsibility” principle and makes our code more fragile. 
  • There is no way to wait for dependency initialization when the app starts. Dependencies, such as an experimentation framework, are initialized while the user is shown the splash screen. Because the navigational requirement doesn’t allow waiting for dependency initializations completion, we potentially expose the user to inconsistent experiences before and after logging in.
In the ANL recommended flow, users accessing the app through a deep link, such as going directly to a store screen from a web site link, will activate the authorization logic and be sent to a login screen.
Figure 2: In the ANL recommended flow, users accessing the app through a deep link, such as going directly to a store screen from a web site link, will activate the authorization logic and be sent to a login screen.

The first two issues are inconvenient, but not a deal breaker. Extra objects can be easily collected by the garbage collector and spreaded logic can be encapsulated in a base class. However, our third issue becomes more important because we only want to let users see app screens if the app dependencies are initialized and the app is ready to work. It is crucial for us to kick off the data downloading process and wait for its completion while the user is on the app splash screen, and therefore we had to figure out if there was a way to do this within the ANL.

Researching the conditional navigation problem  

To figure out how to postpone the navigation from happening on app start we needed to take a deeper look at how the ANL is designed and when it executes the navigation. We found that there is no way to postpone the navigation within the ANL.

Before we explain how we got to this conclusion let’s first introduce some terms:

  • Destination: A class describes a screen, which opens when we navigate to it. It can be a Fragment, Activity, or DialogFragment.
  • NavGraph: The NavGraph is an  XML resource which also holds other navigation-related information.
  • NavHost: This view-container displays destinations from the navigation graph when users move throughout the app. 
  • NavController: This object manages app navigation. NavController is set within a NavHost. If we are dealing with NavHostFragment (the default NavHost implementation), then the logic of creating and accessing NavController already exists.

Now that we have defined some terms, let’s discuss how navigation is executed at app start and why it cannot be altered. Here is a breakdown of how Android navigation works: 

  • The ANL requires us to define our Destinations in the NavGraph. Each NavGraph, defined in the application, is instantiated into an object with a concrete set of defined Destinations. 
  • The instantiation of the main NavGraph happens when this graph is set in a NavHost.
  • And as soon as the NavGraph is set within a NavHost, NavController takes users to a start destination (navigate(mGraph, startDestinationArgs, null, null)) or a destination defined in a deep link (handleDeepLink), which you can see in the following code sample:

 

NavController.kt
private void onGraphCreated(@Nullable Bundle startDestinationArgs) {
// ...
    if (mGraph != null && mBackStack.isEmpty()) {
        boolean deepLinked = !mDeepLinkHandled && mActivity != null
           && handleDeepLink(mActivity.getIntent());
        if (!deepLinked) {
           // Navigate to the first destination in the graph
           // if we haven't deep linked to a destination
          navigate(mGraph, startDestinationArgs, null, null);
       }
    }
}
/**
* Checks the given Intent for a Navigation deep link and navigates to the deep link
* if present.
*/
public boolean handleDeepLink(@Nullable Intent intent) {...}

NavGraph is attached to a NavHost in the Activity’s onCreate lifecycle method, since this is the place where we inflate the Activity’s UI. This process makes the navigation finish before we exit the onCreate method of our Activity. We usually use the Activity’s onResume lifecycle method to kick off the data downloading process so that applications don’t have a chance to wait for anything to complete before navigation happens. Given that the ANL does not let us execute anything before the navigation is called, we needed to find another solution. 

Postponing navigation

While we can tolerate extra screen creation and logic spreading, the inability to wait for dependency initialization on app start became a real obstacle to integrating the ANL into the app. As a result, we looked at the following solutions:

  • Replacing NavGraph at app start
  • Replacing start destination at app start
  • Not using the ANL at app start

Replacing NavGraph at app start

This solution only required a few lines of code, but it broke our back navigation and resulted in having an extra screen at the back stack. Replacing NavGraph only for users that are not logged in at startup led to saving the start destination of the login graph on the back stack and the inability to later pop it out. So when users press back on our main screen it doesn’t exit the app, but instead it shows the login/splash screen. 

Replacing start destination at app start

Another possible solution we looked into was to replace the start destination at app start. Unfortunately, ANL does not provide a way to replace a specified start destination in the NavGraph. This option was removed from the framework in favor of supporting a fixed start destination principle.  (See the discussion here.) However, we can replace a start destination for a NavGraph that is not yet attached to a HavHost as shown on the code snippet below:


override fun onCreate(savedInstanceState: Bundle?) {
...
// Find the nav fragment
val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host) as    NavHostFragment
// Get/set the controller and inflater
navController = navHostFragment.navController
val inflater = navController.navInflater
// Manually inflate the graph and set the start destination
val navGraph = inflater.inflate(R.navigation.store_page_navigation)
if(conditions) {
    navGraph.setDefaultArguments(intent.extras)
    navGraph.startDestination = R.id.fragment_login
} else {
    navGraph.setDefaultArguments(intent.extras)
    navGraph.startDestination = R.id.fragment_home
}
// Set the manually created graph and args
navController.setGraph(navGraph, args.toBundle())
}

This approach also resulted in saving the login/splash screen in the back stack and led to the same problem with back press as above. 

Not using the ANL at app start

The solution we found was to extract the start application flow into its own task and initialize the ANL later. Our solution ensures that, every time the application is launched, we make sure essential dependencies are initialized successfully and users are authorized. 
When checks pass we finish that task and let users proceed to the next screen. We intentionally did not use navigation components in any of the screens belonging to the app start flow, launching the ANL only when the app start task finishes. In cases where the app is launched via a deep link, we pass all intent arguments to our home screen, which uses the ANL, as shown in the following code:

fun startNewTask(@NonNull context: Context, deeplinkBundle: Bundle? = null) {
   val intent = Intent(context, HomePageActivity::class.java)
       .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
       .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

   deeplinkBundle?.apply{
       val deepLink = this.getIntArray("android-support-nav:controller:deepLinkIds")
       deepLink?.let { intent.putExtra("android-support-nav:controller:deepLinkIds",       deepLink) }
       val deepLinkExtras = this.getBundle("android-support-nav:controller:deepLinkExtras")
       deepLinkExtras?.let { intent.putExtra("android-support-nav:controller:deepLinkExtras", deepLinkExtras) }
   }
   context.startActivity(intent)
   finish()
}

With this solution, we always show the login when needed, deep links work whether users are logged in or not, and the back button works as expected.

Conclusion

Our development of this solution for the DoorDash Consumer app showed that the complexity of our app required more granular control of its start flow. We needed to determine when the app initialized dependencies and make sure that the data it showed users existed and was fresh. The ANL is still incredibly useful for solving navigation within the app, but we needed to use it appropriately. 

Engineers wanting to implement a similar solution in their own apps will need to define a login flow in a separate task and pass Intent extras to a starting point for the ANL to support deep links. 

Maria is a Software Engineer works primarily Android applications. She's been at DoorDash since 2018 and has been actively developing mobile applications since 2008.