Benutzerdefinierte Layouts mithilfe von Szenen erstellen

Navigation 3 bietet ein leistungsstarkes und flexibles System zum Verwalten des UI-Flows Ihrer App über Szenen. Mit Szenen können Sie Layouts individuell anpassen, an unterschiedliche Bildschirmgrößen anpassen und komplexe Ansichten mit mehreren Ansichten nahtlos verwalten.

Szenen

In Navigation 3 ist eine Scene die grundlegende Einheit, die eine oder mehrere NavEntry-Instanzen rendert. Stellen Sie sich ein Scene als einen bestimmten visuellen Zustand oder Bereich Ihrer Benutzeroberfläche vor, der Inhalte aus Ihrem Backstack enthalten und verwalten kann.

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

Die Scene-Benutzeroberfläche hat die folgenden Eigenschaften:

  • key: Any: Eine eindeutige Kennung für diese spezifische Scene-Instanz. Dieser Schlüssel in Kombination mit der Klasse von Scene sorgt für Unterscheidungsmerkmale, vor allem zu Animationszwecken.
  • entries: List<NavEntry<T>>: Liste der NavEntry-Objekte, die vom Scene angezeigt werden sollen. Wenn dieselbe NavEntry während eines Übergangs in mehreren Scenes angezeigt wird (z.B. in einem Übergang mit freigegebenen Elementen), wird ihr Inhalt nur vom jeweils letzten ZielScene gerendert, in dem sie angezeigt wird.
  • previousEntries: List<NavEntry<T>>: Mit dieser Property werden die NavEntrys definiert, die ausgeführt werden, wenn von der aktuellen Scene aus die Aktion „Zurück“ ausgeführt wird. Sie ist für die Berechnung des korrekten proaktiven Rückstatus unerlässlich, sodass NavDisplay den korrekten vorherigen Status vorhersagen und darauf umstellen kann. Dies kann eine Szene mit einer anderen Klasse und/oder einem anderen Schlüssel sein.
  • content: @Composable () -> Unit: Dies ist die zusammensetzbare Funktion, mit der Sie festlegen, wie die Scene ihre entries und alle zugehörigen UI-Elemente für diese Scene rendert.

Szenenstrategien

Ein SceneStrategy ist der Mechanismus, der bestimmt, wie eine bestimmte Liste von NavEntrys aus dem Backstack angeordnet und in eine Scene übergeben werden soll. Wenn SceneStrategy die aktuellen Backstack-Einträge sieht, stellt er sich im Grunde zwei wichtige Fragen:

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

Der Kern eines SceneStrategy ist die calculateScene-Methode:

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

Diese Methode nimmt die aktuelle List<NavEntry<T>> aus dem Backstack und einen onBack-Callback an. Es sollte eine Scene<T> zurückgeben, wenn eine solche aus den angegebenen Einträgen gebildet werden kann, oder null, wenn dies nicht möglich ist.

SceneStrategy bietet auch eine praktische Infixfunktion then, mit der Sie mehrere Strategien verketten können. So entsteht eine flexible Entscheidungspipeline, in der jede Strategie versuchen kann, Scene zu berechnen. Wenn das nicht möglich ist, wird die Aufgabe an die nächste Strategie in der Kette weitergeleitet.

Zusammenspiel von Szenen und Szenenstrategien

Die NavDisplay ist das zentrale Composeable, das Ihren Backstack beobachtet und mithilfe eines SceneStrategy die entsprechende Scene ermittelt und rendert.

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

Hier eine Aufschlüsselung der Interaktion:

  • Wenn Sie Ihrem Backstack Schlüssel hinzufügen oder daraus entfernen (z.B. mit backStack.add() oder backStack.removeLastOrNull()), werden diese Änderungen von NavDisplay berücksichtigt.
  • Die NavDisplay gibt die aktuelle Liste der NavEntrys (abgeleitet aus den Backstack-Schlüsseln) an die konfigurierte SceneStrategy's calculateScene-Methode weiter.
  • Wenn die SceneStrategy eine Scene zurückgibt, rendert die NavDisplay die content dieser Scene. Außerdem verwaltet NavDisplay Animationen und die Vorhersage der Rückgabe basierend auf den Eigenschaften von Scene.

Beispiel: Layout mit einem einzigen Bereich (Standardverhalten)

Das einfachste benutzerdefinierte Layout ist ein einzeiliger Bildschirm. Das ist das Standardverhalten, wenn keine andere SceneStrategy Vorrang hat.

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

Beispiel: Einfaches Layout mit zwei Ansichten (benutzerdefinierte Szene und Strategie)

In diesem Beispiel wird gezeigt, wie Sie ein einfaches Layout mit zwei Ansichten erstellen, das unter zwei Bedingungen aktiviert wird:

  1. Die Fensterbreite ist groß genug, um zwei Bereiche zu unterstützen (d.h. mindestens WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. Die ersten beiden Einträge im Backstack geben explizit an, dass sie mithilfe bestimmter Metadaten in einem Zwei-Spalten-Layout angezeigt werden können.

Das folgende Snippet ist der kombinierte Quellcode für TwoPaneScene.kt und TwoPaneSceneStrategy.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
        }
    }
}

Wenn Sie diese TwoPaneSceneStrategy in Ihrer NavDisplay verwenden möchten, ändern Sie Ihre entryProvider-Aufrufe so, dass sie TwoPaneScene.twoPane()-Metadaten für die Einträge enthalten, die in einem zweispaltigen Layout angezeigt werden sollen. Geben Sie dann TwoPaneSceneStrategy() als sceneStrategy an und verwenden Sie den Standard-Fallback für Szenarien mit einem einzigen Steuerfeld:

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

Listendetailinhalte in einer adaptiven Material-Szene anzeigen

Für den Anwendungsfall „Liste – Detail“ enthält das androidx.compose.material3.adaptive:adaptive-navigation3-Artefakt eine ListDetailSceneStrategy, mit der eine Scene für „Liste – Detail“ erstellt wird. Diese Scene verarbeitet automatisch komplexe mehrzeilige Anordnungen (Listen-, Detail- und Zusatzbereiche) und passt sie an die Fenstergröße und den Gerätestatus an.

So erstellen Sie ein Materiallistendetail Scene:

  1. Fügen Sie die Abhängigkeit hinzu: 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 Ansicht im Bereich zu kennzeichnen. Mit dem listPane()-Hilfselement können Sie auch einen detailPlaceholder angeben, wenn kein Element ausgewählt ist.
  3. rememberListDetailSceneStrategy() verwenden: Diese kombinierbare 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 veranschaulicht:

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

Abbildung 1. Beispielinhalte, die in der Materialliste-Detailansicht ausgeführt werden.