Using the Navigation Component in Jetpack Compose

Jetpack Compose is a declarative framework for building native Android UI recommended by Google. To simplify and accelerate UI development, the framework turns the traditional model of Android UI development on its head. Rather than constructing UI by imperatively controlling views defined in XML, UI is built in Jetpack Compose by composing functions that define how app data is transformed into UI.

An app built entirely in Compose may consist of a single Activity that hosts a composition, meaning that the fragment-based Navigation Architectural Components can no longer be used directly in such an application. Fortunately, navigation-compose provides a compatibility layer for interacting with the Navigation Component from Compose.

The Navigation Component

The androidx.navigation:navigation-compose dependency provides an API for Compose apps to interact with the Navigation Component, taking advantage of its familiar features, including handling up and back navigation and deep links.

The Navigation Component consists of three parts:NavController,NavHost, and the navigation graph.

The NavController is the class through which the Navigation Component is accessed. It is used to navigate between destinations and maintains each destination’s state and the back stack’s state. An instance of the NavController is obtained through the rememberNavController() method as shown:

val navController = rememberNavController()

The NavHost, as the name indicates, serves as a host or container for the current navigation destination. The NavHost also links the NavController with the navigation graph (described below). Creating a NavHost requires an instance of NavController, obtained through rememberNavController() as described above, and a String representing the route associated with the starting point of navigation.

NavHost(navController = navController, startDestination = "home") {
   ...
}

In the fragment-based manifestation of the Navigation Component, the navigation graph consists of an XML resource that describes all destinations and possible navigation paths throughout the app. In Compose, the navigation graph is built using the lambda syntax from the Navigation Kotlin DSL instead of XML. The navigation graph is constructed in the trailing lambda passed to NavHost as shown below:

NavHost(navController = navController, startDestination = "home") {
   composable("home") { MealsListScreen() }
   composable("details") { MealDetailsScreen() }
}

In this example, the MealsListScreen() composable is associated with the route defined by the String “home,” and the MealDetailsScreen() composable is associated with the “details” route. The startDestination is set to “home,” meaning that the MealsListScreen() composable will be displayed when the app launches.

Note that in the example above, the lambda is passed to the builder parameter of the NavHost function, which has a receiver type of NavGraphBuilder. This allows for the concise syntax for providing composable destinations to the navigation graph through NavGraphBuilder.composable().

The NavGraphBuilder.composable() method has a required route parameter that is a String representing each unique destination on the navigation graph. The composable associated with the destination route is passed to the content parameter using trailing lambda syntax.

Navigating to a Destination

The navigate method of NavController is used to navigate to a destination:

navController.navigate("details")

While it may be tempting to pass the NavController instance down to composables that will trigger navigation, it is best practice not to do so. Centralizing your app’s navigation code in one place makes it easier to understand and maintain. Furthermore, individual composables may appear or behave differently on different screen sizes. For example, a button may result in navigation to a new screen on a phone but not on tablets. Therefore it is best practice to pass functions down to composables for navigation-related events that can be handled in the composable that hosts the NavController.

For example, imagine MealsListScreen takes an onItemClick: () -> Unit parameter. You could then handle that event in the composable that contains NavHost as follows:

NavHost(navController = navController, startDestination = "home") {
   composable("home") {
      MealsListScreen(onItemClick = { navController.navigate("details") })
   }
...
}

Navigation Arguments

Arguments can be passed to a navigation destination by including argument placeholders within the route. If you wanted to extend the example above and pass a string representing an id for the details screen, you would first add a placeholder to the route:

NavHost(navController = navController, startDestination = "home") {
   ...
   composable("details/{mealId}") { MealDetailsScreen(...) }
}

Then you would add an argument to composable, specifying its name and type:

composable(
   "details/{mealId}",
   arguments = listOf(navArgument("mealId") { type = NavType.StringType })
) { backStackEntry ->
   MealDetailsScreen(...)
}

Then, you would need to update calls that navigate to the destination by passing the id as part of the route:

navController.navigate("details/1234")

Finally, you would retrieve the argument from the NavBackStackEntry that is available within the content parameter of composable():

