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 | navigation rail | persistent navigation drawer (leading edge) |
In view-based layouts, layout resource files can be qualified by window size class breakpoints to use different navigation elements for different display dimensions. Jetpack Compose can use breakpoints provided by the window size class API 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-w1240dp/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
// This method should be run inside a Composable function. val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass // You can get the height of the current window by invoking heightSizeClass instead. @Composable fun MyApp(widthSizeClass: WindowWidthSizeClass) { // Select a navigation element based on window size. when (widthSizeClass) { WindowWidthSizeClass.Compact -> { CompactScreen() } WindowWidthSizeClass.Medium -> { MediumScreen() } WindowWidthSizeClass.Expanded -> { ExpandedScreen() } } } @Composable fun CompactScreen() { Scaffold(bottomBar = { NavigationBar { icons.forEach { item -> NavigationBarItem( selected = isSelected, onClick = { ... }, icon = { ... }) } } } ) { // Other content } } @Composable fun MediumScreen() { Row(modifier = Modifier.fillMaxSize()) { NavigationRail { icons.forEach { item -> NavigationRailItem( selected = isSelected, onClick = { ... }, icon = { ... }) } } // Other content } } @Composable fun ExpandedScreen() { PermanentNavigationDrawer( drawerContent = { icons.forEach { item -> NavigationDrawerItem( icon = { ... }, label = { ... }, selected = isSelected, onClick = { ... } ) } }, content = { // Other content } ) }
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 Support different screen sizes.)
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
@Composable fun ItemDetail(selectedItemId: String? = null) { val navController = rememberNavController() NavHost(navController, "itemSubdetail1") { 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()
|
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 ViewModel
s 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?,
) { /*...*/ }
Additional resources
Recommended for you
- Note: link text is displayed when JavaScript is off
- Migrate Jetpack Navigation to Navigation Compose
- Navigation with Compose
- Build an adaptive app with dynamic navigation