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 przejścia wstecz, 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 typu „composable”, 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>: udostępnia informacje o scenie innym komponentom biblioteki, takim jak NavDisplay. Domyślnie zwraca metadata ostatniego NavEntry w zakresie entries.

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 przejściem wstecz 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 szczegółowej listy (niestandardowa scena i strategia)

Ten przykład pokazuje, jak utworzyć prosty układ szczegółowej listy, 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 szczegółowej listy 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.
 *
 */
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 szczegółowej listy w adaptacyjnej scenie Material

przypadku użycia szczegółowej listy artefakt androidx.compose.material3.adaptive:adaptive-navigation3 udostępnia ListDetailSceneStrategy, który tworzy szczegółową listę Scene. Ta funkcja 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ół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 jest wybrany żaden element.
  3. Użyj rememberListDetailSceneStrategy(): ta funkcja typu „composable” udostępnia wstępnie skonfigurowany element ListDetailSceneStrategy, którego może używać element NavDisplay.

Poniższy fragment kodu to przykład 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<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 listy szczegółów Material Design.