使用场景创建自定义布局

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

了解场景

在 Navigation 3 中,Scene 是用于呈现一个或多个 NavEntry 实例的基本单元。您可以将 Scene 视为界面的独特视觉状态或部分,它可以包含并管理来自返回堆栈的内容的显示。

每个 Scene 实例都通过其 keyScene 本身的类进行唯一标识。此唯一标识符至关重要,因为它会在 Scene 发生变化时驱动顶级动画。

Scene 接口具有以下属性:

  • key: Any:此特定 Scene 实例的唯一标识符。此键与 Scene 的类结合使用可确保独特性,主要用于动画用途。
  • entries: List<NavEntry<T>>:这是 Scene 负责显示的 NavEntry 对象的列表。请务必注意,如果在转换期间(例如在共享元素转换期间)同一 NavEntry 显示在多个 Scenes 中,则其内容将仅由最近显示该 NavEntry 的目标 Scene 呈现。
  • previousEntries: List<NavEntry<T>>:此属性用于定义在当前 Scene 中发生“返回”操作时会产生的 NavEntry。这对于计算适当的预测性返回状态至关重要,可让 NavDisplay 预测并转换到正确的上一个状态,该状态可能是具有不同类和/或键的场景。
  • content: @Composable () -> Unit:这是可组合函数,您可以在其中定义 Scene 如何呈现其 entries 以及特定于该 Scene 的任何周围界面元素。

了解场景策略

SceneStrategy 是一种机制,用于确定如何对返回堆栈中的给定 NavEntry 列表进行排列并转换为 Scene。从本质上讲,在看到当前返回堆栈条目时,SceneStrategy 会自问两个关键问题:

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

SceneStrategy 的核心是其 calculateScene 方法:

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

此方法会从返回堆栈中获取当前 List<NavEntry<T>>onBack 回调。如果它可以根据提供的条目成功构成一个,则应返回 Scene<T>;如果无法构成,则应返回 null

SceneStrategy 还提供了方便的 then 中缀函数,可让您将多个策略链接在一起。这会创建一个灵活的决策流水线,其中每个策略都可以尝试计算 Scene,如果无法计算,则会委托给链中的下一个策略。

场景和场景策略如何协同发挥作用

NavDisplay 是中心可组合项,用于监控返回堆栈并使用 SceneStrategy 确定和呈现适当的 Scene

NavDisplay's sceneStrategy 参数需要一个 SceneStrategy,该 SceneStrategy 负责计算要显示的 Scene。如果所提供的策略(或策略链)未计算出 SceneNavDisplay 会默认自动回退为使用 SinglePaneSceneStrategy

下面详细介绍了此互动过程:

  • 当您向返回堆栈添加或从中移除键(例如,使用 backStack.add()backStack.removeLastOrNull())时,NavDisplay 会观察这些更改。
  • NavDisplay 会将当前的 NavEntrys 列表(从返回堆栈键派生)传递给已配置的 SceneStrategy's calculateScene 方法。
  • 如果 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 工件提供了用于创建列表-详情 SceneListDetailSceneStrategy。此 Scene 会自动处理复杂的多窗格布局(列表、详情和额外窗格),并根据窗口大小和设备状态进行调整。

如需创建材质列表-详情 Scene,请按以下步骤操作:

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

以下代码段是一个 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 列表-详情场景中运行的示例内容。