使用场景创建自定义布局

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

了解场景

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

每个 Scene 实例都由其 key 和 Scene 本身的类唯一标识。此唯一标识符至关重要,因为当 Scene 发生变化时,它会驱动顶级动画。

Scene 接口具有以下属性:

  • key: Any:此特定 Scene 实例的唯一标识符。此键与 Scene 的类相结合,可确保唯一性,主要用于动画目的。
  • entries: List<NavEntry<T>>:这是 Scene 负责显示的 NavEntry 对象的列表。重要的是,如果同一 NavEntry 在过渡期间(例如,在共享元素过渡中)显示在多个 Scenes 中,其内容将仅由显示该内容的最新目标 Scene 呈现。
  • previousEntries: List<NavEntry<T>>:此属性定义了如果从当前 Scene 执行“返回”操作,会产生哪些 NavEntry。这对于计算正确的预测性返回状态至关重要,可让 NavDisplay 预测并转换到正确的先前状态,该状态可能是具有不同类、键或两者的 Scene。
  • content: @Composable () -> Unit:这是一个可组合函数,您可以在其中定义 Scene 如何呈现其 entries 以及特定于该 Scene 的任何周围界面元素。
  • metadata: Map<String, Any>:向其他库组件(例如 NavDisplay)提供场景特定信息。默认情况下,返回 entries 中最后一个 NavEntry 的 metadata。

在自定义场景中实现 equals 和 hashCode

自定义 Scene 实现必须正确实现 equals 和 hashCode,以支持以下功能:

  • 场景状态转换:每次通过场景策略列表计算场景时,NavDisplay 都会检查新场景和上一个场景是否相同。如果检测到更改,它会更新用于在场景之间转换的 SeekableTransition,这反过来会影响场景的生命周期状态。
  • 叠加层管理:对于 OverlayScene(如对话框),NavDisplay 会将场景对象本身用作 key,以跟踪叠加层生命周期和退出动画。

验证定义场景内容的所有属性(例如 key、entries 和 previousEntries)是否都包含在 equals 和 hashCode 实现中。通常,请避免在这些方法的实现中包含回调(例如 onBack),因为它们可以在不更改逻辑场景状态的情况下更改实例。

了解场景策略

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>?

此方法是 SceneStrategyScope 的扩展函数,可从返回堆栈中获取当前 List<NavEntry<T>>。如果可以从提供的条目中成功形成一个,则应返回 Scene<T>;否则,应返回 null。

SceneStrategyScope 负责维护 SceneStrategy 可能需要的任何可选参数,例如 onBack 回调。

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

NavDisplay 是一个中央可组合项,用于观察返回堆栈并使用一个或多个 SceneStrategy 来确定和呈现适当的 Scene。

NavDisplay 的 sceneStrategies 参数需要一个 SceneStrategy 实例列表,这些实例负责计算要显示的 Scene。如果提供的策略未计算出 Scene,NavDisplay 会默认自动回退到使用 SinglePaneSceneStrategy。

以下是互动过程的详细说明:

  • 当您在返回堆栈中添加或移除键时(例如,使用 backStack.add() 或 backStack.removeLastOrNull()),NavDisplay 会观察这些更改。
  • NavDisplay 会按顺序将当前的 NavEntry 列表(派生自返回 堆栈键)传递给已配置的 sceneStrategies,并对每个 调用 calculateScene,直到返回 Scene。
  • 当 SceneStrategy 成功返回 Scene 时,NavDisplay 随后会呈现该 Scene 的 content。NavDisplay 还会根据 Scene 的属性管理动画和预测性返回。

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

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

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> {
    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? =
        SinglePaneScene(
            key = entries.last().contentKey,
            entry = entries.last(),
            previousEntries = entries.dropLast(1)
        )
}

示例:基本列表-详情布局(自定义 Scene 和策略)

以下示例演示了如何创建基于两个条件激活的列表详情布局:

  1. 窗口宽度足够宽,可以支持两个窗格(即至少 WIDTH_DP_MEDIUM_LOWER_BOUND)。
  2. 返回堆栈包含已声明支持使用特定元数据显示在列表详情布局中的条目。

