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

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

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

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

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

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

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

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

SceneStrategy คือกลไกที่กำหนดวิธีจัดเรียงรายการ NavEntry จากกองซ้อนด้านหลังและเปลี่ยนเป็น 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>?

วิธีนี้จะนํา List<NavEntry<T>> ปัจจุบันจากกองซ้อนด้านหลังและ onBack การเรียกกลับ โดยควรแสดงผลเป็น Scene<T> หากสร้างรายการได้สําเร็จจากรายการที่ระบุ หรือ null หากสร้างไม่ได้

SceneStrategy ยังมีฟังก์ชัน then อินฟิกซ์ที่สะดวก ซึ่งช่วยให้คุณต่อเชื่อมกลยุทธ์หลายรายการเข้าด้วยกันได้ วิธีนี้สร้างไปป์ไลน์การทําการตัดสินใจที่ยืดหยุ่น ซึ่งกลยุทธ์แต่ละรายการจะพยายามคํานวณ Scene ได้ และหากคํานวณไม่ได้ ก็จะมอบหมายให้กลยุทธ์ถัดไปในเชน

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

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

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

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

  • เมื่อคุณเพิ่มหรือนำคีย์ออกจากกองซ้อนที่ซ้อนกัน (เช่น ใช้ backStack.add() หรือ backStack.removeLastOrNull()) NavDisplay จะสังเกตการเปลี่ยนแปลงเหล่านี้
  • NavDisplay จะส่งรายการ NavEntrys ปัจจุบัน (ที่มาจากคีย์สแต็กด้านหลัง) ไปยังเมธอด SceneStrategy's calculateScene ที่กําหนดค่าไว้
  • หาก SceneStrategy แสดงผล Scene ได้สําเร็จ NavDisplay จะแสดงผล content ของ Scene นั้น นอกจากนี้ NavDisplay ยังจัดการภาพเคลื่อนไหวและการย้อนกลับแบบคาดการณ์ตามพร็อพเพอร์ตี้ของ Scene ด้วย

ตัวอย่าง: เลย์เอาต์แบบหน้าจอเดียว (ลักษณะการทำงานเริ่มต้น)

เลย์เอาต์ที่กำหนดเองที่ง่ายที่สุดที่คุณมีได้คือการแสดงผลแบบหน้าจอเดียว ซึ่งเป็นลักษณะการทำงานเริ่มต้นหากไม่มี SceneStrategy อื่นใดมาก่อน

data class SinglePaneScene<T : Any>(
    override val key: T,
    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.invoke(entry.key) }
}

/**
 * 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().key,
            entry = entries.last(),
            previousEntries = entries.dropLast(1)
        )
}

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

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

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

ข้อมูลโค้ดต่อไปนี้คือซอร์สโค้ดที่รวมกันของ TwoPaneScene.kt และ TwoPaneSceneStrategy.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.invoke(firstEntry.key)
            }
            Column(modifier = Modifier.weight(0.5f)) {
                secondEntry.content.invoke(secondEntry.key)
            }
        }
    }

    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.key, secondEntry.key)

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

หากต้องการใช้ TwoPaneSceneStrategy นี้ใน NavDisplay ให้แก้ไขการเรียกใช้ entryProvider ให้รวมข้อมูลเมตา TwoPaneScene.twoPane() ของรายการที่คุณต้องการแสดงในเลย์เอาต์แบบ 2 แผง จากนั้นให้ป้อน TwoPaneSceneStrategy() เป็น sceneStrategy โดยอาศัยค่าเริ่มต้นสำหรับการสำรองสำหรับสถานการณ์แบบหน้าจอเดียว

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

แสดงเนื้อหารายละเอียดรายการในฉากแบบปรับเปลี่ยนได้ของ Material

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

หากต้องการสร้างรายละเอียดรายการวัสดุ 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<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")
                        }
                    }
                )
            }
        }
    }
}

รูปที่ 1 ตัวอย่างเนื้อหาที่แสดงในฉาก Material list-detail