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

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 中 (例如在共用元素轉場中),系統只會由顯示該 NavEntry 的最新目標 Scene 算繪其內容。
  • previousEntries: List<NavEntry<T>>:這項屬性會定義如果從目前的 Scene 發生「返回」動作,會產生哪些 NavEntry。這對於計算正確的預測返回狀態至關重要,可讓 NavDisplay 預測並轉換至正確的先前狀態,這可能是具有不同類別和/或鍵的場景。
  • content: @Composable () -> Unit:這是可組合函式,您可以在其中定義 Scene 如何算繪 entries,以及該 Scene 特有的任何周圍 UI 元素。

瞭解場景策略

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 回呼。

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

場景和場景策略如何搭配運作

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

NavDisplay's sceneStrategy 參數預期會收到負責計算要顯示的 SceneSceneStrategy。如果提供的策略 (或策略鏈) 未計算出 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: 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> {
    @Composable
    override fun calculateScene(entries: List<NavEntry<T>>, onBack: (Int) -> Unit): Scene<T> =
        SinglePaneScene(
            key = entries.last().contentKey,
            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()
            }
            Column(modifier = Modifier.weight(0.5f)) {
                secondEntry.Content()
            }
        }
    }

    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.contentKey, secondEntry.contentKey)

            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 清單詳細資料場景中執行的內容。