Navigation for responsive UIs

Navigation is the process of interacting with an application's UI to access the app's content destinations. Android's principles of navigation provide guidelines that help you create consistent, intuitive app navigation.

Responsive UIs provide responsive content destinations and often include different types of navigation elements in response to display size changes—for example, a bottom navigation bar on small displays, a navigation rail on medium‑size displays, or a persistent navigation drawer on large displays—but responsive UIs should still conform to the principles of navigation.

The Jetpack Navigation component implements the principles of navigation and can be used to facilitate development of apps with responsive UIs.

Responsive UI navigation

The size of the display window occupied by an app affects ergonomics and usability. Window size classes enable you to determine appropriate navigation elements (such as navigation bars, rails, or drawers) and place them where they are most accessible for the user. In the Material Design layout guidelines, navigation elements occupy a persistent space on the display's leading edge and can move to the bottom edge when the app's width is compact. Your choice of navigation elements depends largely on the size of the app window and the number of items the element must hold.

Window size class Few items Many items
compact width bottom navigation bar navigation drawer (leading edge or bottom)
medium width navigation rail navigation drawer (leading edge)
expanded width persistent navigation drawer (leading edge) persistent navigation drawer (leading edge)

In views-based layouts, layout resource files can be qualified by window size class breakpoints to use different navigation elements for different display dimensions. Compose can use the breakpoints to programmatically determine the navigation element best suited for the app window.

Views

<!-- res/layout/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>


<!-- res/layout-w600dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigationrail.NavigationRailView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>


<!-- res/layout-w840dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigation.NavigationView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>

Compose

enum class WindowSizeClass { Compact, Medium, Expanded }

@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Select a navigation element based on window size.
    when (windowSizeClass) {
        WindowSizeClass.Compact -> { /* Bottom bar */ }
        WindowSizeClass.Medium -> { /* Rail */ }
        WindowSizeClass.Expanded -> { /* Persistent drawer */ }
    }
}

Responsive content destinations

In a responsive UI, the layout of each content destination must adapt to changes in window size. Your app can adjust layout spacing, reposition elements, add or remove content, or change UI elements, including navigation elements. (See Migrate your UI to responsive layouts and Build adaptive layouts.)

When each individual destination gracefully handles resize events, changes are isolated to the UI. The rest of the app state, including navigation, is unaffected.

Navigation should not occur as a side-effect of window size changes. Don't create content destinations just to accommodate different window sizes. For example, don't create different content destinations for the different screens of a foldable device.

Navigating as a side-effect of window size changes has the following problems:

  • The old destination (for the previous window size) might be momentarily visible before navigating to the new destination
  • To maintain reversibility (for example, when a device is folded and unfolded), navigation is required for each window size
  • Maintaining application state between destinations can be difficult, since navigating can destroy state upon popping the backstack

Also, your app may not even be in the foreground while window size changes are happening. Your app's layout might require more space than the foreground app, and when the user comes back to your app, the orientation and window size all could have changed.

If your app requires unique content destinations based on window size, consider combining the relevant destinations into a single destination that includes alternative layouts.

Content destinations with alternative layouts

As part of a responsive design, a single navigation destination can have alternative layouts depending on app window size. Each layout takes up the entire window, but different layouts are presented for different window sizes.

A canonical example is the list detail view. For small window sizes, your app displays one content layout for the list and one for the detail. Navigating to the list detail view destination initially displays just the list layout. When a list item is selected, your app displays the detail layout, replacing the list. When the back control is selected, the list layout is displayed, replacing the detail. However, for expanded window sizes , the list and detail layouts are displayed side by side.

Views

SlidingPaneLayout enables you to create a single navigation destination that displays two content panes side by side on large screens, but only one pane at a time on small-screen devices such as phones.

<!-- Single destination for list and detail. -->

<navigation ...>

    <!-- Fragment that implements SlidingPaneLayout. -->
    <fragment
        android:id="@+id/article_two_pane"
        android:name="com.example.app.ListDetailTwoPaneFragment" />

    <!-- Other destinations... -->
