Tạo bố cục tuỳ chỉnh bằng Cảnh

Navigation 3 giới thiệu một hệ thống mạnh mẽ và linh hoạt để quản lý quy trình giao diện người dùng của ứng dụng thông qua Cảnh. Cảnh cho phép bạn tạo bố cục có độ tuỳ chỉnh cao, thích ứng với nhiều kích thước màn hình và quản lý liền mạch các trải nghiệm phức tạp có nhiều ngăn.

Tìm hiểu về cảnh

Trong Navigation 3, Scene là đơn vị cơ bản kết xuất một hoặc nhiều thực thể NavEntry. Hãy coi Scene là một trạng thái hoặc phần riêng biệt trên giao diện người dùng, có thể chứa và quản lý việc hiển thị nội dung từ ngăn xếp sau của bạn.

Mỗi thực thể Scene được xác định riêng biệt bằng key và lớp của chính Scene. Giá trị nhận dạng riêng biệt này rất quan trọng vì nó điều khiển ảnh động cấp cao nhất khi Scene thay đổi.

Giao diện Scene có các thuộc tính sau:

  • key: Any: Giá trị nhận dạng duy nhất cho phiên bản Scene cụ thể này. Khoá này, kết hợp với lớp của Scene, đảm bảo tính riêng biệt, chủ yếu cho mục đích tạo ảnh động.
  • entries: List<NavEntry<T>>: Đây là danh sách các đối tượng NavEntryScene chịu trách nhiệm hiển thị. Điều quan trọng là nếu cùng một NavEntry xuất hiện trong nhiều Scenes trong quá trình chuyển đổi (ví dụ: trong quá trình chuyển đổi phần tử dùng chung), thì nội dung của phần tử đó sẽ chỉ được hiển thị bởi Scene đích gần đây nhất đang hiển thị phần tử đó.
  • previousEntries: List<NavEntry<T>>: Thuộc tính này xác định NavEntry sẽ xuất hiện nếu hành động "quay lại" xảy ra từ Scene hiện tại. Điều này là cần thiết để tính toán trạng thái xem trước thao tác quay lại thích hợp, cho phép NavDisplay dự đoán và chuyển sang trạng thái trước đó chính xác, có thể là một Cảnh có lớp và/hoặc khoá khác.
  • content: @Composable () -> Unit: Đây là hàm có khả năng kết hợp mà bạn xác định cách Scene kết xuất entries và mọi phần tử giao diện người dùng xung quanh dành riêng cho Scene đó.

Tìm hiểu các chiến lược về cảnh

SceneStrategy là cơ chế xác định cách sắp xếp và chuyển đổi một danh sách NavEntry nhất định từ ngăn xếp lui thành một Scene. Về cơ bản, khi được cung cấp các mục hiện tại trong ngăn xếp quay lại, SceneStrategy sẽ tự hỏi 2 câu hỏi chính:

  1. Tôi có thể tạo Scene từ những mục này không? Nếu SceneStrategy xác định rằng nó có thể xử lý NavEntry đã cho và tạo thành một Scene có ý nghĩa (ví dụ: hộp thoại hoặc bố cục nhiều ngăn), thì nó sẽ tiếp tục. Nếu không, hàm này sẽ trả về null, cho phép các chiến lược khác có cơ hội tạo ra một Scene.
  2. Nếu có, tôi nên sắp xếp những mục đó vào Scene? như thế nào? Sau khi SceneStrategy cam kết xử lý các mục, thì SceneStrategy sẽ chịu trách nhiệm tạo Scene và xác định cách NavEntry được chỉ định sẽ hiển thị trong Scene đó.

Cốt lõi của một SceneStrategy là phương thức calculateScene:

@Composable
public fun calculateScene(
    entries: List<NavEntry<T>>,
    onBack: (count: Int) -> Unit,
): Scene<T>?

Phương thức này là một hàm mở rộng trên SceneStrategyScope lấy List<NavEntry<T>> hiện tại từ ngăn xếp lùi. Phương thức này sẽ trả về Scene<T> nếu có thể tạo thành công một mục từ các mục đã cung cấp hoặc null nếu không thể.

SceneStrategyScope chịu trách nhiệm duy trì mọi đối số không bắt buộc mà SceneStrategy có thể cần, chẳng hạn như lệnh gọi lại onBack.

SceneStrategy cũng cung cấp một hàm trung tố then thuận tiện, cho phép bạn liên kết nhiều chiến lược với nhau. Thao tác này sẽ tạo ra một quy trình đưa ra quyết định linh hoạt, trong đó mỗi chiến lược có thể cố gắng tính toán một Scene và nếu không thể, chiến lược đó sẽ uỷ quyền cho chiến lược tiếp theo trong chuỗi.

