สร้างเลย์เอาต์ที่กำหนดเองโดยใช้ฉาก

การนำทาง 3 ขอแนะนำระบบที่มีประสิทธิภาพและยืดหยุ่นสำหรับการจัดการโฟลว์ UI ของแอปผ่าน Scene โดยฉากจะช่วยให้คุณสร้างเลย์เอาต์ที่ปรับแต่งได้สูง ปรับให้เข้ากับขนาดหน้าจอต่างๆ และจัดการประสบการณ์แบบหลายบานหน้าต่างที่ซับซ้อน ได้อย่างราบรื่น

ทำความเข้าใจฉาก

ใน Navigation 3 Scene คือหน่วยพื้นฐานที่แสดงผลอินสแตนซ์ NavEntry อย่างน้อย 1 รายการ ให้คิดว่า Scene เป็นสถานะภาพที่แตกต่างกันหรือส่วนของ UI ที่สามารถจัดเก็บและจัดการการแสดงเนื้อหาจาก Back Stack

อินสแตนซ์ Scene แต่ละรายการจะระบุโดยไม่ซ้ำกันด้วย key และคลาสของ Scene เอง ตัวระบุที่ไม่ซ้ำกันนี้มีความสำคัญอย่างยิ่งเนื่องจากเป็นตัวขับเคลื่อนภาพเคลื่อนไหวระดับบนสุดเมื่อ Scene เปลี่ยนแปลง

อินเทอร์เฟซ Scene มีพร็อพเพอร์ตี้ต่อไปนี้

  • key: Any: ตัวระบุที่ไม่ซ้ำกันสำหรับอินสแตนซ์ Scene นี้ คีย์นี้เมื่อรวมกับคลาสของ Scene จะช่วยให้มั่นใจได้ถึงความแตกต่าง โดยมีวัตถุประสงค์หลักเพื่อ การสร้างภาพเคลื่อนไหว
  • entries: List<NavEntry<T>>: นี่คือรายการออบเจ็กต์ NavEntry ที่Scene มีหน้าที่แสดง สิ่งสำคัญคือ หากNavEntry เดียวกันแสดงในScenesหลายรายการระหว่างการเปลี่ยนผ่าน (เช่น ในการเปลี่ยนภาพองค์ประกอบที่แชร์ ) เนื้อหาของNavEntryจะแสดงเฉพาะในSceneเป้าหมายล่าสุด ที่แสดงNavEntry
  • previousEntries: List<NavEntry<T>>: พร็อพเพอร์ตี้นี้กำหนดNavEntryที่จะเกิดขึ้นหากมีการดำเนินการ "ย้อนกลับ" จากSceneปัจจุบัน ซึ่งจำเป็นต่อการคำนวณสถานะย้อนกลับแบบคาดการณ์ที่เหมาะสม เพื่อให้ NavDisplay คาดการณ์และเปลี่ยนไปยังสถานะก่อนหน้าที่ถูกต้องได้ ซึ่งอาจเป็น Scene ที่มีคลาสและ/หรือคีย์ต่างกัน
  • content: @Composable () -> Unit: นี่คือฟังก์ชันที่ประกอบกันได้ซึ่งคุณกำหนดวิธีที่ Scene แสดง entries และองค์ประกอบ UI โดยรอบที่เฉพาะเจาะจงสำหรับ Scene นั้น
  • metadata: Map<String, Any>: ให้ข้อมูลเฉพาะฉากแก่คอมโพเนนต์อื่นๆ ของไลบรารี เช่น NavDisplay โดยค่าเริ่มต้น จะแสดง metadata ของ NavEntry สุดท้ายใน entries

ทำความเข้าใจกลยุทธ์ฉาก

SceneStrategy คือกลไกที่กำหนดวิธีจัดเรียงรายการ NavEntry จาก Back Stack และเปลี่ยนไปเป็น Scene โดยพื้นฐานแล้ว เมื่อได้รับรายการในสแต็กย้อนกลับปัจจุบัน SceneStrategy จะถามตัวเองด้วยคำถามสำคัญ 2 ข้อต่อไปนี้

  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 Callback

ฉากและกลยุทธ์ฉากทำงานร่วมกันอย่างไร

NavDisplay เป็น Composable ส่วนกลางที่สังเกต Back Stack และใช้ SceneStrategy อย่างน้อย 1 รายการเพื่อกำหนดและแสดงผล Scene ที่เหมาะสม

พารามิเตอร์ NavDisplay's sceneStrategies คาดหวังรายการของSceneStrategy อินสแตนซ์ที่รับผิดชอบในการคำนวณ Scene เพื่อแสดง หากระบบไม่สามารถคำนวณ Sceneตามกลยุทธ์ที่ระบุ NavDisplay จะเปลี่ยนไปใช้SinglePaneSceneStrategyโดยอัตโนมัติ โดยค่าเริ่มต้น

รายละเอียดของการโต้ตอบมีดังนี้

  • เมื่อคุณเพิ่มหรือนำคีย์ออกจาก Back Stack (เช่น ใช้ backStack.add() หรือ backStack.removeLastOrNull()) NavDisplay จะสังเกตการเปลี่ยนแปลงเหล่านี้
  • NavDisplay จะส่งรายการ NavEntry ปัจจุบัน (ได้มาจากคีย์ในแบ็กสแต็ก) ไปยัง sceneStrategies ที่กำหนดค่าไว้ตามลำดับ โดยจะเรียกใช้ calculateScene ในแต่ละรายการจนกว่าจะได้รับ Scene
  • เมื่อ SceneStrategy ส่งคืน Scene สำเร็จ NavDisplay จะแสดง content ของ Scene นั้น นอกจากนี้ 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)
        )
}

ตัวอย่าง: เลย์เอาต์รายละเอียดรายการพื้นฐาน (ฉากและกลยุทธ์ที่กำหนดเอง)

ตัวอย่างนี้แสดงวิธีสร้างเลย์เอาต์รายละเอียดรายการอย่างง่ายที่เปิดใช้งานตามเงื่อนไข 2 ข้อ

  1. ความกว้างของหน้าต่างกว้างพอที่จะรองรับ 2 แผง (เช่น อย่างน้อย WIDTH_DP_MEDIUM_LOWER_BOUND)
  2. Back Stack มีรายการที่ประกาศการรองรับการแสดงในเลย์เอาต์รายละเอียดรายการโดยใช้ข้อมูลเมตาที่เฉพาะเจาะจง

ข้อมูลโค้ดต่อไปนี้คือซอร์สโค้ดสำหรับ ListDetailScene.kt และมีทั้ง ListDetailScene และ ListDetailSceneStrategy

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

หากต้องการใช้ ListDetailSceneStrategy ใน NavDisplay ให้แก้ไขการเรียก 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

สำหรับUse Case รายละเอียดรายการ อาร์ติแฟกต์ androidx.compose.material3.adaptive:adaptive-navigation3 จะมี ListDetailSceneStrategy ที่สร้างSceneรายละเอียดรายการ ซึ่งจะScene จัดการการจัดเรียงแบบหลายบานหน้าต่างที่ซับซ้อน (รายการ รายละเอียด และบานหน้าต่างเพิ่มเติม) โดยอัตโนมัติ และปรับให้เหมาะกับขนาดหน้าต่างและสถานะของอุปกรณ์

หากต้องการสร้างรายละเอียดรายการของ Material Scene ให้ทำตามขั้นตอนต่อไปนี้

  1. เพิ่มทรัพยากร Dependency: ใส่ androidx.compose.material3.adaptive:adaptive-navigation3 ในไฟล์ build.gradle.kts ของโปรเจ็กต์
  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