Tworzenie niestandardowych układów za pomocą scen

Nawigacja 3 wprowadza zaawansowany i elastyczny system zarządzania przepływem interfejsu użytkownika aplikacji za pomocą scen. Sceny umożliwiają tworzenie bardzo dopracowanych układów, dostosowywanie ich do różnych rozmiarów ekranu i płynne zarządzanie złożonymi interfejsami z wieloma panelami.

Sceny

W Nawigacji 3 Scene jest podstawową jednostką, która renderuje co najmniej 1 instancję NavEntry. Scene to odrębne wizualne stany lub sekcje interfejsu użytkownika, które mogą zawierać treści z Twojego back-endu i zarządzać ich wyświetlaniem.

Każde wystąpienie Scene jest jednoznacznie identyfikowane przez swój identyfikator key oraz klasę Scene. Ten unikalny identyfikator jest kluczowy, ponieważ powoduje 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>>: lista obiektów NavEntry, za wyświetlanie których odpowiada element Scene. Co ważne, jeśli podczas przejścia (np. w przypadku przejścia elementu współdzielonego) to samo NavEntry jest wyświetlane w kilku Scenes, jego zawartość zostanie wyrenderowana tylko przez ostatnią docelową Scene, która go wyświetla.
  • previousEntries: List<NavEntry<T>>: ta właściwość definiuje wartości NavEntry, które wystąpią, jeśli z bieżącego poziomu Scene zostanie wykonane działanie „wstecz”. Jest to niezbędne do obliczenia prawidłowego przewidywanego stanu wstecz, co pozwala NavDisplay przewidzieć i przejść do prawidłowego poprzedniego stanu, który może być sceną z inną klasą lub innym kluczem.
  • content: @Composable () -> Unit: to funkcja składana, w której definiujesz sposób renderowania elementu entries oraz wszystkich elementów interfejsu otaczających ten element.SceneScene

Strategie dotyczące scen

SceneStrategy to mechanizm, który określa, jak dana lista NavEntry z dolnego poziomu stosu powinna być uporządkowana i przekształcona w Scene. Gdy SceneStrategy otrzyma bieżące wpisy z poziomu podrzędnego stosu wywołań, zada sobie 2 kluczowe pytania:

  1. Czy mogę utworzyć Scene na podstawie tych wpisów? Jeśli SceneStrategy zauważy, że może obsłużyć dane NavEntryi utworzyć sensowne Scene(np. okno dialogowe lub układ z wieloma panelami), kontynuuje. W przeciwnym razie zwraca wartość null, co daje szansę innym strategiom na utworzenie Scene.
  2. Jeśli tak, jak te wpisy należy uporządkować w Scene? Gdy SceneStrategy zdecyduje się na obsługę wpisów, przejmuje odpowiedzialność za tworzenie Scene i określanie sposobu wyświetlania określonych NavEntry w tym Scene.

Istotą SceneStrategy jest metoda calculateScene:

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

Ta metoda pobiera bieżącą wartość List<NavEntry<T>> ze stosu i wywołanie zwrotne onBack. Powinien zwrócić wartość Scene<T>, jeśli uda się utworzyć obiekt na podstawie podanych danych, lub null, jeśli nie uda się tego zrobić.

SceneStrategy udostępnia też wygodną funkcję then, która pozwala łączyć ze sobą wiele strategii. Dzięki temu powstaje elastyczny kanał podejmowania decyzji, w którym każda strategia może próbować obliczyć wartość Scene. Jeśli nie uda się jej to, przekazuje zadanie do następnej strategii w łańcuchu.

Współdziałanie scen i strategii scen

NavDisplay to główny komponent, który obserwuje twój stos z poziomu poprzedniego poziomu i korzysta z elementu SceneStrategy, aby określić i wyrenderować odpowiedni element Scene.

Parametr NavDisplay's sceneStrategy oczekuje wartości SceneStrategy, która odpowiada za obliczenie wyświetlanej wartości Scene. Jeśli strategia (lub łańcuch strategii) nie oblicza wartości Scene, NavDisplay automatycznie przechodzi do domyślnego korzystania z SinglePaneSceneStrategy.

Oto zestawienie interakcji:

  • Gdy dodasz lub usuniesz klucze ze stosu (np. za pomocą funkcji backStack.add() lub backStack.removeLastOrNull()), NavDisplayzauważy te zmiany.
  • Funkcja NavDisplay przekazuje bieżącą listę NavEntrys (wywoływaną z kluczy w backstacku) do skonfigurowanej metody SceneStrategy's calculateScene.
  • Jeśli SceneStrategy zwróci Scene, NavDisplay wyrenderuje content tego Scene. NavDisplay zarządza też animacjami i wsteczną predykcją na podstawie właściwości Scene.

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

Najprostszy niestandardowy układ to wyświetlanie w jednej karcie, co jest domyślnym zachowaniem, jeśli żaden inny SceneStrategy nie ma pierwszeństwa.

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

Przykład: podstawowy układ z 2 panelami (niestandardowa scena i strategia)

Ten przykład pokazuje, jak utworzyć proste rozmieszczenie na 2 panelach, które jest aktywowane 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. 2 najwyższe pozycje w grupie najniższych warstw wyraźnie deklarują, że obsługują wyświetlanie w układzie z 2 panelami za pomocą określonych metadanych.

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

Aby używać tego TwoPaneSceneStrategy w Twoim NavDisplay, zmodyfikuj wywołania entryProvider, aby uwzględnić metadane TwoPaneScene.twoPane() dotyczące wpisów, które chcesz wyświetlać w układzie z dwoma panelami. Następnie podaj TwoPaneSceneStrategy() jako sceneStrategy, korzystając z domyślnego rozwiązania zastępczego w scenariuszach jednopanelowych:

// 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 z materiałem adaptacyjnym

W przypadku przypadku użycia listy z szczegółami artefakt androidx.compose.material3.adaptive:adaptive-navigation3 udostępnia element ListDetailSceneStrategy, który tworzy listę z szczegółami Scene. Sceneautomatycznie obsługuje złożone układy z wieloma panelami (listy, panele szczegółowe i dodatkowe) oraz dostosowuje je na podstawie rozmiaru okna i stanu urządzenia.

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

  1. Dodaj zależność: dodaj plik androidx.compose.material3.adaptive:adaptive-navigation3 do pliku build.gradle.kts projektu.
  2. Definiowanie wpisów za pomocą metadanych ListDetailSceneStrategy: użyj wartości listPane(), detailPane() i extraPane(), aby oznaczyć NavEntrys na potrzeby wyświetlania w odpowiedniej karcie. Pomocnik listPane() umożliwia też określenie wartości detailPlaceholder, gdy nie wybrano żadnego elementu.
  3. Użyj funkcji rememberListDetailSceneStrategy(): ta funkcja składana udostępnia wstępnie skonfigurowany element ListDetailSceneStrategy, którego może używać element NavDisplay.

Ten fragment kodu to przykładowa funkcja Activity, która demonstruje użycie funkcji 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ład treści wyświetlanych w scenie typu lista-szczegóły w Material Design.