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

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

הסבר על סצנות

ב-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>>: המאפיין הזה מגדיר את NavEntrys שיוצגו אם תתבצע פעולת 'חזרה' מה-Scene הנוכחי. הוא חיוני לחישוב מצב החזרה הקודם החזוי הנכון, ומאפשר ל-NavDisplay לצפות את המצב הקודם הנכון ולעבור אליו, שיכול להיות Scene עם מחלקה או מפתח שונים.
  • content: @Composable () -> Unit: זו פונקציה קומפוזבילית שבה מגדירים איך הרכיב Scene מרנדר את הרכיב entries ואת כל רכיבי ממשק המשתמש שמסביב שספציפיים לרכיב Scene.
  • metadata: Map<String, Any>: מספק מידע ספציפי לסצנה לרכיבים אחרים בספרייה, כמו NavDisplay. כברירת מחדל, הפונקציה מחזירה את הערך metadata של NavEntry האחרון ב-entries.

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

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

  1. האם אפשר ליצור Scene מהערכים האלה? אם SceneStrategy קובע שהוא יכול לטפל בNavEntry הנתון וליצור Scene משמעותי (למשל, תיבת דו-שיח או פריסה מרובת חלוניות), הוא ממשיך. אחרת, היא מחזירה את null, כדי לאפשר לשיטות אחרות ליצור Scene.
  2. אם כן, איך צריך לסדר את הרשומות האלה ב-Scene? אחרי ש-SceneStrategy מתחייב לטפל ברשומות, הוא לוקח על עצמו את האחריות ליצירת Scene ולהגדרת האופן שבו NavEntrys יופיעו ב-Scene.

הליבה של SceneStrategy היא ה-method 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 callback.

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

NavDisplay הוא רכיב מרכזי שאפשר להרכיב ממנו רכיבים אחרים. הוא עוקב אחרי מקבץ הפעילויות הקודמות ומשתמש באחד או יותר מ-SceneStrategy כדי לקבוע ולעבד את Scene המתאים.

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

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

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

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

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

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

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