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 specificSceneinstance. This key, combined with theScene's class, ensures distinctness, primarily for animation purposes.entries: List<NavEntry<T>>: This is a list ofNavEntryobjects that theSceneis responsible for displaying. Importantly, if the sameNavEntryis displayed in multipleScenesduring a transition (e.g., in a shared element transition), its content will only be rendered by the most recent targetScenethat is displaying it.previousEntries: List<NavEntry<T>>: This property defines theNavEntrys that would result if a "back" action occurs from the currentScene. It's essential for calculating the proper predictive back state, allowing theNavDisplayto 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 theScenerenders itsentriesand any surrounding UI elements specific to thatScene.
Understand scene strategies
A SceneStrategy is the mechanism that determines how a given list of
NavEntrys 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
Scenefrom these entries? If theSceneStrategydetermines it can handle the givenNavEntrys 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 aSceneStrategycommits to handling the entries, it takes on the responsibility of constructing aSceneand defining how the specifiedNavEntrys 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 is an extension function on a SceneStrategyScope that takes the
current List<NavEntry<T>> from the back stack. It should return a Scene<T>
if it can successfully form one from the provided entries, or null if it
cannot.
The SceneStrategyScope is responsible for maintaining any optional arguments
that the SceneStrategy might need, such as an onBack callback.
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()), theNavDisplayobserves these changes. - The
NavDisplaypasses the current list ofNavEntrys(derived from the back stack keys) to the configuredSceneStrategy's calculateScenemethod. - If the
SceneStrategysuccessfully returns aScene, theNavDisplaythen renders thecontentof thatScene. TheNavDisplayalso 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: Any, 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() } } /** * 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().contentKey, 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() } Column(modifier = Modifier.weight(0.5f)) { secondEntry.Content() } } } 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.contentKey, secondEntry.contentKey) 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-navigation3in your project'sbuild.gradle.ktsfile. - Define your entries with
ListDetailSceneStrategymetadata: UselistPane(), detailPane(), andextraPane()to mark yourNavEntrysfor appropriate pane display. ThelistPane()helper also allows you to specify adetailPlaceholderwhen no item is selected. - Use
rememberListDetailSceneStrategy(): This composable function provides a pre-configuredListDetailSceneStrategythat 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") } } ) } } } }