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 element Scene jest podstawową jednostką, która renderuje co najmniej 1 element NavEntry. Scene to odrębny stan wizualny lub sekcja interfejsu użytkownika, która może zawierać treści z back-endu i zarządzać ich wyświetlaniem.

Każda instancja Scene jest jednoznacznie identyfikowana przez swój key oraz klasę Scene. Ten unikalny identyfikator jest kluczowy, ponieważ powoduje animację najwyższego poziomu po zmianie wartości 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. Podczas przejścia (np.w przypadku przejścia elementu współdzielonego) ta sama wartość NavEntry może być wyświetlana w kilku elementach Scenes. W takim przypadku jej zawartość zostanie wyrenderowana tylko przez ostatni element docelowy Scene, który ją wyświetla.
  • previousEntries: List<NavEntry<T>>: ta właściwość definiuje wartości NavEntry, które wystąpią, jeśli z bieżącego Scene nastąpi działanie „wstecz”. Jest to niezbędne do obliczenia prawidłowego stanu wstecznego przewidywanego, 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 ze stosu z poziomu podrzędnego powinna być uporządkowana i przekształcona w Scene. Gdy SceneStrategy zobaczy bieżące wpisy w steku wywołania, 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. dialog 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 przyjmuje 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 wpisów, 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ę to, przekazuje zadanie do wykonania kolejnej strategii w łańcuchu.

Współdziałanie scen i strategii scen

NavDisplay to główny komponent, który obserwuje twój stos z poziomu warstwy podstawowej 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()), funkcja 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 elementów na końcu deklarują obsługę wyświetlania 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ć 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 2 panelami. Następnie podaj wartość 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 w ramach sceny adaptacyjnej z użyciem materiału

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 tagów 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.