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ó tính 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 trải nghiệm nhiều ngăn phức tạp.

Tìm hiểu về Cảnh

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

Mỗi thực thể Scene được xác định duy nhất bằng key và lớp của chính Scene đó. Mã nhận dạng duy nhấ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: Mã nhận dạng duy nhất cho thực thể 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 là 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ị. Quan trọng là nếu cùng một NavEntry được hiển thị trong nhiều Scenes trong quá trình chuyển đổi (ví dụ: trong quá trình chuyển đổi phần tử được chia sẻ ), thì nội dung của nó sẽ chỉ được kết xuất bởi đích gần đây nhất Scene đang hiển thị nội dung đó.
  • previousEntries: List<NavEntry<T>>: Thuộc tính này xác định NavEntry sẽ xuất hiện nếu thao tác "quay lại" xảy ra từ Scene hiện tại. Thuộc tính này rất cần thiết để tính toán trạng thái quay lại dự đoán thích hợp, cho phép NavDisplay dự đoán và chuyển đổi sang trạng thái trước đó chính xác, có thể là Cảnh có lớp, khoá hoặc cả hai khác nhau.
  • content: @Composable () -> Unit: Đây là hàm kết hợp nơi 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.
  • metadata: Map<String, Any>: Cung cấp thông tin dành riêng cho cảnh cho các thành phần thư viện khác , chẳng hạn như NavDisplay. Theo mặc định, trả về metadata của NavEntry cuối cùng trong entries.

Triển khai equals và hashCode trong Cảnh tuỳ chỉnh

Việc triển khai Scene tuỳ chỉnh phải triển khai chính xác equalshashCode để hỗ trợ những điều sau:

  • Chuyển đổi trạng thái cảnh: Mỗi khi một cảnh được tính toán theo danh sách chiến lược cảnh, NavDisplay sẽ kiểm tra sự bằng nhau của cảnh mới và cảnh trước đó. Nếu phát hiện thấy sự thay đổi, thì thành phần này sẽ cập nhật SeekableTransition dùng để chuyển đổi giữa các cảnh, từ đó ảnh hưởng đến trạng thái vòng đời của các cảnh.
  • Quản lý lớp phủ: Đối với OverlayScene (chẳng hạn như hộp thoại), NavDisplay sử dụng chính đối tượng cảnh làm key để theo dõi vòng đời lớp phủ và ảnh động thoát.

Hãy xác minh rằng tất cả các thuộc tính xác định nội dung của cảnh, chẳng hạn như key, entriespreviousEntries, đều được đưa vào quá trình triển khai equalshashCode. Nói chung, hãy tránh đưa các lệnh gọi lại (chẳng hạn như onBack) vào quá trình triển khai các phương thức này vì chúng có thể thay đổi thực thể mà không thay đổi trạng thái cảnh logic.

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

A 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 Scene. Về cơ bản, khi được trình bày với các mục trong ngăn xếp lui hiện tại, SceneStrategy sẽ tự hỏi hai câu hỏi chính:

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

Cốt lõi của 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 hiện tại List<NavEntry<T>> từ ngăn xếp lui. Phương thức này sẽ trả về Scene<T> nếu có thể tạo thành công một phương thức từ các mục đượ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.

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 quan sát ngăn xếp lui và sử dụng một hoặc nhiều SceneStrategy để xác định và kết xuất Scene thích hợp.

Tham số sceneStrategies của NavDisplay's dự kiến sẽ có danh sách các thực thể 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 các chiến lược được cung cấp, thì theo mặc định, NavDisplay sẽ tự động quay lại sử dụng SinglePaneSceneStrategy.

Sau đây là thông tin chi tiết về hoạt động tương tác:

  • Khi bạn thêm hoặc xoá khoá khỏi ngăn xếp lui (ví dụ: sử dụng backStack.add() hoặc backStack.removeLastOrNull()), NavDisplay sẽ quan sát những thay đổi này.
  • The NavDisplay chuyển danh sách NavEntry hiện tại (bắt nguồn từ các khoá ngăn xếp lui) đến sceneStrategies đã định cấu hình theo thứ tự, gọi calculateScene trên từng khoá cho đến khi Scene được trả về.
  • Khi SceneStrategy trả về thành công Scene, NavDisplay sẽ kết xuất content của Scene đó. NavDisplay cũng quản lý ảnh động và tính năng quay lại dự đoán 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à bố cục 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> {
    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? =
        SinglePaneScene(
            key = entries.last().contentKey,
            entry = entries.last(),
            previousEntries = entries.dropLast(1)
        )
}

Ví dụ: Bố cục danh sách-chi tiết cơ bản (Cảnh và chiến lược tuỳ chỉnh)

Ví dụ này minh hoạ cách tạo bố cục danh sách-chi tiết đượ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 là WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. Ngăn xếp lui chứa các mục đã khai báo hỗ trợ hiển thị trong bố cục danh sách-chi tiết bằng cách sử dụng siêu dữ liệu cụ thể.

Đoạn mã sau đây là mã nguồn cho ListDetailScene.kt và nó chứa cả ListDetailSceneListDetailSceneStrategy:

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

Để sử dụng ListDetailSceneStrategy này trong NavDisplay, hãy sửa đổi các lệnh gọi entryProvider để đưa siêu dữ liệu ListDetailScene.listPane() vào mục mà bạn dự định hiển thị dưới dạng bố cục danh sáchListDetailScene.detailPane() cho mục mà bạn muốn hiển thị dưới dạng bố cục chi tiết. Sau đó, hãy cung cấp ListDetailSceneStrategy() 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 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)
}

Nếu không muốn tạo cảnh danh sách-chi tiết của riêng mình, bạn có thể sử dụng cảnh danh sách-chi tiết của Material, đi kèm với các chi tiết hợp lý và hỗ trợ trình giữ chỗ, như minh hoạ trong phần tiếp theo.

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

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

Để tạo Scene danh sách-chi tiết của 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 ListDetailSceneStrategy siêu dữ liệu: Sử dụng listPane(), detailPane(), và 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 detailPlaceholder khi không có mục nào được chọn.
  3. Sử dụng rememberListDetailSceneStrategy(): Hàm kết hợp này cung cấp ListDetailSceneStrategy được định cấu hình sẵn mà NavDisplay có thể sử dụng.

Đoạn mã sau đây là mẫu Activity 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<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")
                        }
                    }
                )
            }
        }
    }
}

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