Home Android & Kotlin Tutorials

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.

Version

  • Kotlin 1.6, Android 12.0, Android Studio 2021.1.1

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:

  1. How to implement routes and destinations
  2. How to implement navigation using the Bottom Navigation UI pattern
  3. Animate when navigating between different screens
  4. 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.

Note: Remember that when you deal with different screen sizes, you might encounter solutions that could look strange. If that happens, don’t worry — it’s normal. Take a step back and remember that everything you’re learning will need to work on several screen sizes.

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.

Getting started

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!

Note: When you read destination, it means the screens that show to your users, while routes are the paths the user needs to walk to reach the content.

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.

First destination

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:

  1. You define a destination that users reach by tapping a city using composable(). Here, you get the backstackEntry, 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 notice city 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 to CityDetailUi().
  2. 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:

Details screen

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:

  1. 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.
  2. You cycle through the different destinations available for the BottomNavigation, and you create a BottomNavigationItem for each of them.
  3. You assign an icon and label for each BottomNavigationItem.
  4. 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.
  5. 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!

Settings screen

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:

  1. You use visibility to link the flag that you changed inside all three composable()s.
  2. You use enter and exit to regulate the enter and exit animation.
  3. 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:

Animate all the things

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.

Note: If you’re interested in the official source, check it out here.

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.

Empty tablets

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.

First destination on tablets

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:

  1. You’re getting the current location on the navigation tree.
  2. You cycle through all the available destinations.
  3. For each item, you load its icon and its label.
  4. You mark the item as selected if the currently presented destination matches the item itself.
  5. 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!

Navigation Rail

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!

Reviews

More like this

Contributors

Comments