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

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

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

ใน 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 ที่มีคลาส คีย์ หรือทั้ง 2 อย่างที่แตกต่างกัน
  • content: @Composable () -> Unit: นี่คือฟังก์ชันที่ใช้ร่วมกันได้ซึ่ง คุณกำหนดวิธีที่ Scene แสดงผล entries และองค์ประกอบ UI โดยรอบ ที่เฉพาะเจาะจงกับ Scene นั้น
  • metadata: Map<String, Any>: ให้ข้อมูลเฉพาะของฉากแก่คอมโพเนนต์อื่นๆ ของไลบรารี เช่น NavDisplay โดยค่าเริ่มต้น จะแสดงผล metadata ของ NavEntry สุดท้ายใน entries

ใช้ equals และ hashCode ในฉากที่กำหนดเอง

การติดตั้งใช้งาน Scene ที่กำหนดเองต้องใช้ equals และ hashCode อย่างถูกต้องเพื่อรองรับสิ่งต่อไปนี้

  • การเปลี่ยนสถานะของฉาก: ทุกครั้งที่รายการกลยุทธ์ของ ฉากคำนวณฉาก NavDisplay จะตรวจสอบความเท่ากันของฉากใหม่และฉาก ก่อนหน้า หากตรวจพบการเปลี่ยนแปลง ระบบจะอัปเดต SeekableTransition ที่ใช้ในการเปลี่ยนผ่านระหว่างฉาก ซึ่งจะส่งผลต่อสถานะวงจรการทำงานของฉาก
  • การจัดการการวางซ้อน: สำหรับ OverlayScene (เช่น กล่องโต้ตอบ) NavDisplay จะใช้ออบเจ็กต์ฉากเองเป็น key เพื่อติดตามวงจรการทำงานของการวางซ้อนและแอนิเมชันการออก

ตรวจสอบว่าพร็อพเพอร์ตี้ทั้งหมดที่กำหนดเนื้อหาของฉาก เช่น key, entries และ previousEntries รวมอยู่ในการติดตั้งใช้งาน equals และ hashCode โดยทั่วไป ให้หลีกเลี่ยงการรวมการเรียกกลับ (เช่น onBack) ในการติดตั้งใช้งานเมธอดเหล่านี้ เนื่องจากเมธอดเหล่านี้สามารถเปลี่ยนอินสแตนซ์ได้โดยไม่เปลี่ยนสถานะฉากเชิงตรรกะ

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

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

  1. ฉันสร้าง Scene จากรายการเหล่านี้ได้ไหม หาก SceneStrategy กำหนดว่าสามารถจัดการ NavEntrys ที่กำหนดและสร้าง Scene ที่มีความหมาย (เช่น กล่องโต้ตอบหรือเลย์เอาต์หลายบานหน้าต่าง) ได้ ระบบจะดำเนินการต่อ มิฉะนั้น ระบบจะแสดงผล null เพื่อให้กลยุทธ์อื่นๆ มีโอกาสสร้าง Scene
  2. หากทำได้ ฉันควรจัดเรียงรายการเหล่านั้นเป็น Scene? อย่างไร เมื่อ SceneStrategy ตัดสินใจที่จะจัดการรายการแล้ว กลยุทธ์นั้นจะรับผิดชอบในการสร้าง Scene และกำหนดวิธีแสดง NavEntrys ที่ระบุภายใน Scene นั้น

หัวใจสำคัญของ SceneStrategy คือเมธอด calculateScene

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

เมธอดนี้เป็นฟังก์ชันส่วนขยายใน SceneStrategyScope ซึ่งใช้ List<NavEntry<T>> ปัจจุบันจาก Back Stack เมธอดนี้ควรแสดงผล Scene<T> หากสร้างได้สำเร็จจากรายการที่ให้ไว้ หรือ null หาก สร้างไม่ได้

SceneStrategyScope มีหน้าที่รับผิดชอบในการดูแลอาร์กิวเมนต์ที่ไม่บังคับ ซึ่ง SceneStrategy อาจต้องใช้ เช่น การเรียกกลับ onBack

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

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

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

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

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

หากต้องการใช้ 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

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

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

  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