Benutzerdefinierte Layouts mithilfe von Szenen erstellen

Navigation 3 bietet ein leistungsstarkes und flexibles System zum Verwalten des UI-Ablaufs Ihrer App über Szenen. Mit Szenen können Sie hochgradig angepasste Layouts erstellen, sich an verschiedene Bildschirmgrößen anpassen und komplexe Multi-Fenster-Erlebnisse nahtlos verwalten.

Szenen verstehen

In Navigation 3 ist ein Scene die grundlegende Einheit, die eine oder mehrere NavEntry-Instanzen rendert. Ein Scene ist ein bestimmter visueller Zustand oder Abschnitt der Benutzeroberfläche, der die Anzeige von Inhalten aus dem Back Stack enthalten und verwalten kann.

Jede Scene-Instanz wird eindeutig durch ihre key und die Klasse der Scene selbst identifiziert. Diese eindeutige Kennung ist wichtig, da sie die Animation auf oberster Ebene steuert, wenn sich die Scene ändert.

Die Schnittstelle Scene hat die folgenden Eigenschaften:

  • key: Any: Eine eindeutige Kennung für diese spezifische Scene-Instanz. Dieser Schlüssel sorgt in Kombination mit der Klasse des Scene für Eindeutigkeit, hauptsächlich für Animationszwecke.
  • entries: List<NavEntry<T>>: Dies ist eine Liste von NavEntry-Objekten, für deren Anzeige der Scene verantwortlich ist. Wichtig: Wenn dieselbe NavEntry während eines Übergangs (z.B. bei einem Übergang mit gemeinsamem Element) in mehreren Scenes angezeigt wird, wird ihr Inhalt nur vom neuesten Ziel-Scene gerendert, das sie anzeigt.
  • previousEntries: List<NavEntry<T>>: Diese Property definiert die NavEntrys, die sich ergeben, wenn eine „Zurück“-Aktion vom aktuellen Scene ausgeführt wird. Sie ist für die Berechnung des richtigen Status der intelligenten „Zurück“-Geste unerlässlich, damit die NavDisplay den richtigen vorherigen Status antizipieren und dorthin wechseln kann. Dies kann eine Szene mit einer anderen Klasse, einem anderen Schlüssel oder beidem sein.
  • content: @Composable () -> Unit: Dies ist die zusammensetzbare Funktion, mit der Sie definieren, wie die Scene ihre entries und alle umgebenden UI-Elemente rendert, die für diese Scene spezifisch sind.
  • metadata: Map<String, Any>: Stellt anderen Bibliothekskomponenten wie NavDisplay szenenspezifische Informationen bereit. Standardmäßig wird der metadata des letzten NavEntry in entries zurückgegeben.

„equals“ und „hashCode“ in benutzerdefinierten Szenen implementieren

Benutzerdefinierte Scene-Implementierungen müssen equals und hashCode korrekt implementieren, um Folgendes zu unterstützen:

  • Szenenstatusübergänge: Jedes Mal, wenn eine Szene anhand der Liste der Szenenstrategien berechnet wird, prüft NavDisplay, ob die neue Szene mit der vorherigen übereinstimmt. Wenn eine Änderung erkannt wird, wird die SeekableTransition aktualisiert, die für den Übergang zwischen Szenen verwendet wird. Dies wirkt sich wiederum auf den Lebenszyklusstatus der Szenen aus.
  • Overlay-Verwaltung: Für OverlayScenes (z. B. Dialogfelder) verwendet NavDisplay das Szenenobjekt selbst als key, um Overlay-Lebenszyklen und Exit-Animationen zu verfolgen.

Prüfen Sie, ob alle Properties, die den Inhalt der Szene definieren, z. B. key, entries und previousEntries, in den Implementierungen von equals und hashCode enthalten sind. Im Allgemeinen sollten Sie Callbacks (wie onBack) in der Implementierung dieser Methoden vermeiden, da sie Instanzen ändern können, ohne den logischen Szenenstatus zu ändern.

Szenenstrategien verstehen

Ein SceneStrategy ist der Mechanismus, der bestimmt, wie eine bestimmte Liste von NavEntrys aus dem Back Stack angeordnet und in ein Scene umgewandelt werden soll. Wenn ein SceneStrategy die aktuellen Back-Stack-Einträge erhält, stellt er sich im Wesentlichen zwei wichtige Fragen:

  1. Kann ich aus diesen Einträgen eine Scene erstellen? Wenn der SceneStrategy feststellt, dass er die angegebenen NavEntrys verarbeiten und eine sinnvolle Scene bilden kann (z.B. ein Dialogfeld oder ein Layout mit mehreren Bereichen), fährt er fort. Andernfalls wird null zurückgegeben, sodass andere Strategien die Möglichkeit haben, eine Scene zu erstellen.
  2. Wenn ja, wie sollte ich diese Einträge in der Scene? anordnen? Sobald sich ein SceneStrategy zur Verarbeitung der Einträge verpflichtet, übernimmt er die Verantwortung für die Erstellung einer Scene und die Definition, wie die angegebenen NavEntrys in dieser Scene angezeigt werden.

Das Herzstück eines SceneStrategy ist die Methode calculateScene:

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

