使用場景建立自訂版面配置

Navigation 3 推出了強大且靈活的系統,可透過場景管理應用程式的 UI 流程。您可以使用場景建立高度客製化的版面配置、配合不同螢幕大小調整版面配置,以及順暢地管理複雜的多窗格體驗。

瞭解場景

在 Navigation 3 中,Scene 是用於轉譯一或多個 NavEntry 例項的基本單位。您可以將 Scene 視為 UI 的獨特視覺狀態或區段,可包含及管理來自後端堆疊的內容顯示。

每個 Scene 例項都由其 keyScene 本身的類別唯一識別。這個專屬 ID 非常重要,因為它會在 Scene 變更時驅動頂層動畫。

Scene 介面具有下列屬性:

  • key: Any:這個特定 Scene 例項的專屬 ID。這個鍵與 Scene 類別搭配使用,可確保區別性,主要用於動畫效果。
  • entries: List<NavEntry<T>>:這是 Scene 負責顯示的 NavEntry 物件清單。重要的是,如果在轉場期間 (例如在共用元素轉場) 有相同的 NavEntry 顯示在多個 Scenes 中,則只有最近顯示該元素的目標 Scene 才會算繪其內容。
  • previousEntries: List<NavEntry<T>>:這項屬性會定義在從目前 Scene 發生「back」動作時,所產生的 NavEntry。這對於計算適當的預測返回狀態至關重要,讓 NavDisplay 能夠預測並轉換至正確的上一個狀態,該狀態可能是具有不同類別和/或鍵的場景。
  • 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>?

這個方法會從返回堆疊和 onBack 回呼中取得目前的 List<NavEntry<T>>。如果能成功從提供的項目建立一個項目,則應傳回 Scene<T>;如果無法建立,則應傳回 null

SceneStrategy 也提供方便的 then 中綴函式,可讓您將多個策略串連在一起。這會建立靈活的決策管道,讓每個策略都能嘗試計算 Scene,如果無法計算,則會將權限委派給鏈結中的下一個策略。

場景和場景策略的搭配運作方式

NavDisplay 是觀察返回堆疊的核心可組合項,並使用 SceneStrategy 判斷並轉譯適當的 Scene

NavDisplay's sceneStrategy 參數會預期 SceneStrategy,負責計算要顯示的 Scene。如果提供的策略 (或策略鏈結) 未計算出 SceneNavDisplay 會自動改為預設使用 SinglePaneSceneStrategy

以下是互動情形的詳細資料:

  • 當您在返回堆疊中新增或移除鍵 (例如使用 backStack.add()backStack.removeLastOrNull()) 時,NavDisplay 會觀察這些變更。
  • NavDisplay 會將目前的 NavEntrys 清單 (衍生自後堆疊索引鍵) 傳遞至已設定的 SceneStrategy's calculateScene 方法。
  • 如果 SceneStrategy 成功傳回 SceneNavDisplay 就會轉譯該 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() 標示 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<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 清單詳細資料場景中執行的內容範例。