</navigation>

See Create a two pane layout for details on implementing a list detail layout using SlidingPaneLayout.

Compose

In Compose, a list detail view can be implemented by combining alternative composables in a single route that uses window size classes to emit the appropriate composable for each size class.

A route is the navigation path to a content destination, which typically is a single composable, but can also be alternative composables. Business logic determines which of the alternative composables is displayed. The composable fills the app window regardless of which alternative is displayed.

The list detail view consists of three composables, for example:

/* Displays a list of items. */
@Composable
fun ListOfItems(
    onItemSelected: (String) -> Unit,
) { /*...*/ }

/* Displays the detail for an item. */
@Composable
fun ItemDetail(
    selectedItemId: String? = null,
) { /*...*/ }

/* Displays a list and the detail for an item side by side. */
@Composable
fun ListAndDetail(
    selectedItemId: String? = null,
    onItemSelected: (String) -> Unit,
) {
  Row {
    ListOfItems(onItemSelected = onItemSelected)
    ItemDetail(selectedItemId = selectedItemId)
  }
}

A single navigation route provides access to the list detail view:

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      /*...*/
    )
  } else {
    // If the display size cannot accommodate both the list and the item detail,
    // show one of them based on the user's focus.
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(/*...*/)
    }
  }
}

The ListDetailRoute (the navigation destination) determines which of the three composables to emit: ListAndDetail for expanded window size; ListOfItems or ItemDetail for compact, depending on whether a list item has been selected.

The route is included in a NavHost, for example:

NavHost(navController = navController, startDestination = "listDetailRoute") {
  composable("listDetailRoute") {
    ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,
                    selectedItemId = selectedItemId)
  }
  /*...*/
}

You can provide the isExpandedWindowSize argument by examining your app's WindowMetrics.

The selectedItemId argument can be provided by a ViewModel that maintains state across all window sizes. When the user selects an item from the list, the selectedItemId state variable is updated:

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

