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

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

הסבר על סצנות

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

כל מכונה של 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.
  • ‫metadata: Map<String, Any>: מספק מידע ספציפי לסצנה לרכיבי ספרייה אחרים כמו NavDisplay. כברירת מחדל, הפונקציה מחזירה את ה-metadata של ה-NavEntry האחרון ב-entries.

הטמעה של equals ו-hashCode בסצנות בהתאמה אישית

בהטמעות בהתאמה אישית של Scene, צריך להטמיע בצורה נכונה את equals ו-hashCode כדי לתמוך בפעולות הבאות:

  • מעברים בין מצבי סצנה: בכל פעם שסצנה מחושבת לפי רשימת האסטרטגיות של הסצנה, NavDisplay בודק את השוויון בין הסצנה החדשה לבין הסצנה הקודמת. אם הוא מזהה שינוי, הוא מעדכן את SeekableTransition שמשמש למעבר בין סצנות, וזה משפיע על מצב מחזור החיים של הסצנות.
  • ניהול שכבות-על: בשביל OverlayScene (כמו תיבות דו-שיח), NavDisplay משתמש באובייקט הסצנה עצמו בתור key כדי לעקוב אחרי מחזורי החיים של שכבות-העל ואנימציות היציאה.

מוודאים שכל המאפיינים שמגדירים את תוכן הסצנה, כמו key, entries ו-previousEntries, נכללים בהטמעות של equals ו-hashCode. באופן כללי, כדאי להימנע מהכללת קריאות חוזרות (כמו onBack) בהטמעה של השיטות האלה, כי הן יכולות לשנות מופעים בלי לשנות את המצב הלוגי של הסצנה.

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

SceneStrategy הוא המנגנון שקובע איך צריך לסדר רשימה נתונה של NavEntry ממקבץ הפעילויות הקודמות (back stack) ולהעביר אותה אל Scene. בעיקרון, כשמוצגים הערכים הנוכחיים של מקבץ הפעילויות הקודמות, SceneStrategy שואל את עצמו שתי שאלות עיקריות:

  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>> הנוכחי ממקבץ הפעילויות הקודמות (back stack). היא אמורה להחזיר Scene<T> אם היא יכולה ליצור אחת מהרשומות שסופקו, או null אם היא לא יכולה.

ה-SceneStrategyScope אחראי על תחזוקת כל הארגומנטים האופציונליים שה-SceneStrategy עשוי להזדקק להם, כמו קריאה חוזרת (callback) של onBack.

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

‫NavDisplay הוא ה-composable המרכזי שצופה במקבץ הפעילויות הקודמות (back stack) ומשתמש ב-SceneStrategy אחד או יותר כדי לקבוע ולעבד את ה-Scene המתאים.

הפרמטר sceneStrategies של NavDisplay מצפה לרשימה של מופעי SceneStrategy שאחראים לחישוב של Scene שיוצג. אם לא מחושב Scene לפי השיטות שצוינו, NavDisplay חוזרת באופן אוטומטי להשתמש ב-SinglePaneSceneStrategy כברירת מחדל.

הנה פירוט של האינטראקציה:

  • כשמוסיפים או מסירים מפתחות ממקבץ הפעילויות הקודמות (למשל, באמצעות 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)
        )
}

דוגמה: פריסה בסיסית של רשימה ופרטים (Scene ואסטרטגיה בהתאמה אישית)

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

  1. רוחב החלון מספיק רחב כדי לתמוך בשני חלונות (כלומר, לפחות WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. מקבץ הפעילויות הקודמות מכיל רשומות שהצהירו על תמיכה בהצגה בפריסה של רשימה ופרטים באמצעות מטא-נתונים ספציפיים.

קטע הקוד הבא הוא קוד המקור של 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

בתרחיש לדוגמה של רשימה-פרטים, פריט המידע שנוצר בתהליך הפיתוח (Artifact) androidx.compose.material3.adaptive:adaptive-navigation3 מספק ListDetailSceneStrategy שיוצר Scene של רשימה-פרטים. הרכיב Scene מטפל באופן אוטומטי בסידורים מורכבים של כמה חלוניות (רשימה, פרטים וחלוניות נוספות) ומתאים אותם לפי גודל החלון ומצב המכשיר.

כדי ליצור רשימת פרטים ב-Material 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<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.