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

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

瞭解場景

在 Navigation 3 中,Scene 是算繪一或多個 NavEntry 執行個體的基本單位。您可以將 Scene 視為 UI 的獨特視覺狀態或區段,可包含及管理返回堆疊中的內容顯示。

每個 Scene 執行個體都由其 key 和 Scene 本身的類別唯一識別。這個專屬 ID 非常重要,因為當 Scene 變更時,它會驅動頂層動畫。

Scene 介面具有下列屬性:

  • key: Any:這個特定 Scene 執行個體的專屬 ID。這個鍵與 Scene 的類別結合,可確保獨特性,主要用於動畫。
  • entries: List<NavEntry<T>>:這是 Scene 負責顯示的 NavEntry 物件清單。重要的是,如果在轉換期間 (例如在共用元素轉換中) 多個 Scenes 顯示相同的 NavEntry,則其內容只會由顯示該內容的最新目標 Scene 轉譯。
  • previousEntries: List<NavEntry<T>>:這項屬性定義了從目前 Scene 執行「返回」動作時,會產生的 NavEntry。這對於計算正確的預測返回狀態至關重要,可讓 NavDisplay 預測並轉換至正確的上一個狀態,這可能是具有不同類別、索引鍵或兩者的 Scene。
  • content: @Composable () -> Unit:這個可組合函式用於定義 Scene 如何算繪其 entries,以及該 Scene 專屬的任何周圍 UI 元素。
  • 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 清單詳細資料場景中執行的範例內容。