Create custom layouts using Scenes

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 specific Scene instance. This key, combined with the Scene's class, ensures distinctness, primarily for animation purposes.
  • entries: List<NavEntry<T>>: This is a list of NavEntry objects that the Scene is responsible for displaying. Importantly, if the same NavEntry is displayed in multiple Scenes during a transition (e.g., in a shared element transition), its content will only be rendered by the most recent target Scene that is displaying it.
  • previousEntries: List<NavEntry<T>>: This property defines the NavEntrys that would result if a "back" action occurs from the current Scene. It's essential for calculating the proper predictive back state, allowing the NavDisplay 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 the Scene renders its entries and any surrounding UI elements specific to that Scene.

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:

  1. Can I create a Scene from these entries? If the SceneStrategy determines it can handle the given NavEntrys and form a meaningful Scene (e.g., a dialog or a multi-pane layout), it proceeds. Otherwise, it returns null, giving other strategies a chance to create a Scene.
  2. If so, how should I arrange those entries into the Scene? Once a SceneStrategy commits to handling the entries, it takes on the responsibility of constructing a Scene and defining how the specified NavEntrys will be displayed within that Scene.

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() or backStack.removeLastOrNull()), the NavDisplay observes these changes.
  • The NavDisplay passes the current list of NavEntrys (derived from the back stack keys) to the configured SceneStrategy's calculateScene method.
  • If the SceneStrategy successfully returns a Scene, the NavDisplay then renders the content of that Scene. The NavDisplay also manages animations and predictive back based on the Scene'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:

  1. The window width is sufficiently wide to support two panes (i.e., at least WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. 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:

  1. Add the dependency: Include androidx.compose.material3.adaptive:adaptive-navigation3 in your project's build.gradle.kts file.
  2. Define your entries with ListDetailSceneStrategy metadata: Use listPane(), detailPane(), and extraPane() to mark your NavEntrys for appropriate pane display. The listPane() helper also allows you to specify a detailPlaceholder when no item is selected.
  3. Use rememberListDetailSceneStrategy(): This composable function provides a pre-configured ListDetailSceneStrategy that can be used by a NavDisplay.

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")
                        }
                    }
                )
            }
        }
    }
}

Figure 1. Example content running in Material list-detail Scene.