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.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

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