Jetpack Compose Destinations
In this tutorial, you’ll learn how to implement an effective navigation pattern with Jetpack Compose, in a way that will work with different screen sizes, from phones to tablets. By Roberto Orgiu.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Jetpack Compose Destinations
20 mins
Compose is in its early stages and navigation is one of the most complex topics.
With Compose Destinations you have a central API for navigating between screens and taking care of the back stack. In this tutorial, you’ll use Compose Destinations to implement navigation in an app called Landscapes. Say hello to Jetpack Compose Destinations!
Here’s a summary of the various lessons you’ll learn from this tutorial:
- How to implement routes and destinations
- How to implement navigation using the Bottom Navigation UI pattern
- Animate when navigating between different screens
- Handle layouts based on screen dimensions
Getting Started
Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.
You’re going to build an app that will show pictures of several cities all over the world in a classic list/detail pattern. You’ll also implement the navigation for a Settings screen, just to make things a bit more challenging.
On a phone, you’ll put navigation at the bottom to let the user switch between the list of the cities and the settings. Tapping any of the navigation items will bring the user to the related screen, and tapping an item in the list of cities will hide the bottom navigation bar and show some pictures related to each city. Tapping the back button will bring the user back to the list of cities.
On a tablet, though, the navigation will be different. You’ll implement the list/detail pattern by putting the two screens side by side, with a lateral navigation rail to switch from this content to the settings.
Import the project into Android Studio. Once it finishes downloading all the dependencies, build and run. You’ll see… nothing! An empty screen will welcome you, and it’s time to start.
Setting Up the Project
Open build.gradle of your app module, and you’ll find this dependency:
implementation("androidx.navigation:navigation-compose:2.4.2")
This is the dependency you’ll need to implement the navigation. In this project, you won’t need to declare it and you won’t need to poke around the screens. You can if you want to, but in this tutorial you’ll focus on the navigation.
Now that you have all necessary dependencies, you can start coding!
Adding the First Destination
Open CompatUi.kt. In CompatUi()
, look for items
. Notice that the value is a list of screens available for your users. You’ll start by showing the list of the cities.
Add this code inside the Scaffold()
at // TODO: Define NavHost here
:
NavHost(
navController = navController,
startDestination = Screen.List.path,
modifier = Modifier.padding(innerPadding)
) {
composable(Screen.List.path) {
CityListUi(viewModel = viewModel,
onCitySelected = { /* Replace here */ })
}
}
With this code, you create a NavHost
— the shell that contains all your destinations. You instruct it to show a specific startDestination
, and you define how the destination should display by adding the right Composable
to it.
Now, open MainUi.kt, and add this line of code right above the theme
declaration:
val navController = rememberNavController()
You’re asking Jetpack Compose Navigation Library to give you the NavController
— you’ll need it to create the NavHost
.
Now, add this line at the end of // TODO: Add CompatUi() here
:
CompatUi(navController = navController, viewModel = viewModel, themeStore = themeStore)
This line simply loads the UI you just created when the app starts. You’re using navController
to present a screen — viewModel
contains logic for the screen parts and themeStore
defines an appropriate theme.
Build and run. Notice the app shows the list of cities. Scroll down to see all cities you used.
You’re done with the first step! You just added your first destination. Next, you’ll add a couple more destinations, plus a way to reach them.
Navigating to the Detail screen
In this section, you’ll add the city detail and the settings screen. Don’t worry — the screens are ready for you to use in your code. All you have to do is link them so that when a user taps a city, it opens a screen that shows more pictures of that city.
In CompatUi.kt, find the /* Replace here */
comment and replace it with the click behavior:
navController.navigate("detail/${it.name}")
This line instructs the NavController
where to go when the user taps a city. In this case, the app will navigate to a screen with details of a tapped city.
Now, you need to add more destinations! Below the lambda of composable(Screen.List.path)
, add the following code:
// 1
composable(route = "detail/{city}") { backstackEntry ->
val cityName = backstackEntry.arguments?.getString("city") ?: error("City is required")
val city = viewModel.cities.first { it.name == cityName }
CityDetailUi(viewModel = viewModel, city = city, isBigLayout = false)
}
// 2
composable(route = Screen.Settings.path) {
SettingsUi(themeStore = themeStore)
}
There are two different parts:
- You define a destination that users reach by tapping a city using
composable()
. Here, you get thebackstackEntry
, from which you can extract all destination arguments (in this case, the name of the city). Take a look at the route definition, and you’ll noticecity
appearing inside curly braces. This is how Jetpack Compose Navigation indicates the variable data (arguments) you can pass to the different routes. Once you have the argument, you can pass it toCityDetailUi()
. - You specify the settings screen by passing
themeStore
. You can’t reach that screen yet, but soon you’ll implement all you need to be able to see it.
Build and run the app. When it starts, tap a city to see its details:
Implementing Bottom Navigation
As you’re fully aware, you can’t reach the settings screen just yet. But this changes right now!
The next step is to add a BottomNavigation
that interacts with your shiny NavHost
and lets you reach the settings screen as well.
In order to do it, continue working in CompatUi.kt, but this time inside Scaffold()
.
At // TODO: Define bottomBar here
, paste the following code:
bottomBar = {
BottomNavigation(backgroundColor = MaterialTheme.colors.primary) {
// 1
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
// 2
items.forEach { screen ->
BottomNavigationItem(
// 3
icon = {
Icon(
imageVector = screen.icon,
contentDescription = screen.name
)
},
label = { Text(text = screen.name) },
// 4
selected = currentDestination?.hierarchy?.any { it.route == screen.path } == true,
// 5
onClick = {
navController.navigate(screen.path) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
})
}
}
}
Here’s what the code above does:
-
Since the
BottomNavigation
has more than one destination stored inside a stack, you need to know where you are so that you can react accordingly. With this line, you’re getting the exact place where you are in the stack. -
You cycle through the different destinations available for the
BottomNavigation
, and you create aBottomNavigationItem
for each of them. -
You assign an icon and label for each
BottomNavigationItem
. - This one is particularly interesting: While looping through all the destinations, you check whether the current destination is presented in the app. When you run into the presented one, you mark it as selected.
-
Whenever the user taps on the item, you instruct the
NavController
to reach a specific destination in the stack.
Build and run. You can now reach the settings screen by simply tapping on the Settings tab in the bottom navigation bar!
Animating All the Things
You might have noticed that if you tap again on a city, the bottom navigation bar does not disappear — which doesn’t really look that good, does it?
To fix this, you’ll add an animation to show or hide the bottom navigation bar where appropriate.
In CompatUi.kt, add this line after // TODO: Define bottomBarVisibility here
:
val bottomBarVisibility = rememberSaveable { (mutableStateOf(true)) }
This controls BottomNavigation
‘s visibility. It can show and hide the navigation based on the current screen.
Next, you need to change the visibility of the bar in each destination.
Add this code in two places: above CityListUi()
and SettingsUi()
:
LaunchedEffect(null) {
bottomBarVisibility.value = true
}
With this code, you show BottomNavigation
once the app presents the CityList screen and the Settings screen.
Now, you need to hide BottomNavigation
when presenting the city details screen. Above CityDetailUi()
, place this code:
LaunchedEffect(null) {
bottomBarVisibility.value = false
}
This code hides the bottom navigation bar.
Next, you need to change how the BottomNavigation
behaves so that it respects the visibility flag you just set up. While you’re at it, you can also animate it as it shows and hides — it’s a nice touch!
In Scaffold()
, wrap your BottomNavigation
declaration inside an AnimatedVisibility
so that it can react according to the flag. Update bottomBar
with this:
bottomBar = {
AnimatedVisibility(
// 1
visible = bottomBarVisibility.value,
// 2
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
// 3
content = {
BottomNavigation(backgroundColor = MaterialTheme.colors.primary) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
BottomNavigationItem(
icon = {
Icon(
imageVector = screen.icon,
contentDescription = screen.name
)
},
label = { Text(text = screen.name) },
selected = currentDestination?.hierarchy?.any { it.route == screen.path } == true,
onClick = {
navController.navigate(screen.path) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
})
}
}
})
}
AnimatedVisibility
takes four parameters. Here’s what’s happening:
-
You use
visibility
to link the flag that you changed inside all threecomposable()
s. -
You use
enter
andexit
to regulate the enter and exit animation. -
Finally, you need the exact content that’s being animated. In this case, you use the
BottomNavigation
you created previously.
Build and run, then tap a city. You’ll see the BottomNavigation
animate in and out like this:
Refactoring for Tablets
Tablets and smartphones have pretty different kinds of space available on their screens, so you might want to change your UI from one to the other. For example, while on a phone, you could have a list and a detail on separate screens, they could be combined for a bigger tablet screen.
Another example is navigation itself. For a vertical format, you might prefer a BottomNavigation
, but with a bigger and more horizontal space you could switch to another pattern for the navigation.
Of course, you also need to know when to display each UI.
Handling Different Screen Sizes
Understanding when to display a specific UI is no easy task — and you need to find breakpoints that allow you to distinguish a phone from a tablet with reasonable certainty. Luckily, Google’s Jetpack Compose official samples have such logic in the form of the WindowSize
class. It’s included in the sample project for simplicity.
Now, you’re going to see how to use the WindowSize
class inside your app. Open MainActivity.kt and find setContent()
. In its lambda, you’ll find windowSize
. This variable contains the size of your current screen, which you’ll need to use in order to show the compact UI for phones or the extended UI for tablets.
Open MainUi.kt and wrap CompatUi()
within an if
statement:
if (windowSize == WindowSize.Compact) {
CompatUi(navController = navController, viewModel = viewModel, themeStore = themeStore)
} else {
ExpandedUi(
navController = navController,
viewModel = viewModel,
themeStore = themeStore
)
}
This snippet checks the windowSize
value in order to show either the phone version of the UI or the more expanded tablet version.
Use your tablet device or emulator to build and run the app. It doesn’t really show much.
Next, you’ll make the app show content on the tablet.
Morphing the Main Screen
You’re going to display list and detail side by side, so you won’t be navigating from one destination to another in this case. Instead, you’ll change the data on the right part of the UI based on what the user taps on the left side. This is the first big change from phone to tablet. While you can reuse your composable()
by wrapping them inside containers, you need to also adjust your logic so that it works for both situations.
Open ExpandedUi.kt. Right after // TODO: Add NavHost for tablets here
, add this code:
NavHost(navController = navController, startDestination = Screen.List.path) {
composable(Screen.List.path) {
CityListWithDetailUi(viewModel = viewModel)
}
composable(Screen.Settings.path) {
SettingsUi(themeStore = themeStore)
}
}
It’s pretty similar to what you did earlier for the phone layout, but it lacks the detail declaration. You won’t be traveling through destinations on tablets. Instead you’ll react on the list item tapped by updating a variable that will trigger a recomposition in the detail section.
To see it in detail, open CityListWithDetailUi.kt and check the beginning of the parent composable function. You’ll notice a selectedCity
mutable state. Every time you tap an item on the list, this value is updated with the selected city. Every time the selected city changes, a recomposition will occur, and CityDetailUi()
will display the new data. Fancy, isn’t it?
Now, build and run your app on a tablet, and select a city.
Reaching for the Settings Again
Once again, there’s no way to get into the settings screen. Time to fix that!
Since you have quite a lot of real estate in the horizontal axis, it makes sense to move the navigation UI there, rather than keeping it at the bottom. To do so, you’re going to use a different component — a NavigationRail. The idea is the same as in the BottomNavigation
, but this time the component will lay items down vertically, and it will stand on a side.
In ExpandedUi.kt, move the NavHost
declaration inside the Row
at the portion marked // TODO: Move NavHost here
.
Next, add this code at // TODO: Add NavigationRail here
:
NavigationRail(backgroundColor = MaterialTheme.colors.primary) {
// 1
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
// 2
items.forEachIndexed { index, screen ->
NavigationRailItem(
// 3
icon = {
Icon(
imageVector = screen.icon,
contentDescription = screen.name
)
},
label = { Text(screen.name) },
// 4
selected = currentDestination?.route?.let { it == screen.path } ?: false,
// 5
onClick = {
navController.navigate(screen.path) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
Here’s a recap of how this code works:
- You’re getting the current location on the navigation tree.
- You cycle through all the available destinations.
- For each item, you load its icon and its label.
- You mark the item as selected if the currently presented destination matches the item itself.
- You define what happens when users tap the icon.
This code is really very similar to the code you wrote a few minutes ago for the BottomNavigation
. The only clear differences are in the NavigationRail
and NavigationRailItem
declarations.
Build and run on a tablet. Look at your amazing side navigation!
Where to Go From Here?
Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.
If you want to learn more about Jetpack Compose, make sure to check out the Jetpack Compose by Tutorials book. It contains everything you need to know to get started. Or, check out these articles about getting started, working with animations, providing accessibility the right way, and bringing Jetpack Compose to Desktop.
We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!