Navigation 3 introduces a powerful and flexible system for managing your app's UI flow through Scenes. Scenes allow you to create highly customized layouts, adapt to different screen sizes, and manage complex multi-pane experiences seamlessly.
Understand Scenes
In Navigation 3, a Scene
is the fundamental unit that renders one or more
NavEntry
instances. Think of a Scene
as a distinct visual state or section
of your UI that can contain and manage the display of content from your back
stack.
Each Scene
instance is uniquely identified by its key
and the class of the
Scene
itself. This unique identifier is crucial because it drives the
top-level animation when the Scene
changes.
The Scene
interface has the following properties:
key: Any
: A unique identifier for this specificScene
instance. This key, combined with theScene
's class, ensures distinctness, primarily for animation purposes.entries: List<NavEntry<T>>
: This is a list ofNavEntry
objects that theScene
is responsible for displaying. Importantly, if the sameNavEntry
is displayed in multipleScenes
during a transition (e.g., in a shared element transition), its content will only be rendered by the most recent targetScene
that is displaying it.previousEntries: List<NavEntry<T>>
: This property defines theNavEntry
s that would result if a "back" action occurs from the currentScene
. It's essential for calculating the proper predictive back state, allowing theNavDisplay
to anticipate and transition to the correct previous state, which may be a Scene with a different class and/or key.content: @Composable () -> Unit
: This is the composable function where you define how theScene
renders itsentries
and any surrounding UI elements specific to thatScene
.
Understand scene strategies
A SceneStrategy
is the mechanism that determines how a given list of
NavEntry
s from the back stack should be arranged and transitioned into a
Scene
. Essentially, when presented with the current back stack entries, a
SceneStrategy
asks itself two key questions:
- Can I create a
Scene
from these entries? If theSceneStrategy
determines it can handle the givenNavEntry
s and form a meaningfulScene
(e.g., a dialog or a multi-pane layout), it proceeds. Otherwise, it returnsnull
, giving other strategies a chance to create aScene
. - If so, how should I arrange those entries into the
Scene?
Once aSceneStrategy
commits to handling the entries, it takes on the responsibility of constructing aScene
and defining how the specifiedNavEntry
s will be displayed within thatScene
.
The core of a SceneStrategy
is its calculateScene
method:
@Composable public fun calculateScene( entries: List<NavEntry<T>>, onBack: (count: Int) -> Unit, ): Scene<T>?
This method takes the current List<NavEntry<T>>
from the back stack and an
onBack
callback. It should return a Scene<T>
if it can successfully form one
from the provided entries, or null
if it cannot.
SceneStrategy
also provides a convenient then
infix function, allowing
you to chain multiple strategies together. This creates a flexible
decision-making pipeline where each strategy can attempt to calculate a Scene
,
and if it can't, it delegates to the next one in the chain.
How Scenes and scene strategies work together
The NavDisplay
is the central composable that observes your back stack and
uses a SceneStrategy
to determine and render the appropriate Scene
.
The NavDisplay's sceneStrategy
parameter expects a SceneStrategy
that is
responsible for calculating the Scene
to display. If no Scene
is calculated
by the provided strategy (or chain of strategies), NavDisplay
automatically
falls back to using a SinglePaneSceneStrategy
by default.
Here's a breakdown of the interaction:
- When you add or remove keys from your back stack (e.g., using
backStack.add()
orbackStack.removeLastOrNull()
), theNavDisplay
observes these changes. - The
NavDisplay
passes the current list ofNavEntrys
(derived from the back stack keys) to the configuredSceneStrategy's calculateScene
method. - If the
SceneStrategy
successfully returns aScene
, theNavDisplay
then renders thecontent
of thatScene
. TheNavDisplay
also manages animations and predictive back based on theScene
's properties.
Example: Single pane layout (default behavior)
The simplest custom layout you can have is a single-pane display, which is the
default behavior if no other SceneStrategy
takes precedence.
data class SinglePaneScene<T : Any>( override val key: T, val entry: NavEntry<T>, override val previousEntries: List<NavEntry<T>>, ) : Scene<T> { override val entries: List<NavEntry<T>> = listOf(entry) override val content: @Composable () -> Unit = { entry.content.invoke(entry.key) } } /** * A [SceneStrategy] that always creates a 1-entry [Scene] simply displaying the last entry in the * list. */ public class SinglePaneSceneStrategy<T : Any> : SceneStrategy<T> { @Composable override fun calculateScene(entries: List<NavEntry<T>>, onBack: (Int) -> Unit): Scene<T> = SinglePaneScene( key = entries.last().key, entry = entries.last(), previousEntries = entries.dropLast(1) ) }
Example: Basic two-pane layout (custom Scene and strategy)
This example demonstrates how to create a simple two-pane layout that is activated based on two conditions:
- The window width is sufficiently wide to support two panes (i.e., at
least
WIDTH_DP_MEDIUM_LOWER_BOUND
). - The top two entries on the back stack explicitly declare their support for being displayed in a two-pane layout using specific metadata.
The following snippet is the combined source code for TwoPaneScene.kt
and
TwoPaneSceneStrategy.kt
:
// --- TwoPaneScene --- /** * A custom [Scene] that displays two [NavEntry]s side-by-side in a 50/50 split. */ class TwoPaneScene<T : Any>( override val key: Any, override val previousEntries: List<NavEntry<T>>, val firstEntry: NavEntry<T>, val secondEntry: NavEntry<T> ) : Scene<T> { override val entries: List<NavEntry<T>> = listOf(firstEntry, secondEntry) override val content: @Composable (() -> Unit) = { Row(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.weight(0.5f)) { firstEntry.content.invoke(firstEntry.key) } Column(modifier = Modifier.weight(0.5f)) { secondEntry.content.invoke(secondEntry.key) } } } companion object { internal const val TWO_PANE_KEY = "TwoPane" /** * Helper function to add metadata to a [NavEntry] indicating it can be displayed * in a two-pane layout. */ fun twoPane() = mapOf(TWO_PANE_KEY to true) } } // --- TwoPaneSceneStrategy --- /** * A [SceneStrategy] that activates a [TwoPaneScene] if the window is wide enough * and the top two back stack entries declare support for two-pane display. */ class TwoPaneSceneStrategy<T : Any> : SceneStrategy<T> { @OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3WindowSizeClassApi::class) @Composable override fun calculateScene( entries: List<NavEntry<T>>, onBack: (Int) -> Unit ): Scene<T>? { val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass // Condition 1: Only return a Scene if the window is sufficiently wide to render two panes. // We use isWidthAtLeastBreakpoint with WIDTH_DP_MEDIUM_LOWER_BOUND (600dp). if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) { return null } val lastTwoEntries = entries.takeLast(2) // Condition 2: Only return a Scene if there are two entries, and both have declared // they can be displayed in a two pane scene. return if (lastTwoEntries.size == 2 && lastTwoEntries.all { it.metadata.containsKey(TwoPaneScene.TWO_PANE_KEY) } ) { val firstEntry = lastTwoEntries.first() val secondEntry = lastTwoEntries.last() // The scene key must uniquely represent the state of the scene. val sceneKey = Pair(firstEntry.key, secondEntry.key) TwoPaneScene( key = sceneKey, // Where we go back to is a UX decision. In this case, we only remove the top // entry from the back stack, despite displaying two entries in this scene. // This is because in this app we only ever add one entry to the // back stack at a time. It would therefore be confusing to the user to add one // when navigating forward, but remove two when navigating back. previousEntries = entries.dropLast(1), firstEntry = firstEntry, secondEntry = secondEntry ) } else { null } } }
To use this TwoPaneSceneStrategy
in your NavDisplay
, modify your
entryProvider
calls to include TwoPaneScene.twoPane()
metadata for the
entries you intend to show in a two-pane layout. Then, provide
TwoPaneSceneStrategy()
as your sceneStrategy
, relying on the default
fallback for single-pane scenarios:
// Define your navigation keys @Serializable data object ProductList : NavKey @Serializable data class ProductDetail(val id: String) : NavKey @Composable fun MyAppContent() { val backStack = rememberNavBackStack(ProductList) NavDisplay( backStack = backStack, entryProvider = entryProvider { entry<ProductList>( // Mark this entry as eligible for two-pane display metadata = TwoPaneScene.twoPane() ) { key -> Column { Text("Product List") Button(onClick = { backStack.add(ProductDetail("ABC")) }) { Text("View Details for ABC (Two-Pane Eligible)") } } } entry<ProductDetail>( // Mark this entry as eligible for two-pane display metadata = TwoPaneScene.twoPane() ) { key -> Text("Product Detail: ${key.id} (Two-Pane Eligible)") } // ... other entries ... }, // Simply provide your custom strategy. NavDisplay will fall back to SinglePaneSceneStrategy automatically. sceneStrategy = TwoPaneSceneStrategy<Any>(), onBack = { count -> repeat(count) { if (backStack.isNotEmpty()) { backStack.removeLastOrNull() } } } ) }
Display list-detail content in a Material Adaptive Scene
For the list-detail use case, the
androidx.compose.material3.adaptive:adaptive-navigation3
artifact provides a
ListDetailSceneStrategy
that creates a list-detail Scene
. This Scene
automatically handles complex multi-pane arrangements (list, detail, and extra
panes) and adapts them based on window size and device state.
To create a Material list-detail Scene
, follow these steps:
- Add the dependency: Include
androidx.compose.material3.adaptive:adaptive-navigation3
in your project'sbuild.gradle.kts
file. - Define your entries with
ListDetailSceneStrategy
metadata: UselistPane(), detailPane()
, andextraPane()
to mark yourNavEntrys
for appropriate pane display. ThelistPane()
helper also allows you to specify adetailPlaceholder
when no item is selected. - Use
rememberListDetailSceneStrategy
(): This composable function provides a pre-configuredListDetailSceneStrategy
that can be used by aNavDisplay
.
The following snippet is a sample Activity
demonstrating the usage of
ListDetailSceneStrategy
:
@Serializable object ProductList : NavKey @Serializable data class ProductDetail(val id: String) : NavKey @Serializable data object Profile : NavKey class MaterialListDetailActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3AdaptiveApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Scaffold { paddingValues -> val backStack = rememberNavBackStack(ProductList) val listDetailStrategy = rememberListDetailSceneStrategy<Any>() NavDisplay( backStack = backStack, modifier = Modifier.padding(paddingValues), onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } }, sceneStrategy = listDetailStrategy, entryProvider = entryProvider { entry<ProductList>( metadata = ListDetailSceneStrategy.listPane( detailPlaceholder = { ContentYellow("Choose a product from the list") } ) ) { ContentRed("Welcome to Nav3") { Button(onClick = { backStack.add(ProductDetail("ABC")) }) { Text("View product") } } } entry<ProductDetail>( metadata = ListDetailSceneStrategy.detailPane() ) { product -> ContentBlue("Product ${product.id} ", Modifier.background(PastelBlue)) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Button(onClick = { backStack.add(Profile) }) { Text("View profile") } } } } entry<Profile>( metadata = ListDetailSceneStrategy.extraPane() ) { ContentGreen("Profile") } } ) } } } }