使用场景创建自定义布局

Navigation 3 引入了一个强大灵活的系统,可通过 Scenes 管理应用的 UI 流程。场景允许您创建高度自定义的布局,适应不同的屏幕尺寸,并无缝管理复杂的多窗格体验。

了解场景

在 Navigation 3 中,Scene 是渲染一个或多个 NavEntry 实例的基本单元。可以将 Scene 视为 UI 中一个独特的视觉状态或部分,它可以包含并管理返回堆栈中内容的显示。

每个 Scene 实例都由其 keyScene 本身的类唯一标识。此唯一标识符至关重要,因为它会在 Scene 更改时驱动顶级动画。

Scene 界面具有以下属性

  • key: Any:此特定 Scene 实例的唯一标识符。此键与 Scene 的类结合使用,可确保其独特性,主要用于动画目的。
  • entries: List<NavEntry<T>>:这是 NavEntry 对象的列表,由 Scene 负责显示。重要的是,如果在过渡期间(例如,在共享元素过渡中)同一 NavEntry 显示在多个 Scene 中,则其内容只会由显示它的最新目标 Scene 渲染。
  • previousEntries: List<NavEntry<T>>:此属性定义了如果从当前 Scene 执行“返回”操作将产生的 NavEntry。它对于计算正确的预测性返回状态至关重要,允许 NavDisplay 预测并过渡到正确的先前状态,该状态可能是一个具有不同类和/或键的 Scene。
  • content: @Composable () -> Unit:这是一个可组合函数,您可以在其中定义 Scene 如何渲染其 entries 以及该 Scene 特有的任何周围 UI 元素。

理解场景策略

SceneStrategy 是一个机制,用于确定如何将给定返回堆栈中的 NavEntry 列表排列并转换为 Scene。本质上,当提供当前返回堆栈条目时,SceneStrategy 会自问两个关键问题

  1. 我能否从这些条目创建 Scene如果 SceneStrategy 确定它可以处理给定的 NavEntry 并形成一个有意义的 Scene(例如,对话框或多窗格布局),它将继续执行。否则,它会返回 null,让其他策略有机会创建 Scene
  2. 如果可以,我该如何将这些条目排列到 Scene 中?一旦 SceneStrategy 承诺处理这些条目,它就承担了构建 Scene 并定义如何在 Scene 中显示指定 NavEntry 的责任。

SceneStrategy 的核心是其 calculateScene 方法

@Composable
public fun calculateScene(
    entries: List<NavEntry<T>>,
    onBack: (count: Int) -> Unit,
): Scene<T>?

此方法接收返回堆栈中的当前 List<NavEntry<T>> 和一个 onBack 回调。如果它能成功地从提供的条目中形成一个 Scene<T>,则应返回该 Scene;否则返回 null

SceneStrategy 还提供了一个方便的 then 中缀函数,允许您将多个策略链接在一起。这创建了一个灵活的决策管道,其中每个策略都可以尝试计算一个 Scene,如果不能,则将其委托给链中的下一个策略。

场景和场景策略如何协同工作

NavDisplay 是一个核心可组合项,它观察您的返回堆栈并使用 SceneStrategy 来确定并渲染适当的 Scene

NavDisplaysceneStrategy 参数需要一个负责计算要显示的 SceneSceneStrategy。如果提供的策略(或策略链)未能计算出 SceneNavDisplay 将默认自动回退到使用 SinglePaneSceneStrategy

以下是交互的细分:

  • 当您从返回堆栈中添加或移除键时(例如,使用 backStack.add()backStack.removeLastOrNull()),NavDisplay 会观察这些更改。
  • NavDisplay 将当前 NavEntry 列表(派生自返回堆栈键)传递给配置的 SceneStrategycalculateScene 方法。
  • 如果 SceneStrategy 成功返回 Scene,则 NavDisplay 会渲染该 ScenecontentNavDisplay 还会根据 Scene 的属性管理动画和预测性返回。

示例:单窗格布局(默认行为)

您可以拥有的最简单的自定义布局是单窗格显示,如果没有其他 SceneStrategy 优先,则这是默认行为。

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

示例:基本双窗格布局(自定义场景和策略)

本示例演示如何创建简单的双窗格布局,该布局基于两个条件激活:

  1. 窗口宽度足以支持两个窗格(即至少 WIDTH_DP_MEDIUM_LOWER_BOUND)。
  2. 返回堆栈上的前两个条目使用特定元数据明确声明它们支持在双窗格布局中显示。

以下代码片段是 TwoPaneScene.ktTwoPaneSceneStrategy.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
        }
    }
}

要在 NavDisplay 中使用此 TwoPaneSceneStrategy,请修改您的 entryProvider 调用,为打算在双窗格布局中显示的条目添加 TwoPaneScene.twoPane() 元数据。然后,提供 TwoPaneSceneStrategy() 作为您的 sceneStrategy,并依赖于单窗格场景的默认回退:

// 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()
                }
            }
        }
    )
}

在 Material 自适应场景中显示列表-详情内容

对于列表-详情用例androidx.compose.material3.adaptive:adaptive-navigation3 工件提供了 ListDetailSceneStrategy,它会创建列表-详情 Scene。此 Scene 会自动处理复杂的多窗格排列(列表、详情和额外窗格),并根据窗口大小和设备状态进行调整。

要创建 Material 列表-详情 Scene,请遵循以下步骤:

  1. 添加依赖项:在项目的 build.gradle.kts 文件中包含 androidx.compose.material3.adaptive:adaptive-navigation3
  2. 使用 ListDetailSceneStrategy 元数据定义您的条目:使用 listPane(), detailPane(), 和 extraPane() 来标记您的 NavEntry,以便进行适当的窗格显示。listPane() 辅助函数还允许您在未选择任何项时指定 detailPlaceholder
  3. 使用 rememberListDetailSceneStrategy():此可组合函数提供了一个预配置的 ListDetailSceneStrategy,可供 NavDisplay 使用。

以下代码片段是一个示例 Activity,演示了 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")
                        }
                    }
                )
            }
        }
    }
}

图 1. 在 Material 列表-详情场景中运行的示例内容。