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

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

範例:基本清單/詳細資料版面配置 (自訂場景和策略)

本範例說明如何建立簡單的清單詳細資料版面配置,並根據下列兩種情況啟用:

  1. 視窗寬度足夠支援兩個窗格 (即至少 WIDTH_DP_MEDIUM_LOWER_BOUND)。
  2. 返回堆疊包含已宣告支援使用特定中繼資料顯示在清單詳細資料版面配置中的項目。

以下程式碼片段是 ListDetailScene.kt 的原始碼,其中包含 ListDetailSceneListDetailSceneStrategy

// --- ListDetailScene ---
/**
 * A [Scene] that displays a list and a detail [NavEntry] side-by-side in a 40/60 split.
 *
 */
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.containsKey(DETAIL_KEY) } ?: return null
        val listEntry = entries.findLast { it.metadata.containsKey(LIST_KEY) } ?: 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
        )
    }

    companion object {
        internal const val LIST_KEY = "ListDetailScene-List"
        internal const val DETAIL_KEY = "ListDetailScene-Detail"

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

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun detailPane() = mapOf(DETAIL_KEY to 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() },
        sceneStrategy = 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() },
                    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 清單詳細資料場景中執行的內容。