Tworzenie niestandardowych układów za pomocą scen

Biblioteka Navigation 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 ich do różnych rozmiarów ekranu i płynne 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 listy wstecznej.

Każde wystąpienie Scene jest jednoznacznie identyfikowane przez jego key i klasę samego Scene. Ten unikalny identyfikator jest kluczowy, ponieważ odpowiada za animację najwyższego poziomu, gdy zmienia się 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. Ważne jest to, że jeśli ten sam NavEntry jest wyświetlany w wielu Scenes podczas przejścia (np. w przejściu udostępnionego 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ą z inną klasą, kluczem lub oboma tymi elementami.
  • content: @Composable () -> Unit: jest to funkcja kompozycyjna, w której określasz, jak komponent Scene renderuje swój element entries i wszystkie otaczające go elementy interfejsu użytkownika, które są specyficzne dla tego komponentu Scene.
  • metadata: Map<String, Any>: przekazuje informacje o scenie do innych komponentów biblioteki, takich jak NavDisplay. Domyślnie zwraca metadata ostatniego NavEntry w zakresie entries.

Implementowanie metod equals i hashCode w niestandardowych scenach

Implementacje niestandardowe Scene muszą prawidłowo implementować equalshashCode, aby obsługiwać te funkcje:

  • Przejścia między stanami sceny: za każdym razem, gdy scena jest obliczana przez listę strategii scen, funkcja NavDisplay sprawdza, czy nowa scena jest taka sama jak poprzednia. Jeśli wykryje zmianę, zaktualizuje SeekableTransition, które służy do przechodzenia między scenami, co z kolei wpłynie na stan cyklu życia scen.
  • Zarządzanie nakładkami: w przypadku OverlayScene (np. okien dialogowych) NavDisplay używa samego obiektu sceny jako key do śledzenia cykli życia nakładek i animacji wyjścia.

Sprawdź, czy wszystkie właściwości definiujące zawartość sceny, takie jak key, entriespreviousEntries, są uwzględnione w implementacjach equalshashCode. Ogólnie unikaj umieszczania wywołań zwrotnych (np. onBack) w implementacji tych metod, ponieważ mogą one zmieniać instancje bez zmiany logicznego stanu sceny.

Informacje o strategiach 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 liście wstecznej, 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 można.

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

Współdziałanie scen i strategii scen

NavDisplay to centralny komponent, który obserwuje stos wsteczny i używa co najmniej 1 komponentu SceneStrategy do określania i renderowania odpowiedniego komponentu Scene.

Parametr NavDisplay's sceneStrategies oczekuje listy SceneStrategyinstancji, które są odpowiedzialne za obliczanie Scene do wyświetlenia. Jeśli żadna z podanych strategii nie obliczy wartości Scene, usługa 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ę NavEntry (pochodzących z kluczy w stosie wstecznym) do skonfigurowanego sceneStrategies w określonej kolejności, wywołując calculateScene w przypadku każdego z nich, dopóki nie zostanie zwrócony Scene.
  • Gdy SceneStrategy zwróci Scene, NavDisplay renderuje 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świetlacz z 1 panelem, który 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> {
    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? =
        SinglePaneScene(
            key = entries.last().contentKey,
            entry = entries.last(),
            previousEntries = entries.dropLast(1)
        )
}

Przykład: podstawowy układ lista-szczegóły (niestandardowa scena i strategia)

Ten przykład pokazuje, jak utworzyć układ lista-szczegóły, który jest aktywowany na podstawie 2 warunków:

  1. Szerokość okna jest wystarczająca, aby pomieścić 2 panele (czyli co najmniej WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. Stos wsteczny zawiera wpisy, które zadeklarowały obsługę wyświetlania w układzie lista-szczegóły za pomocą określonych metadanych.

Poniższy fragment to kod źródłowy ListDetailScene.kt, który zawiera zarówno ListDetailScene, jak i 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)
        }
    }
}

Aby użyć tego ListDetailSceneStrategyNavDisplay, zmodyfikuj wywołania entryProvider, aby uwzględniały metadane ListDetailScene.listPane() dla wpisu, który chcesz wyświetlać w układzie listy, oraz ListDetailScene.detailPane() dla wpisu, który chcesz wyświetlać w układzie szczegółów. Następnie podaj ListDetailSceneStrategy() jako sceneStrategy, korzystając z domyślnego rozwiązania w przypadku scenariuszy z jednym panelem:

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

Jeśli nie chcesz tworzyć własnej sceny z listą i szczegółami, możesz użyć sceny z listą i szczegółami Material Design, która zawiera przydatne szczegóły i obsługuje symbole zastępcze, co pokazano w następnej sekcji.

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

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

Aby utworzyć listę szczegółową 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 plik Activity, który pokazuje użycie elementu 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")
                        }
                    }
                )
            }
        }
    }
}

Rysunek 1. Przykładowe treści wyświetlane w scenie Material List-Detail.