Diese Methode ist eine Erweiterungsfunktion für einen SceneStrategyScope, die den aktuellen List<NavEntry<T>> aus dem Back Stack übernimmt. Sie sollte einen Scene<T> zurückgeben, wenn sie erfolgreich einen aus den bereitgestellten Einträgen erstellen kann, oder null, wenn dies nicht möglich ist.

Der SceneStrategyScope ist für die Verwaltung aller optionalen Argumente verantwortlich, die der SceneStrategy möglicherweise benötigt, z. B. einen onBack-Callback.

Zusammenwirken von Szenen und Szenenstrategien

NavDisplay ist die zentrale komponierbare Funktion, die den Back Stack beobachtet und eine oder mehrere SceneStrategys verwendet, um die entsprechende Scene zu bestimmen und zu rendern.

Der Parameter sceneStrategies von NavDisplay erwartet eine Liste von SceneStrategy-Instanzen, die für die Berechnung der anzuzeigenden Scene verantwortlich sind. Wenn von den bereitgestellten Strategien kein Scene berechnet wird, greift NavDisplay automatisch auf die Verwendung eines SinglePaneSceneStrategy zurück.

Hier ist eine Aufschlüsselung der Interaktion:

  • Wenn du Schlüssel aus dem Back Stack hinzufügst oder entfernst (z.B. mit backStack.add() oder backStack.removeLastOrNull()), werden diese Änderungen vom NavDisplay beobachtet.
  • Der NavDisplay übergibt die aktuelle Liste der NavEntrys (abgeleitet von den Back-Stack-Schlüsseln) der Reihe nach an die konfigurierten sceneStrategiess und ruft für jeden calculateScene auf, bis ein Scene zurückgegeben wird.
  • Wenn ein SceneStrategy erfolgreich ein Scene zurückgibt, rendert die NavDisplay dann die content dieses Scene. Der NavDisplay verwaltet auch Animationen und die intelligente „Zurück“-Geste basierend auf den Attributen des Scene.

Beispiel: Layout mit einem Bereich (Standardverhalten)

Das einfachste benutzerdefinierte Layout ist eine Anzeige mit einem einzelnen Bereich. Das ist das Standardverhalten, wenn kein anderes SceneStrategy Vorrang hat.

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

Beispiel: Einfaches Listen-Detailansicht-Layout (benutzerdefinierte Szene und Strategie)

In diesem Beispiel wird gezeigt, wie Sie ein Listen-Detail-Layout erstellen, das unter zwei Bedingungen aktiviert wird:

  1. Die Fensterbreite ist ausreichend, um zwei Bereiche zu unterstützen (mindestens WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. Der Back Stack enthält Einträge, die über bestimmte Metadaten angegeben haben, dass sie in einem Listen-Detail-Layout angezeigt werden können.

Das folgende Snippet ist der Quellcode für ListDetailScene.kt und enthält sowohl ListDetailScene als auch 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)
        }
    }
}

Wenn Sie dieses ListDetailSceneStrategy in Ihrem NavDisplay verwenden möchten, ändern Sie Ihre entryProvider-Aufrufe so, dass sie ListDetailScene.listPane()-Metadaten für den Eintrag enthalten, den Sie als Listenlayout anzeigen möchten, und ListDetailScene.detailPane() für den Eintrag, den Sie als Detaillayout anzeigen möchten. Geben Sie dann ListDetailSceneStrategy() als sceneStrategy an und verwenden Sie den Standard-Fallback für Szenarien mit einem einzelnen Bereich:

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

Wenn Sie keine eigene Listen-Detailansicht erstellen möchten, können Sie die Material-Listen-Detailansicht verwenden, die sinnvolle Details und Unterstützung für Platzhalter bietet, wie im nächsten Abschnitt gezeigt.

Listen-Detailansicht-Inhalte in einer adaptiven Material-Szene anzeigen

Für den Anwendungsfall „Listen-Detailansicht“ stellt das Artefakt androidx.compose.material3.adaptive:adaptive-navigation3 eine ListDetailSceneStrategy bereit, die eine Scene für die Listen-Detailansicht erstellt. Diese Scene verarbeitet automatisch komplexe Anordnungen mit mehreren Bereichen (Listen-, Detail- und zusätzliche Bereiche) und passt sie an die Fenstergröße und den Gerätestatus an.

So erstellen Sie eine Material-Listen-Detailansicht Scene:

  1. Abhängigkeit hinzufügen: Fügen Sie androidx.compose.material3.adaptive:adaptive-navigation3 in die build.gradle.kts-Datei Ihres Projekts ein.
  2. Einträge mit ListDetailSceneStrategy-Metadaten definieren: Verwenden Sie listPane(), detailPane() und extraPane(), um Ihre NavEntrys für die entsprechende Bereichsanzeige zu markieren. Mit dem listPane()-Hilfsprogramm können Sie auch einen detailPlaceholder angeben, wenn kein Element ausgewählt ist.
  3. rememberListDetailSceneStrategy() verwenden: Diese zusammensetzbare Funktion bietet eine vorkonfigurierte ListDetailSceneStrategy, die von einer NavDisplay verwendet werden kann.

Das folgende Snippet ist ein Beispiel für Activity, das die Verwendung von ListDetailSceneStrategy zeigt:

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

Abbildung 1: Beispiel für Inhalte, die in der Material-Listen-Detailansicht ausgeführt werden.