Implement navigation for adaptive UIs

Navigation refers to the interactions that allow users to navigate across, into, and back out from the different pieces of content within your app. Adaptive UIs in Compose don’t fundamentally change the process of navigation, and you should still adhere to all of the principles of navigation. The Navigation component makes it easy to apply the recommended patterns, and you can continue to use it in apps with highly adaptive layouts.

In addition to the above principles, there are a few other considerations to make the user experience great in apps with adaptive layouts. As covered in the guide for building adaptive layouts, the structure of the UI may depend on the space available to your app. These extra navigation principles all consider what happens when the screen space available to your app changes.

Responsive navigation UI

To provide the best possible navigation experience to your users, you should provide a navigation UI that is tailored to the space available to your app. You may wish to use a bottom app bar, an always-present or collapsible navigation drawer, a rail, or perhaps something completely new based on the available screen space and your app's unique style.

Because these components occupy the entire width or height of the screen, the logic for deciding which one should be used is a screen-level layout decision. For that reason, we recommend using Window Size Classes to determine which type of navigation UI to show. Window size classes are breakpoints that are designed to balance simplicity with the flexibility to optimize your app for most unique cases.

enum class WindowSizeClass { Compact, Medium, Expanded }

@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Perform logic on the size class to decide whether to show
    // the nav rail
    val showNavRail = windowSizeClass != WindowSizeClass.Compact
    MyScreen(
        showNavRail = showNavRail,
        /* ... */
    )
}

Fully responsive destinations

Multi-window mode, foldables, and free-form windows on Chrome OS all can cause the space available to your app to change more than ever.

To provide a seamless experience for the user, within your navigation host, use a single navigation graph where each destination is responsive. This approach reinforces the major principles of responsive UI: flexibility and continuity. If each individual destination gracefully handles resize events, then changes are isolated to only the UI, and the rest of the app state (including navigation) is preserved, which aids continuity.

With parallel navigation graphs, whenever the app transitions to another size class, you will have to determine the user's current destination in the other graph, reconstruct a back stack, and reconcile other state information that differs between the graphs. This approach is complicated and prone to error.

At a specific destination, you have many options for making a layout responsive. You can adjust spacing, use alternative layouts, add additional columns of information to use more space, or display extra details that wouldn’t fit with less space. You can learn more about the tools available to implement these changes at build adaptive layouts.

For an even better user experience, you can add more content to a specific destination with a large screen canonical layout, such as a list/detail view. The navigation considerations for such a design are explored below.

Distinguish between routes and screens

The navigation component allows defining routes, each of which corresponds to some destination. Navigating results in changing which destination is currently displayed, along with keeping track of a backstack, the list of destinations that the user was at previously.

At a specific destination, you can display any content you’d like. For a NavHost that is handling the main navigation for your app, you generally display a different screen at each destination, which takes up the entire space available to your app.

Most commonly, each destination is responsible for displaying a single screen, and each screen is displayed at only one destination. However, this isn’t a hard requirement. In fact, it can be extremely helpful to have a destination choose between multiple screens to display, depending on the size available to your app.

Let’s take a look at JetNews, one of the official Compose samples. The primary function of the app is to display articles, which the user can select from a list. When the app has enough room, it can display both the list and an article at the same time. This interface is a list/detail layout, which is one of the Material Design canonical layouts.

List, Detail, and List + Detail screens in JetNews

Even though these are 3 visually distinct screens, the app displays all three of them underneath the same "home" route.

In code, the destination calls HomeRoute:

@Composable
fun JetnewsNavGraph(
    navController: NavHostController,
    isExpandedScreen: Boolean,
    // ...
) {
    // ...
    NavHost(
        navController = navController,
        startDestination = JetnewsDestinations.HomeRoute
    ) {
        composable(JetnewsDestinations.HomeRoute) {
            // ...
            HomeRoute(
                isExpandedScreen = isExpandedScreen,
                // ...
            )
        }
        // ...
    }
}

Then the HomeRoute code decides which of the three screens to show, which are each composables suffixed with Screen. The app makes this decision based on a combination of the app state stored in HomeViewModel, as well as the window size class describing the current available space.

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(/* ... */)
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

With this approach, the app clearly separates navigation operations that replace the whole HomeRoute with another destination (by calling navigate() on the NavController) from navigation operations that affect only the contents within this destination (such as selecting an article from the list). We recommend handling these events by updating a shared state that applies to all window sizes, even though a transition between the list and article screen appears to be a navigation operation to the user if the app is only showing a single pane.

