Tworzenie niestandardowych układów za pomocą scen

Nawigacja 3 wprowadza zaawansowany i elastyczny system zarządzania przepływem interfejsu aplikacji za pomocą scen. Sceny umożliwiają tworzenie wysoce spersonalizowanych układów, dostosowywanie się do różnych rozmiarów ekranu i bezproblemowe zarządzanie złożonymi interfejsami wielopanelowymi.

Informacje o scenach

W Navigation 3 Scene to podstawowa jednostka, która renderuje co najmniej 1 instancję NavEntry. Scene to odrębny stan wizualny lub sekcja interfejsu, która może zawierać i zarządzać wyświetlaniem treści z backendu.

Każde wystąpienie Scene jest jednoznacznie identyfikowane przez jego key i klasę samego Scene. Ten unikalny identyfikator jest kluczowy, ponieważ steruje animacją najwyższego poziomu, gdy zmienia się wartość Scene.

Interfejs Scene ma te właściwości:

  • key: Any: unikalny identyfikator tej konkretnej instancji Scene. Ten klucz w połączeniu z klasą Scene zapewnia odrębność, głównie na potrzeby animacji.
  • entries: List<NavEntry<T>>: Jest to lista obiektów NavEntry, za których wyświetlanie odpowiada Scene. Co ważne, jeśli ten sam NavEntry jest wyświetlany w wielu Scenes podczas przejścia (np. w przejściu wspólnego elementu), jego treść będzie renderowana tylko przez najnowszy docelowy Scene, który go wyświetla.
  • previousEntries: List<NavEntry<T>>: ta właściwość określa NavEntry, które pojawią się, gdy z bieżącego Scene zostanie wykonana czynność „wstecz”. Jest to niezbędne do obliczenia prawidłowego stanu przewidywanego powrotu, co pozwala NavDisplay przewidywać i przechodzić do prawidłowego poprzedniego stanu, który może być sceną o innej klasie lub kluczu.
  • content: @Composable () -> Unit: jest to funkcja kompozycyjna, w której określasz, jak Scene renderuje swój element entries i wszystkie otaczające elementy interfejsu użytkownika specyficzne dla tego elementu Scene.

Informacje o strategiach dotyczących scen

SceneStrategy to mechanizm, który określa, jak dana lista NavEntry z listy wstecznej powinna być ułożona i przekształcona w Scene. Gdy aplikacja SceneStrategy otrzymuje bieżące wpisy na stosie wstecznym, zadaje sobie 2 kluczowe pytania:

  1. Czy na podstawie tych wpisów mogę utworzyć Scene? Jeśli SceneStrategy stwierdzi, że może obsłużyć podane NavEntry i utworzyć sensowny Scene (np. okno dialogowe lub układ wielopanelowy), przechodzi dalej. W przeciwnym razie zwraca wartość null, dając innym strategiom szansę na utworzenie wartości Scene.
  2. Jeśli tak, jak mam uporządkować te wpisy w Scene? Gdy SceneStrategy zobowiąże się do obsługi wpisów, przejmuje odpowiedzialność za utworzenie Scene i określenie, jak określone NavEntry będą wyświetlane w tym Scene.

Podstawą SceneStrategy jest metoda calculateScene:

@Composable
public fun calculateScene(
    entries: List<NavEntry<T>>,
    onBack: (count: Int) -> Unit,
): Scene<T>?

Ta metoda jest funkcją rozszerzającą w przypadku SceneStrategyScope, która pobiera bieżący List<NavEntry<T>> ze stosu wstecznego. Powinien zwracać wartość Scene<T>, jeśli na podstawie podanych wpisów można utworzyć listę, lub null, jeśli nie jest to możliwe.

SceneStrategyScope odpowiada za utrzymywanie wszelkich argumentów opcjonalnych, których może potrzebować SceneStrategy, np. wywołania zwrotnego onBack.

SceneStrategy udostępnia też wygodną then funkcję wrostkową, która umożliwia łączenie ze sobą wielu strategii. W ten sposób powstaje elastyczny potok decyzyjny, w którym każda strategia może próbować obliczyć wartość Scene, a jeśli jej się to nie uda, przekazuje zadanie do następnej strategii w łańcuchu.