composable(
 "details/{mealId}",
 arguments = listOf(navArgument("mealId") { type = NavType.StringType })
) { backStackEntry ->
 MealDetailsScreen(mealId = backStackEntry.arguments?.getString("mealId"))
}

Deep Links

One of the key benefits of using the Navigation Component is the automatic handling of deep links. Because routes are defined as strings that mimic URIs by convention, they can be built to correspond to the same patterns used for deep links into your app. Carrying forward with the example above and assuming that it is associated with a fictitious web property at https://bignerdranch.com/cookbook you would first add the following intent filter to AndroidManifest.xml to enable the app to receive the appropriate deep links:

<intent-filter>
   <action android:name="android.intent.action.VIEW" />
   <category android:name="android.intent.category.DEFAULT" />
   <category android:name="android.intent.category.BROWSABLE" />
   <data
      android:host="bignerdranch.com"
      android:pathPrefix="/cookbook"
      android:scheme="https" />
</intent-filter>

Then you would update your composable destination to handle deep links of the pattern https://bignerdranch.com/cookbook/{mealId} by passing a value to the deepLinks parameter as shown:

composable(
   "details/{mealId}",
   arguments = listOf(navArgument("mealId") { type = NavType.StringType }),
   deepLinks = listOf(navDeepLink { uriPattern = "https://bignerdranch.com/cookbook/{mealId}" })
) { backStackEntry ->
 MealDetailsScreen(mealId = backStackEntry.arguments?.getString("mealId"))
}

These deep links could be tested using an ADB command such as:

adb shell am start -d https://bignerdranch.com/cookbook/1234

A Note on Best Practices

In the above demonstrations, string literals were used to define routes and navigation argument names for clarity and simplicity. It is best practice to store these strings as constants or in some other construct to reduce repetition and prevent typo-based bugs. A cleaner implementation of the above example might look like this:

 

interface Destination {
   val route: String
   val title: Int
}

object Home : Destination {
   override val route: String = "home"
   override val title: Int = R.string.app_name
}

object Details: Destination {
   override val route: String = "details"
   override val title: Int = R.string.meal_details
   const val mealIdArg = "mealId"
   val routeWithArg: String = "$route/{$mealIdArg}"
   val arguments = listOf(navArgument(mealIdArg) { type = NavType.StringType })
   fun getNavigationRouteToMeal(mealId: String) = "$route/$mealId"
}

...

NavHost(
   navController = navController,
   startDestination = Home.route
) {
   composable(Home.route) {
      MealsListScreen(onItemClick = {
        navController.navigate(Details.getNavigationRouteToMeal(it))
     })
}

   composable(
      Details.routeWithArg,
      arguments = Details.arguments
) { backStackEntry ->
    MealDetailsScreen(
      mealId = backStackEntry.arguments?.getString(Details.mealIdArg) ?: ""
    )
  }
}

Drawbacks

Lack of argument type safety

The primary drawback is the lack of type safety for passing arguments. While this may not seem like a big deal if you are following the best practice of not passing complex data in navigation arguments, it would still be preferable to have compile-time assurance, even for simple types.

Repetitive and cumbersome API for passing arguments

In addition to the lack of type safety, the API for defining argument types and parsing them from the BackStackEntry is fairly repetitive and cumbersome. It involves a fair amount of potentially tricky string concatenation to build routes.

No navigation editor

Many developers have grown to enjoy using the Navigation Editor to get a visual representation of the navigation graph for their apps and to quickly and easily define navigation actions. There is no comparable tool for Compose.

Alternatives

Use Fragments to host Compose

Perhaps the most straightforward alternative, especially if you’re already accustomed to the fragment-based Navigation component, would be to use Fragments to host each screen-level composable. This would carry the benefit of type-safe navigation arguments and access to the Navigation Editor.

Third-party alternatives

As a result of the drawbacks above, several third-party tools, such as Compose Destinations and Voyager have been developed. For a detailed overview and comparison of these alternatives, we recommend this article.

The post Using the Navigation Component in Jetpack Compose appeared first on Big Nerd Ranch.