יצירת פריסות בהתאמה אישית באמצעות סצנות

ב-Navigation 3 מוצגת מערכת חזקה וגמישה לניהול התהליך של ממשק המשתמש באפליקציה באמצעות סצנות. בעזרת סצנות אפשר ליצור פריסות בהתאמה אישית גבוהה, להתאים את התצוגה לגדלים שונים של מסכים ולנהל בקלות חוויות מורכבות עם כמה חלונות.

הסבר על סצנות

ב-Navigation 3, Scene היא היחידה הבסיסית שמרינדרת מופע אחד או יותר של NavEntry. אפשר לחשוב על Scene כמצב חזותי או קטע ספציפי בממשק המשתמש, שיכול להכיל ולנהל את הצגת התוכן מהמקבץ האחורי.

כל מכונה של Scene מזוהה באופן ייחודי באמצעות key שלה והכיתה של ה-Scene עצמו. המזהה הייחודי הזה חיוני כי הוא מניע את האנימציה ברמה העליונה כשהערך של Scene משתנה.

לממשק Scene יש את המאפיינים הבאים:

  • key: Any: מזהה ייחודי של מופע Scene הספציפי הזה. המפתח הזה, בשילוב עם הכיתה של Scene, מבטיח את הייחודיות, בעיקר למטרות אנימציה.
  • entries: List<NavEntry<T>>: זוהי רשימה של אובייקטים מסוג NavEntry שה-Scene אחראי להציג. חשוב לדעת: אם אותו NavEntry מוצג בכמה Scenes במהלך מעבר (למשל, במעבר של רכיב משותף), התוכן שלו יומר רק על ידי היעד Scene האחרון שמוצג בו.
  • previousEntries: List<NavEntry<T>>: המאפיין הזה מגדיר את הערכים של NavEntry שיגרמו אם תתבצע פעולת 'חזרה' מה-Scene הנוכחי. הוא חיוני לחישוב המצב החזוי הנכון של החזרה אחורה, ומאפשר ל-NavDisplay לחזות את המצב הקודם הנכון ולעבור אליו, שיכול להיות סצנה עם מפתח או סיווג שונים.
  • content: @Composable () -> Unit: זוהי הפונקציה הניתנת ליצירה, שבה מגדירים איך Scene ידפיס את entries ואת כל רכיבי ממשק המשתמש שמסביב שספציפיים ל-Scene הזה.

הסבר על אסטרטגיות סצנה

SceneStrategy הוא המנגנון שקובע איך צריך לארגן רשימה נתונה של NavEntrys מסטאק העורפי ולהעביר אותה ל-Scene. בעיקרון, כשמוצגים לו הרשומות הנוכחיות ב-back stack, SceneStrategy שואל את עצמו שתי שאלות מפתח:

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

השיטה הזו מקבלת את List<NavEntry<T>> הנוכחי מסטאק העורפי ופונקציית קריאה חוזרת onBack. הפונקציה אמורה להחזיר Scene<T> אם היא יכולה ליצור קבוצה אחת מהרשומות שסופקו, או null אם היא לא יכולה.

SceneStrategy מספק גם פונקציית infix נוחה של then, שמאפשרת לשרשר כמה אסטרטגיות יחד. כך נוצר צינור גמיש לקבלת החלטות, שבו כל אסטרטגיה יכולה לנסות לחשב את הערך של Scene, ואם היא לא מצליחה, היא מעבירה את הבעיה לאסטרטגיה הבאה בשרשרת.

איך סצנות ואסטרטגיות סצנות פועלות יחד

NavDisplay הוא הרכיב המרכזי ב-Composable, שמנטר את סטאק הקוד הקודם ומשתמש ב-SceneStrategy כדי לקבוע ולייצר את Scene המתאים.

הפרמטר NavDisplay's sceneStrategy מצפה ל-SceneStrategy שאחראי לחישוב הערך של Scene להצגה. אם לא מתבצע חישוב של Scene לפי האסטרטגיה שצוינה (או לפי שרשרת האסטרטגיות), NavDisplay חוזר אוטומטית לשימוש ב-SinglePaneSceneStrategy כברירת מחדל.

זהו פירוט האינטראקציה:

  • כשמוסיפים או מסירים מפתחות מהמקבץ הקודם (למשל, באמצעות backStack.add() או backStack.removeLastOrNull()), השינויים האלה מתועדים ב-NavDisplay.
  • הפונקציה NavDisplay מעבירה את הרשימה הנוכחית של NavEntrys (שנגזרת ממפתחות ה-stack האחורי) לשיטה 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)
        )
}

דוגמה: פריסה בסיסית של שני חלונות (סצנה ואסטרטגיה בהתאמה אישית)

בדוגמה הזו מוסבר איך ליצור פריסה פשוטה של שני חלונות, שמופעל בהתאם לשני תנאים:

  1. רוחב החלון מספיק כדי לתמוך בשתי חלוניות (כלומר, לפחות WIDTH_DP_MEDIUM_LOWER_BOUND).
  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() עבור הרשומות שאתם רוצים להציג בפריסה של שתי חלוניות. לאחר מכן, מציינים את 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. מוסיפים את התלות: כוללים את 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 Design.