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 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.
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") }) } ... }
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")) }
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
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) ?: "" ) } }
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.
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.
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.