The route also includes a custom BackHandler when the item detail composable occupies the entire app window:

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }

  fun onItemBackPress() {
    viewModelState.update {
      it.copy(selectedItemId = null)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
    onItemBackPress: () -> Unit = { listDetailViewModel.onItemBackPress() },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
      BackHandler {
        onItemBackPress()
      }
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

Combining app state from a ViewModel with window size class information makes choosing the appropriate composable a matter of simple logic. By maintaining a unidirectional data flow, your app is able to fully use the available display space while preserving application state.

For a complete list detail view implementation in Compose, see the JetNews sample on GitHub.

One navigation graph

To provide a consistent user experience on any device or window size, use a single navigation graph where the layout of each content destination is responsive.

If you use a different navigation graph for each window size class, whenever the app transitions from one size class to another, you have to determine the user's current destination in the other graphs, construct a back stack, and reconcile state information that differs among the graphs.

Nested navigation host

Your app might include a content destination that has content destinations of its own. For example, in a list detail view, the item detail pane could include UI elements that navigate to content that replaces the item detail.

To implement this kind of sub-navigation, the detail pane can be a nested navigation host with its own navigation graph that specifies the destinations accessed from the detail pane:

Views

<!-- layout/two_pane_fragment.xml -->

<androidx.slidingpanelayout.widget.SlidingPaneLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/sliding_pane_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list_pane"
        android:layout_width="280dp"
        android:layout_height="match_parent"
        android:layout_gravity="start"/>

    <!-- Detail pane is a nested navigation host. Its graph is not connected
         to the main graph that contains the two_pane_fragment destination. -->
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/detail_pane"
        android:layout_width="300dp"
        android:layout_weight="1"
        android:layout_height="match_parent"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/detail_pane_nav_graph" />
</androidx.slidingpanelayout.widget.SlidingPaneLayout>

Compose

NavHost(navController = navController, startDestination = "listDetailRoute") {
  composable("listDetailRoute") {
    ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,
                    selectedItemId = selectedItemId)
  }
  navigation(startDestination = "itemSubdetail1", route = "itemSubDetail") {
    composable("itemSubdetail1") { ItemSubdetail1(/*...*/) }
    composable("itemSubdetail2") { ItemSubdetail2(/*...*/) }
    composable("itemSubdetail3") { ItemSubdetail3(/*...*/) }
  }
  /*...*/
}

This is different from a nested navigation graph because the navigation graph of the nested NavHost is not connected to the main navigation graph; that is, you cannot navigate directly from destinations in one graph to destinations in the other.

For more information, see Nested navigation graphs and Navigating with Compose.

Preserved state

To provide responsive content destinations, your app must preserve its state when the device is rotated or folded or the app window is resized. By default, configuration changes such as these recreate the app's activities, fragments, view hierarchy, and composables. The recommended way to save UI state is with a ViewModel or rememberSaveable, which survive across configuration changes. (See Save UI states and State and Jetpack Compose.)

Size changes should be reversible—for example, when the user rotates the device and then rotates it back.

Responsive layouts can display different pieces of content at different window sizes; and so, responsive layouts often need to save additional state related to content, even if the state isn't applicable to the current window size. For example, a layout might have space to show an additional scrolling widget only at larger window widths. If a resize event causes the window width to become too small, the widget is hidden. When the app resizes to its previous dimensions, the scrolling widget becomes visible again, and the original scroll position should be restored.

ViewModel scopes

The Migrate to the Navigation component developer guide recommends a single-activity architecture in which destinations are implemented as fragments and their data models are implemented using ViewModel.

A ViewModel is always scoped to a lifecycle, and once that lifecycle ends permanently, the ViewModel is cleared and can be discarded. The lifecycle to which the ViewModel is scoped—and therefore how broadly the ViewModel can be shared—depends on which property delegate is used to obtain the ViewModel.

In the simplest case, every navigation destination is a single fragment with a completely isolated UI state; and so, each fragment can use the viewModels() property delegate to obtain a ViewModel scoped to that fragment.

To share UI state between fragments, scope the ViewModel to the activity by calling activityViewModels() in the fragments (the equivalent for activity is just viewModels()). This allows the activity and any fragments that attach to it to share the ViewModel instance. However, in a single-activity architecture, this ViewModel scope lasts effectively as long as the app, so the ViewModel remains in memory even if no fragments are using it.

Suppose your navigation graph has a sequence of fragment destinations representing a checkout flow, and the current state for the entire checkout experience is in a ViewModel that is shared among the fragments. Scoping the ViewModel to the activity is not only too broad, but actually exposes another problem: if the user goes through the checkout flow for one order, and then goes through it again for a second order, both orders use the same instance of the checkout ViewModel. Before the second order checkout, you will have to manually clear data from the first order, and any mistakes could be costly for the user.

Instead, scope the ViewModel to a navigation graph in the current NavController. Create a nested navigation graph to encapsulate the destinations that are part of the checkout flow. Then in each of those fragment destinations, use the navGraphViewModels() property delegate, and pass the ID of the navigation graph to obtain the shared ViewModel. This ensures that once the user exits the checkout flow and the nested navigation graph is out of scope, the corresponding instance of the ViewModel is discarded and will not be used for the next checkout.

Scope Property delegate Can share ViewModel with
Fragment Fragment.viewModels() Current fragment only
Activity Activity.viewModels()

Fragment.activityViewModels()

Activity and all fragments attached to it
Navigation graph Fragment.navGraphViewModels() All fragments in the same navigation graph

Note that if you are using a nested navigation host (see above), destinations in that host cannot share ViewModels with destinations outside the host when using navGraphViewModels() because the graphs are not connected. In this case, you can use the activity's scope instead.

Hoisted state

In Compose, you can preserve state during window size changes with state hoisting. By hoisting the state of composables to a position higher in the composition tree, state can be preserved even while the composables are no longer visible.

In the Compose section of Content destinations with alternative layouts above, we hoisted the state of the list detail view composables to ListDetailRoute so that state is preserved regardless of which composable is displayed:

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) { /*...*/ }