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

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

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

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

อินสแตนซ์ 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 นั้น

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

SceneStrategy คือกลไกที่กำหนดวิธีจัดเรียงรายการ NavEntry จาก Back Stack และเปลี่ยนไปเป็น Scene โดยพื้นฐานแล้ว เมื่อได้รับรายการในสแต็กย้อนกลับปัจจุบัน a 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

SceneStrategy ยังมีthenฟังก์ชัน Infix ที่สะดวกสบาย ซึ่งช่วยให้คุณ เชื่อมโยงกลยุทธ์หลายๆ อย่างเข้าด้วยกันได้ ซึ่งจะสร้างไปป์ไลน์การตัดสินใจที่ยืดหยุ่น ซึ่งแต่ละกลยุทธ์จะพยายามคำนวณ 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: 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 บานหน้าต่างพื้นฐาน (ฉากและกลยุทธ์ที่กำหนดเอง)

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

  1. ความกว้างของหน้าต่างกว้างพอที่จะรองรับ 2 แผง (เช่น อย่างน้อย WIDTH_DP_MEDIUM_LOWER_BOUND)
  2. รายการ 2 อันดับแรกใน Back Stack จะประกาศอย่างชัดเจนว่ารองรับการแสดงในเลย์เอาต์แบบ 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()
            }
            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)
    }
}

@Composable
fun <T : Any> rememberTwoPaneSceneStrategy(): TwoPaneSceneStrategy<T> {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

    return remember(windowSizeClass) {
        TwoPaneSceneStrategy(windowSizeClass)
    }
}

// --- 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>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {
    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {
        // 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
        }
    }
}

หากต้องการใช้ 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 = rememberTwoPaneSceneStrategy(),
        onBack = {
            if (backStack.isNotEmpty()) {
                backStack.removeLastOrNull()
            }
        }
    )
}

แสดงเนื้อหารายละเอียดรายการใน Material Adaptive Scene

สำหรับกรณีการใช้งานแบบรายการ-รายละเอียด อาร์ติแฟกต์ 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() },
                    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