以下代码段是 ListDetailScene.kt 的源代码,其中包含 ListDetailScene 和 ListDetailSceneStrategy:

// --- ListDetailScene ---
/**
 * A [Scene] that displays a list and a detail [NavEntry] side-by-side in a 40/60 split.
 *
 */
data class ListDetailScene<T : Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val listEntry: NavEntry<T>,
    val detailEntry: NavEntry<T>,
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(listEntry, detailEntry)
    override val content: @Composable (() -> Unit) = {
        Row(modifier = Modifier.fillMaxSize()) {
            Column(modifier = Modifier.weight(0.4f)) {
                listEntry.Content()
            }
            Column(modifier = Modifier.weight(0.6f)) {
                detailEntry.Content()
            }
        }
    }
}

@Composable
fun <T : Any> rememberListDetailSceneStrategy(): ListDetailSceneStrategy<T> {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

    return remember(windowSizeClass) {
        ListDetailSceneStrategy(windowSizeClass)
    }
}

// --- ListDetailSceneStrategy ---
/**
 * A [SceneStrategy] that returns a [ListDetailScene] if the window is wide enough, the last item
 * is the backstack is a detail, and before it, at any point in the backstack is a list.
 */
class ListDetailSceneStrategy<T : Any>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {

    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {

        if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
            return null
        }

        val detailEntry =
            entries.lastOrNull()?.takeIf { it.metadata.contains(DetailKey) } ?: return null
        val listEntry = entries.findLast { it.metadata.contains(ListKey) } ?: return null

        // We use the list's contentKey to uniquely identify the scene.
        // This allows the detail panes to be displayed instantly through recomposition, rather than
        // having NavDisplay animate the whole scene out when the selected detail item changes.
        val sceneKey = listEntry.contentKey

        return ListDetailScene(
            key = sceneKey,
            previousEntries = entries.dropLast(1),
            listEntry = listEntry,
            detailEntry = detailEntry
        )
    }

    object ListKey : NavMetadataKey<Boolean>
    object DetailKey : NavMetadataKey<Boolean>
    companion object {

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun listPane() = metadata {
            put(ListKey, true)
        }

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun detailPane() = metadata {
            put(DetailKey, true)
        }
    }
}

如需在 NavDisplay 中使用此 ListDetailSceneStrategy,请修改 entryProvider 调用,以便为要显示为列表布局的条目添加 ListDetailScene.listPane() 元数据,并为要显示为详情布局的条目添加 ListDetailScene.detailPane()。然后,提供 ListDetailSceneStrategy() 作为 sceneStrategy,并依赖于单窗格场景的默认后备:

// Define your navigation keys
@Serializable
data object ConversationList : NavKey

@Serializable
data class ConversationDetail(val id: String) : NavKey

@Composable
fun MyAppContent() {
    val backStack = rememberNavBackStack(ConversationList)
    val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        sceneStrategies = listOf(listDetailStrategy),
        entryProvider = entryProvider {
            entry<ConversationList>(
                metadata = ListDetailSceneStrategy.listPane()
            ) {
                Column(modifier = Modifier.fillMaxSize()) {
                    Text(text = "I'm a Conversation List")
                    Button(onClick = { backStack.addDetail(ConversationDetail("123")) }) {
                        Text(text = "Open detail")
                    }
                }
            }
            entry<ConversationDetail>(
                metadata = ListDetailSceneStrategy.detailPane()
            ) {
                Text(text = "I'm a Conversation Detail")
            }
        }
    )
}

private fun NavBackStack<NavKey>.addDetail(detailRoute: ConversationDetail) {

    // Remove any existing detail routes, then add the new detail route
    removeIf { it is ConversationDetail }
    add(detailRoute)
}

如果您不想创建自己的列表-详情场景,可以使用 Material 列表-详情场景,该场景具有合理的细节并支持占位符,如下一部分所示。

在 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() 标记 NavEntrys,以便在适当的窗格中显示。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<NavKey>()

                NavDisplay(
                    backStack = backStack,
                    modifier = Modifier.padding(paddingValues),
                    onBack = { backStack.removeLastOrNull() },
                    sceneStrategies = listOf(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 列表-详情场景中运行的示例内容。