Crea diseños personalizados con escenas

Navigation 3 presenta un sistema potente y flexible para administrar el flujo de la IU de tu app a través de Scenes. Las escenas te permiten crear diseños altamente personalizados, adaptarte a diferentes tamaños de pantalla y administrar experiencias complejas de varios paneles sin problemas.

Información sobre las escenas

En Navigation 3, un Scene es la unidad fundamental que renderiza una o más instancias de NavEntry. Piensa en un Scene como un estado visual o una sección distintos de tu IU que pueden contener y administrar la visualización del contenido de tu pila de actividades.

Cada instancia de Scene se identifica de forma única por su key y la clase del propio Scene. Este identificador único es fundamental porque impulsa la animación de nivel superior cuando cambia Scene.

La interfaz Scene tiene las siguientes propiedades:

  • key: Any: Es un identificador único para esta instancia específica de Scene. Esta clave, combinada con la clase de Scene, garantiza la distinción, principalmente para fines de animación.
  • entries: List<NavEntry<T>>: Es una lista de objetos NavEntry que el Scene es responsable de mostrar. Es importante destacar que, si el mismo NavEntry se muestra en varios Scenes durante una transición (p.ej., en una transición de elementos compartidos), su contenido solo se renderizará en el Scene de destino más reciente que lo muestre.
  • previousEntries: List<NavEntry<T>>: Esta propiedad define los NavEntrys que se generarían si se realiza una acción de "volver" desde el Scene actual. Es fundamental para calcular el estado de atrás predictivo adecuado, lo que permite que NavDisplay anticipe y realice la transición al estado anterior correcto, que puede ser una escena con una clase o una clave diferente.
  • content: @Composable () -> Unit: Esta es la función componible en la que defines cómo el Scene renderiza su entries y cualquier elemento de IU circundante específico de ese Scene.

Comprende las estrategias de escena

Un SceneStrategy es el mecanismo que determina cómo se debe organizar una lista determinada de NavEntry de la pila de actividades y cómo se debe realizar la transición a un Scene. Básicamente, cuando se le presentan las entradas actuales de la pila de actividades, un SceneStrategy se hace dos preguntas clave:

  1. ¿Puedo crear un Scene a partir de estas entradas? Si el SceneStrategy determina que puede controlar los NavEntrys proporcionados y formar un Scene significativo (p.ej., un diálogo o un diseño de varios paneles), continúa. De lo contrario, devuelve null, lo que les da a otras estrategias la oportunidad de crear un Scene.
  2. Si es así, ¿cómo debo organizar esas entradas en el Scene?? Una vez que un SceneStrategy se compromete a controlar las entradas, asume la responsabilidad de construir un Scene y definir cómo se mostrarán los NavEntry especificados dentro de ese Scene.

El núcleo de un SceneStrategy es su método calculateScene:

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

Este método es una función de extensión en un SceneStrategyScope que toma el List<NavEntry<T>> actual de la pila de actividades. Debe devolver un Scene<T> si puede formar uno correctamente a partir de las entradas proporcionadas o null si no puede.

El SceneStrategyScope es responsable de mantener los argumentos opcionales que el SceneStrategy podría necesitar, como una devolución de llamada de onBack.

SceneStrategy también proporciona una conveniente función de infijo then, que te permite encadenar varias estrategias. Esto crea un canal de toma de decisiones flexible en el que cada estrategia puede intentar calcular un Scene y, si no puede, delega la tarea a la siguiente estrategia de la cadena.

Cómo funcionan en conjunto las escenas y las estrategias de escenas

El elemento NavDisplay es el elemento componible central que observa tu pila de actividades y usa un SceneStrategy para determinar y renderizar el Scene adecuado.

El parámetro NavDisplay's sceneStrategy espera un SceneStrategy que sea responsable de calcular el Scene que se mostrará. Si la estrategia proporcionada (o la cadena de estrategias) no calcula ningún Scene, NavDisplay recurre automáticamente al uso de un SinglePaneSceneStrategy de forma predeterminada.

A continuación, se muestra un desglose de la interacción:

  • Cuando agregas o quitas claves de tu pila de actividades (p.ej., con backStack.add() o backStack.removeLastOrNull()), NavDisplay observa estos cambios.
  • El NavDisplay pasa la lista actual de NavEntrys (derivada de las claves de la pila de historial) al método SceneStrategy's calculateScene configurado.
  • Si SceneStrategy devuelve correctamente un Scene, el NavDisplay renderiza el content de ese Scene. El NavDisplay también administra las animaciones y la función de atrás predictivo según las propiedades del Scene.

Ejemplo: Diseño de panel único (comportamiento predeterminado)

El diseño personalizado más simple que puedes tener es una pantalla de un solo panel, que es el comportamiento predeterminado si ningún otro SceneStrategy tiene prioridad.

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> {
    @Composable
    override fun calculateScene(entries: List<NavEntry<T>>, onBack: (Int) -> Unit): Scene<T> =
        SinglePaneScene(
            key = entries.last().contentKey,
            entry = entries.last(),
            previousEntries = entries.dropLast(1)
        )
}

Ejemplo: Diseño básico de dos paneles (escena y estrategia personalizadas)

En este ejemplo, se muestra cómo crear un diseño simple de dos paneles que se activa según dos condiciones:

  1. El ancho de la ventana es lo suficientemente ancho como para admitir dos paneles (es decir, al menos WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. Las dos primeras entradas de la pila de actividades declaran explícitamente su compatibilidad para mostrarse en un diseño de dos paneles con metadatos específicos.

El siguiente fragmento es el código fuente combinado de TwoPaneScene.kt y 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()
            }
            Column(modifier = Modifier.weight(0.5f)) {
                secondEntry.Content()
            }
        }
    }

    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.contentKey, secondEntry.contentKey)

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

Para usar este TwoPaneSceneStrategy en tu NavDisplay, modifica tus llamadas a entryProvider para incluir metadatos de TwoPaneScene.twoPane() para las entradas que deseas mostrar en un diseño de dos paneles. Luego, proporciona TwoPaneSceneStrategy() como tu sceneStrategy, y confía en la resiliencia predeterminada para situaciones de un solo panel:

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

Cómo mostrar contenido de lista-detalles en una escena adaptable de Material

Para el caso de uso de lista-detalles, el artefacto androidx.compose.material3.adaptive:adaptive-navigation3 proporciona un ListDetailSceneStrategy que crea un Scene de lista-detalles. Este Scene controla automáticamente las disposiciones complejas de varios paneles (lista, detalles y paneles adicionales) y los adapta según el tamaño de la ventana y el estado del dispositivo.

Para crear un Scene de lista-detalle de Material, sigue estos pasos:

  1. Agrega la dependencia: Incluye androidx.compose.material3.adaptive:adaptive-navigation3 en el archivo build.gradle.kts de tu proyecto.
  2. Define tus entradas con metadatos ListDetailSceneStrategy: Usa listPane(), detailPane() y extraPane() para marcar tu NavEntrys para que se muestre en el panel adecuado. El asistente listPane() también te permite especificar un detailPlaceholder cuando no se selecciona ningún elemento.
  3. Use rememberListDetailSceneStrategy(): Esta función de componibilidad proporciona un ListDetailSceneStrategy preconfigurado que puede usar un NavDisplay.

El siguiente fragmento es un ejemplo de Activity que demuestra el uso de 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<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")
                        }
                    }
                )
            }
        }
    }
}

Figura 1. Ejemplo de contenido que se ejecuta en la escena de lista y detalles de Material.