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-Pane-Anwendungen nahtlos verwalten.

Szenen verstehen

In Navigation 3 ist ein Scene die Grundeinheit, mit der eine oder mehrere NavEntry-Instanzen gerendert werden. Stellen Sie sich ein Scene als einen separaten visuellen Zustand oder Abschnitt Ihrer Benutzeroberfläche vor, der die Anzeige von Inhalten aus Ihrem Backstack enthalten und verwalten kann.

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

Die Scene-Schnittstelle hat die folgenden Eigenschaften:

  • key: Any: Eine eindeutige Kennung für diese spezielle Scene-Instanz. Dieser Schlüssel sorgt in Kombination mit der Klasse des Scene für die Unterscheidung, hauptsächlich für Animationszwecke.
  • entries: List<NavEntry<T>>: Dies ist eine Liste von NavEntry-Objekten, die von Scene angezeigt werden. Wichtig: Wenn dasselbe NavEntry während eines Übergangs in mehreren Scenes angezeigt wird (z.B. bei einem Übergang mit gemeinsam genutzten Elementen), werden die Inhalte nur vom letzten Ziel-Scene gerendert, in dem sie angezeigt werden.
  • previousEntries: List<NavEntry<T>>: Diese Property definiert die NavEntrys, die sich ergeben, wenn von der aktuellen Scene aus eine „Zurück“-Aktion erfolgt. Dies ist wichtig, um den richtigen vorherigen Zustand für die Vorhersage zu berechnen. So kann NavDisplay den richtigen vorherigen Zustand, der möglicherweise eine Szene mit einer anderen Klasse und/oder einem anderen Schlüssel ist, vorhersagen und zu ihm wechseln.
  • content: @Composable () -> Unit: Dies ist die zusammensetzbare Funktion, in der Sie definieren, wie das Scene sein entries und alle umgebenden UI-Elemente rendert, die für dieses Scene spezifisch sind.

Szenenstrategien verstehen

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

  1. Kann ich aus diesen Einträgen eine Scene erstellen? Wenn die SceneStrategy feststellt, dass sie die angegebenen NavEntry verarbeiten und eine sinnvolle Scene (z.B. einen Dialog oder ein Layout mit mehreren Bereichen) erstellen kann, wird fortgefahren. Andernfalls wird null zurückgegeben, damit andere Strategien die Möglichkeit haben, einen Scene zu erstellen.
  2. Falls ja, wie sollte ich diese Einträge in der Scene? anordnen? Sobald sich ein SceneStrategy dazu verpflichtet, die Einträge zu verarbeiten, übernimmt es die Verantwortung für die Erstellung einer Scene und die Definition der Darstellung der angegebenen NavEntrys in dieser Scene.

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 ein SceneStrategyScope, das das aktuelle List<NavEntry<T>> aus dem Backstack verwendet. Die Methode sollte Scene<T> zurückgeben, wenn sie aus den bereitgestellten Einträgen erfolgreich eine bilden kann, andernfalls null.

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

SceneStrategy bietet auch eine praktische Infix-Funktion then, mit der Sie mehrere Strategien verketten können. So wird eine flexible Pipeline für die Entscheidungsfindung erstellt, in der jede Strategie versucht, einen Scene-Wert zu berechnen. Wenn das nicht möglich ist, wird die Aufgabe an die nächste Strategie in der Kette delegiert.

Zusammenwirken von Szenen und Szenenstrategien

Die NavDisplay ist die zentrale zusammensetzbare Funktion, die Ihren Backstack beobachtet und mithilfe eines SceneStrategy die entsprechende Scene bestimmt und rendert.

Für den Parameter NavDisplay's sceneStrategy ist ein SceneStrategy erforderlich, mit dem der anzuzeigende Scene berechnet wird. Wenn mit der angegebenen Strategie (oder Strategiekette) kein Scene berechnet wird, wird für NavDisplay standardmäßig automatisch ein SinglePaneSceneStrategy verwendet.

So läuft die Interaktion ab:

  • Wenn Sie dem Backstack Schlüssel hinzufügen oder daraus entfernen (z.B. mit backStack.add() oder backStack.removeLastOrNull()), werden diese Änderungen von NavDisplay beobachtet.
  • Die NavDisplay übergibt die aktuelle Liste der NavEntrys (abgeleitet von den Backstack-Schlüsseln) an die konfigurierte SceneStrategy's calculateScene-Methode.
  • Wenn SceneStrategy erfolgreich eine Scene zurückgibt, rendert NavDisplay die content dieser Scene. Das NavDisplay verwaltet auch Animationen und die Vorhersage für die Zurück-Geste basierend auf den Attributen des Scene.

Beispiel: Layout mit einem Bereich (Standardverhalten)

Das einfachste benutzerdefinierte Layout ist eine Einzelbereichsanzeige. Dies ist das Standardverhalten, wenn keine andere 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 Layout mit Liste und Details (benutzerdefinierte Szene und Strategie)

In diesem Beispiel wird gezeigt, wie ein einfaches Listen-/Detail-Layout erstellt wird, das auf Grundlage von zwei Bedingungen aktiviert wird:

  1. Die Fensterbreite ist ausreichend, um zwei Bereiche zu unterstützen (d.h. mindestens WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. Der Backstack enthält Einträge, die mit bestimmten Metadaten deklariert 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.
 *
 */
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.containsKey(DETAIL_KEY) } ?: return null
        val listEntry = entries.findLast { it.metadata.containsKey(LIST_KEY) } ?: 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
        )
    }

    companion object {
        internal const val LIST_KEY = "ListDetailScene-List"
        internal const val DETAIL_KEY = "ListDetailScene-Detail"

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun listPane() = mapOf(LIST_KEY to true)

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun detailPane() = mapOf(DETAIL_KEY to true)
    }
}

Wenn Sie diese ListDetailSceneStrategy in Ihrem NavDisplay verwenden möchten, müssen Sie Ihre entryProvider-Aufrufe so ändern, dass sie ListDetailScene.listPane()-Metadaten für den Eintrag enthalten, den Sie als Liste>-Layout anzeigen möchten, und ListDetailScene.detailPane() für den Eintrag, den Sie als Detail>-Layout anzeigen möchten. Geben Sie dann ListDetailSceneStrategy() als sceneStrategy an und verlassen Sie sich auf den Standard-Fallback für Szenarien mit einem 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() },
        sceneStrategy = 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 Listendetailszene erstellen möchten, können Sie die Material-Listendetailszene verwenden. Sie enthält sinnvolle Details und unterstützt Platzhalter, wie im nächsten Abschnitt gezeigt.

Listendetailinhalte in einer adaptiven Material-Szene anzeigen

Für den Anwendungsfall „Liste – Detail“ stellt das androidx.compose.material3.adaptive:adaptive-navigation3-Artefakt ein ListDetailSceneStrategy bereit, mit dem ein Scene für die Liste – Detail erstellt wird. Diese Scene übernimmt automatisch die Verarbeitung komplexer Anordnungen mit mehreren Bereichen (Liste, Detail und zusätzliche Bereiche) und passt sie an die Fenstergröße und den Gerätestatus an.

So erstellen Sie eine Materiallistendetail-Scene:

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

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

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

Abbildung 1. Beispiel für Inhalte, die in einer Materialliste mit Detailansicht ausgeführt werden.