Therefore, when we tap on an article in the list, we update a boolean flag isArticleOpen:

class HomeViewModel(/* ... */) {
    fun selectArticle(articleId: String) {
        viewModelState.update {
            it.copy(
                isArticleOpen = true,
                selectedArticleId = articleId
            )
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            selectedArticleId = selectedArticleId,
            onSelectArticle = onSelectArticle,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                // ...
            )
        } else {
            HomeListScreen(
                onSelectArticle = onSelectArticle,
                // ...
            )
        }
    }
}

Similarly, we install a custom BackHandler when just the article screen is shown, which sets isArticleOpen back to false.

class HomeViewModel(/* ... */) {
    fun onArticleBackPress() {
        viewModelState.update {
            it.copy(isArticleOpen = false)
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    onArticleBackPress: () -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                onUpPressed = onArticleBackPress,
                // ...
            )
            BackHandler {
                onArticleBackPress()
            }
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

This layering brings together a lot of important concepts when designing a Compose app. By making screens reusable and allowing their important state to be hoisted, you can swap out entire screens easily. By combining app state from a ViewModel with the available size information, deciding which screen to show is governed by a simple piece of logic. Finally, by preserving a unidirectional data flow, your adaptive UI will always be making use of the available space while preserving the user’s state.

For the full implementation, check out the JetNews sample on GitHub.

Preserve the user’s state

The most important consideration for adaptive UIs is to preserve the user’s state when the device is rotated or folded, or the app’s window is resized. In particular, all of these resizes should be reversible.

For example, suppose the user is viewing some screen in your app, and then rotates their device. If they undo that rotation (that is, rotate their device back to where it started), they should be returned to the exact same screen they started at, with all of their state preserved. If they have scrolled partway through a piece of content before rotating, they should be returned to the same scroll position after rotating back.

Saving list scroll position after rotating

Orientation changes and window resizes cause configuration changes, which by default recreate your Activity and your composables. State can be saved through these configuration changes with rememberSaveable or ViewModels, which you can learn more about in State and Jetpack Compose. If you don't use tools like these, the user’s state will be lost.

Adaptive layouts tend to have additional state, since they might display different pieces of content at different screen sizes. Therefore, it is also important to save the user’s state for those additional pieces of content, even for components that are no longer visible.

Suppose some scrolling content is only visible at larger widths. If a rotation causes the width to become too small to display the scrolling content, the scrolling content will be hidden. When the user rotates their device back, the scrolling content will become visible again, and the original scroll position should be restored.

Saving detail scroll position while rotating

In Compose, you can accomplish this with state hoisting. By hoisting the state of composables higher in the composition tree, their state can be preserved even while they are no longer visible.

In JetNews, we hoist the state to HomeRoute, so that it is preserved and reused while changing which screen is visible:

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    selectedArticleId: String,
    // ...
) {
    val homeListState = rememberHomeListState()
    val articleState = rememberSaveable(
        selectedArticleId,
        saver = ArticleState.Saver
    ) {
        ArticleState()
    }

    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            homeListState = homeListState,
            articleState = articleState,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                articleState = articleState,
                // ...
            )
        } else {
            HomeListScreen(
                homeListState = homeListState,
                // ...
            )
        }
    }
}

Avoid navigating as a side-effect of a size change

If you are adding a screen to your app that takes advantage of the additional space that larger displays can provide, it might be tempting to add a new destination to your app for the newly designed layout.

However, let’s say the user is viewing this new layout on the inner screen of a foldable device. If the user folds the device, there might not be enough space to display the new layout on the outer screen. This introduces the requirement to navigate somewhere else if the new screen size is too small. This has a few issues:

  • Navigating as a side-effect of composition might cause the old destination to be momentarily visible, since it needs to be displayed before the navigation occurs
  • In order to maintain reversibility, we would also need to navigate back upon unfolding
  • It will be extremely hard to maintain user state between these changes, since navigating might wipe out the old state upon popping the backstack

As an additional consideration, your app may not even be in the foreground while these changes are occurring. Your app might be showing a layout that requires more space, and then the user puts your app in the background. If they come back to your app later, the orientation, size and physical screen all could have changed since your app was last resumed.

If you find yourself only wanting to show some destinations for certain screen sizes, consider combining the relevant destinations into a single route, and showing different screens at that route as explored above.