Cách Cảnh và chiến lược cảnh phối hợp với nhau

NavDisplay là thành phần kết hợp trung tâm theo dõi ngăn xếp lui và sử dụng SceneStrategy để xác định và hiển thị Scene phù hợp.

Tham số NavDisplay's sceneStrategy dự kiến sẽ có một SceneStrategy chịu trách nhiệm tính toán Scene để hiển thị. Nếu không có Scene nào được tính toán theo chiến lược (hoặc chuỗi chiến lược) đã cung cấp, thì theo mặc định, NavDisplay sẽ tự động quay lại sử dụng SinglePaneSceneStrategy.

Sau đây là nội dung chi tiết về hoạt động tương tác:

  • Khi bạn thêm hoặc xoá các khoá khỏi ngăn xếp quay lại (ví dụ: bằng cách dùng backStack.add() hoặc backStack.removeLastOrNull()), NavDisplay sẽ theo dõi những thay đổi này.
  • NavDisplay truyền danh sách hiện tại gồm NavEntrys (xuất phát từ các khoá ngăn xếp sau) đến phương thức SceneStrategy's calculateScene đã định cấu hình.
  • Nếu SceneStrategy trả về thành công một Scene, thì NavDisplay sẽ hiển thị content của Scene đó. NavDisplay cũng quản lý ảnh động và thao tác xem trước khi quay lại dựa trên các thuộc tính của Scene.

Ví dụ: Bố cục một ngăn (hành vi mặc định)

Bố cục tuỳ chỉnh đơn giản nhất mà bạn có thể có là màn hình một ngăn. Đây là hành vi mặc định nếu không có SceneStrategy nào khác được ưu tiên.

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)
        )
}

Ví dụ: Bố cục cơ bản gồm 2 ngăn (Cảnh và chiến lược tuỳ chỉnh)

Ví dụ này minh hoạ cách tạo một bố cục đơn giản gồm 2 ngăn được kích hoạt dựa trên 2 điều kiện:

  1. Chiều rộng cửa sổ đủ rộng để hỗ trợ 2 ngăn (tức là ít nhất WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. Hai mục hàng đầu trong ngăn xếp lui khai báo rõ ràng việc hỗ trợ hiển thị trong bố cục 2 ngăn bằng siêu dữ liệu cụ thể.

Đoạn mã sau đây là mã nguồn kết hợp cho 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
        }
    }
}

Để sử dụng TwoPaneSceneStrategy này trong NavDisplay, hãy sửa đổi các lệnh gọi entryProvider để thêm siêu dữ liệu TwoPaneScene.twoPane() cho những mục mà bạn dự định hiển thị trong bố cục hai ngăn. Sau đó, hãy cung cấp TwoPaneSceneStrategy() làm sceneStrategy, dựa vào phương án dự phòng mặc định cho các trường hợp một ngăn:

// 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()
                }
            }
        }
    )
}

Hiển thị nội dung danh sách-chi tiết trong một Cảnh thích ứng Material

Đối với trường hợp sử dụng danh sách-chi tiết, cấu phần phần mềm androidx.compose.material3.adaptive:adaptive-navigation3 cung cấp một ListDetailSceneStrategy tạo ra một Scene danh sách-chi tiết. Scene này tự động xử lý các cách sắp xếp phức tạp gồm nhiều ngăn (danh sách, chi tiết và các ngăn bổ sung) và điều chỉnh các ngăn đó dựa trên kích thước cửa sổ và trạng thái thiết bị.

Để tạo một Scene danh sách-chi tiết theo Material, hãy làm theo các bước sau:

  1. Thêm phần phụ thuộc: Thêm androidx.compose.material3.adaptive:adaptive-navigation3 vào tệp build.gradle.kts của dự án.
  2. Xác định các mục bằng siêu dữ liệu ListDetailSceneStrategy: Sử dụng listPane(), detailPane()extraPane() để đánh dấu NavEntrys cho chế độ hiển thị ngăn thích hợp. Trình trợ giúp listPane() cũng cho phép bạn chỉ định một detailPlaceholder khi không có mục nào được chọn.
  3. Sử dụng rememberListDetailSceneStrategy(): Hàm có khả năng kết hợp này cung cấp một ListDetailSceneStrategy được định cấu hình sẵn mà NavDisplay có thể sử dụng.

Đoạn mã sau đây là một Activity mẫu minh hoạ cách sử dụng 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")
                        }
                    }
                )
            }
        }
    }
}

Hình 1. Nội dung mẫu đang chạy trong Cảnh chi tiết danh sách Material.