Współdziałanie scen i strategii scen

NavDisplay to centralny komponent, który obserwuje stos wsteczny i za pomocą SceneStrategy określa oraz renderuje odpowiedni Scene.

Parametr NavDisplay's sceneStrategy oczekuje SceneStrategy, który jest odpowiedzialny za obliczanie Scene do wyświetlenia. Jeśli podana strategia (lub łańcuch strategii) nie obliczy wartości Scene, NavDisplay automatycznie powróci do domyślnego używania wartości SinglePaneSceneStrategy.

Oto opis interakcji:

  • Gdy dodasz lub usuniesz klucze z listy wstecznej (np. za pomocą backStack.add() lub backStack.removeLastOrNull()), NavDisplay zarejestruje te zmiany.
  • NavDisplay przekazuje bieżącą listę NavEntrys (pochodną kluczy stosu wstecznego) do skonfigurowanej metody SceneStrategy's calculateScene.
  • Jeśli SceneStrategy zwróci Scene, NavDisplay wyrenderuje content tego Scene. NavDisplay zarządza też animacjami i przewidywanym powrotem na podstawie właściwości Scene.

Przykład: układ z jednym panelem (domyślne działanie)

Najprostszy układ niestandardowy to wyświetlanie w jednym panelu, które jest domyślnym zachowaniem, jeśli nie ma innego SceneStrategy o wyższym priorytecie.

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> {
    @Composable
    override fun calculateScene(entries: List<NavEntry<T>>, onBack: (Int) -> Unit): Scene<T> =
        SinglePaneScene(
            key = entries.last().contentKey,
            entry = entries.last(),
            previousEntries = entries.dropLast(1)
        )
}

Przykład: podstawowy układ dwupanelowy (niestandardowa scena i strategia)

Ten przykład pokazuje, jak utworzyć prosty układ dwupanelowy, który jest aktywowany na podstawie 2 warunków:

  1. Szerokość okna jest wystarczająca, aby wyświetlić 2 panele (czyli co najmniej WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. Dwa pierwsze wpisy na liście wstecznej wyraźnie deklarują obsługę wyświetlania w układzie dwupanelowym za pomocą określonych metadanych.

Poniższy fragment to połączony kod źródłowy TwoPaneScene.ktTwoPaneSceneStrategy.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)
    }
}

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

Aby użyć tego TwoPaneSceneStrategyNavDisplay, zmodyfikuj wywołania entryProvider, tak aby zawierały metadane TwoPaneScene.twoPane() dla wpisów, które chcesz wyświetlać w układzie dwupanelowym. Następnie podaj TwoPaneSceneStrategy() jako sceneStrategy, korzystając z domyślnego wartości zastępczej w przypadku scenariuszy z jednym panelem:

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

Wyświetlanie treści z listy szczegółów w scenie adaptacyjnej Material

przypadku użycia listy szczegółowej artefakt androidx.compose.material3.adaptive:adaptive-navigation3 udostępnia ListDetailSceneStrategy, który tworzy Scene listy szczegółowej. Ten komponent Sceneautomatycznie obsługuje złożone układy wielopanelowe (listy, szczegóły i dodatkowe panele) oraz dostosowuje je do rozmiaru okna i stanu urządzenia.

Aby utworzyć listę szczegółów Material Scene, wykonaj te czynności:

  1. Dodaj zależność: w pliku build.gradle.kts projektu umieść androidx.compose.material3.adaptive:adaptive-navigation3.
  2. Określaj wpisy za pomocą ListDetailSceneStrategymetadanych: używaj tagów listPane(), detailPane()extraPane(), aby oznaczyć NavEntrys do wyświetlania w odpowiednim panelu. Pomocnik listPane() umożliwia też określenie detailPlaceholder, gdy nie wybrano żadnego elementu.
  3. Użyj rememberListDetailSceneStrategy(): ta funkcja kompozycyjna udostępnia wstępnie skonfigurowany element ListDetailSceneStrategy, którego może używać element NavDisplay.

Poniższy fragment to przykładowy Activity, który pokazuje użycie 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")
                        }
                    }
                )
            }
        }
    }
}

Rysunek 1. Przykładowe treści wyświetlane w scenie listy szczegółów Material Design.