Navigation 3: la pila de pantallas
Al terminar: Navegar de lista a partido y a goleadores con Navigation 3.
Al terminar esta lección tendrás Navigation 3 instalado y una app de ejemplo, un museo, con tres pantallas encadenadas: una lista de salas, una sala y su obra destacada. «Atrás» vuelve por el mismo camino, y cada sala recuerda lo suyo mientras sigue en la pila. Marcador no cambia: la lección siguiente aplica todo esto a la lista de partidos, al partido y a los goleadores.
Importa porque en cuanto una app tiene más de una pantalla, la navegación decide qué se conserva y qué se pierde al ir y volver. Usas Navigation 3, la librería de navegación más reciente de Jetpack, y la misma que llevará la Pokédex. Temas: la pila de pantallas como lista, claves, decoradores de entrada y, en un recuadro, Navigation Compose 2.
La pila de pantallas es una lista que tú posees
Una pila de pantallas (en inglés, back stack) es la lista de pantallas por las que has pasado, con la actual al final. «Atrás» quita la última y muestra la anterior.
Cuatro piezas:
| Pieza | Qué es | En Marcador |
|---|---|---|
Clave (NavKey) | Un objeto pequeño que identifica una pantalla y lleva sus argumentos | PartidoKey(partidoId = 3) |
| Pila | Una lista de claves. Navegar es add; volver es removeAt; pila.clear() la vacía | pila.add(GoleadoresKey) |
NavDisplay | El composable que dibuja la última clave de la pila | Muestra lista, partido o goleadores |
entryProvider | Dice qué pantalla dibuja cada clase de clave | entry<PartidoKey> { clave -> … } |
Una clave lleva @Serializable (la anotación que permite convertir un objeto en texto y recuperarlo, la de JSON e instalar kotlinx.serialization) por una razón concreta: la pila se guarda con rememberNavBackStack, y solo puede guardar lo que sabe convertir.
Dos piezas de sintaxis del ejemplo: data object Inicio : NavKey es un object (una única instancia, como en Herencia, interfaces y composición) que se imprime con su nombre, y sirve para pantallas sin argumentos; entry<Detalle> { clave -> … } dice qué dibuja la pantalla cuando la clave de arriba de la pila es un Detalle, y clave es esa clave, con sus argumentos.
@Serializable // se puede guardar como texto
data object Inicio : NavKey // pantalla sin argumentos: un object
@Serializable
data class Detalle(val id: Int) : NavKey // pantalla con argumento tipado
@Composable
fun MiApp() {
val pila = rememberNavBackStack(Inicio) // la pila empieza con Inicio y sobrevive a rotar y a am kill
NavDisplay(
backStack = pila, // dibuja la ÚLTIMA clave de esta lista
onBack = { if (pila.size > 1) pila.removeAt(pila.lastIndex) }, // «atrás»: quita la de arriba si queda otra
entryProvider = entryProvider {
entry<Inicio> { Button(onClick = { pila.add(Detalle(3)) }) { Text("Abrir el 3") } } // navegar = add
entry<Detalle> { clave -> Text("Detalle ${clave.id}") } // clave: el Detalle de arriba, con su id
},
)
}Es una ilustración, no un fichero de Marcador. rememberNavBackStack(Inicio) crea la pila con Inicio y la conserva al rotar y tras am kill. El argumento viaja tipado: clave.id es un Int. El onBack es lo que ocurre al pulsar «atrás»: quita la última clave, pero solo si queda otra debajo (pila.size > 1), porque con la pila vacía NavDisplay no tendría nada que dibujar. Con una sola clave, «atrás» ya no es cosa de la pila: el sistema cierra la Activity (la ventana única de la app) y, con ella, la app.
Decoradores: qué se guarda por cada pantalla de la pila
NavDisplay dibuja la pantalla y nada más. Por dentro convierte cada clave de la pila en una entrada (NavEntry): la clave junto con lo que se dibuja para ella y lo que se recuerda mientras esté en la pila. Lo que cada entrada recuerda lo decide una lista de decoradores de entrada (entryDecorators): cada uno rodea el contenido de la entrada y le añade una capacidad.
| Decorador | Da a cada entrada | Sin él |
|---|---|---|
rememberSaveableStateHolderNavEntryDecorator() | Su propio almacén de rememberSaveable | Al volver de un partido, la lista pierde el scroll |
rememberViewModelStoreNavEntryDecorator() | Su propio almacén de ViewModel | Todas las entradas comparten el ViewModel de la Activity |
El segundo hace que abrir el partido 3 cree un ViewModel para esa entrada, que muere con ella al volver atrás: cada partido abierto tiene el suyo.
Al pasar entryDecorators das tú la lista completa. Cuando una pantalla tiene ViewModel, como en Marcador, pon los dos, y el de estado guardable primero, como indica la documentación. En el museo del ejercicio no hay ningún ViewModel, así que basta el de estado guardable.
NavDisplay(
backStack = pila,
onBack = { if (pila.size > 1) pila.removeAt(pila.lastIndex) },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // primero: cada entrada guarda su rememberSaveable
rememberViewModelStoreNavEntryDecorator(), // después: cada entrada tiene sus ViewModel
),
entryProvider = entryProvider { /* … */ },
)Recuadro · Navigation Compose 2, la que preguntan
Antes de Navigation 3 existía Navigation Compose 2, y es la que suele aparecer en las entrevistas. Funciona al revés: un navController gestiona la pila por ti, y las pantallas se identifican por rutas de texto con los argumentos dentro (el id llega como texto y hay que interpretarlo). En Navigation 3 la pila es una lista tuya y el destino es una clave con propiedades tipadas.
NavHost(navController, startDestination = "lista") {
composable("lista") { /* … */ }
composable("partido/{id}") { /* … */ }
}
navController.navigate("partido/3")Prepara el proyecto · Instala Navigation 3 y el plugin de serialización
Objetivo: que :app tenga las cuatro librerías (tres de Navigation 3 y la de JSON) y el plugin de serialización, y que sus clases se reconozcan en el código.
Es la receta de Los ficheros de Gradle de tu proyecto. El plugin ya lo instalaste en :torneo (JSON e instalar kotlinx.serialization); aquí solo se aplica también en :app.
1 · Qué instalas y por qué ahora. Cuatro librerías y un plugin, todo en :app, porque las pantallas y la pila viven ahí:
navigation3-runtime(androidx.navigation3):NavKey, la pila yrememberNavBackStack.navigation3-ui(androidx.navigation3):NavDisplayy el decorador que guarda el estado de cada pantalla.lifecycle-viewmodel-navigation3(androidx.lifecycle): da a cada pantalla de la pila su propio almacén deViewModel. Es de la familialifecycle, comolifecycle-runtime-ktx.kotlinx-serialization-json: trae@Serializable.:torneoya la tiene, pero conimplementation, que no se transmite a los módulos que dependen de él, así que:appla declara también (la diferencia conapiestá en Gradle: ficheros, catálogo y dependencias).- El plugin
kotlin-serialization: genera el código de cada clase@Serializable. Las claves de pantalla se escriben en:app, así que el módulo aplica el plugin.
2 · Catálogo. Abre Gradle Scripts → libs.versions.toml (Version Catalog "libs"). Solo navigation3 es un número nuevo, que comparten runtime y ui; los demás ya existen (lifecycleRuntimeKtx, kotlinxSerializationJson, kotlin). Añade al final de cada sección:
[versions]
navigation3 = "1.1.7" # propio de runtime y ui
[libraries]
androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3" }
# Usa la versión de la familia lifecycle, que ya tienes
androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycleRuntimeKtx" }
# kotlinx-serialization-json y el plugin ya están en el catálogo y en la raíz (con apply false)
# desde la lección «JSON e instalar kotlinx.serialization»: ni el catálogo ni build.gradle.kts (Project: Marcador) cambian3 · Módulo :app. Abre Gradle Scripts → build.gradle.kts (Module :app). Aplica el plugin junto a kotlin.compose, sin apply false, y añade cuatro líneas en dependencies { … }, debajo de la última implementation:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization) // nueva: sin «apply false», en el módulo sí se aplica
// … y los plugins de KSP y Room, que ya tienes
}
dependencies {
// implementation en las cuatro: las usa el código de la app
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.kotlinx.serialization.json)
}4 · Sync Now. Debe terminar sin errores rojos; la primera vez descarga las librerías y necesita internet.
5 · Comprueba. En la Terminal de Android Studio (View → Tool Windows → Terminal), las librerías que usa la app al ejecutarse:
./gradlew :app:dependencies --configuration gratisDebugRuntimeClasspathAñade | grep navigation3 al final (la barra | pasa la salida al comando siguiente, y grep filtra las líneas que contienen esa palabra). Salen muchas líneas; las tuyas son las que empiezan por +---: androidx.navigation3:navigation3-runtime:1.1.7, androidx.navigation3:navigation3-ui:1.1.7 y androidx.lifecycle:lifecycle-viewmodel-navigation3:2.11.0. (Las muy sangradas con (c) son restricciones de versión de la familia lifecycle: no las busques.)
Del plugin no hay comprobación inmediata: sin él, las clases @Serializable compilan igual, pero nadie genera el código que las convierte en texto. La prueba es que el museo del ejercicio de abajo se abra y funcione; en la lección siguiente hay otra comprobación rápida y la definitiva (que la app sobreviva a am kill).
Ejercicio · el museo
Reto · Ejercicio: el museo
En la carpeta ejercicios de tu proyecto (la que creaste en La rotación y la muerte del proceso), crea la subcarpeta museo: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → escribe .ejercicios.museo. Después, clic derecho sobre esa carpeta → New → Kotlin Class/File → File → Museo. La primera línea debe ser package com.sebastiannarvaez.marcador.ejercicios.museo.
Escribe un composable Museo y su vista previa. Tres claves que se puedan guardar: la lista de salas (sin datos), una sala (con su nombre) y la obra destacada de una sala (con el nombre de la sala). La pila empieza en la lista, y cada pantalla dice «Pantallas en la pila: N».
- La lista: un botón por sala, «Pintura», «Escultura» y «Fotografía», que abre esa sala.
- La sala: «Sala de …», un botón «Me gusta: N» que suma uno (un contador que se guarda con
rememberSaveable) y «Ver obra destacada». - La obra: «Obra destacada de …», «Volver», que quita la de arriba, y «Volver al inicio», que deja solo la lista.
Con el decorador que da a cada entrada su propio estado guardable. «Atrás» quita la de arriba si queda más de una.
Lo que debes ver: ejecuta la vista previa en el emulador (triángulo verde junto a @Preview → Run 'MuseoPreview', como en Estado en Compose), donde funciona también el gesto de atrás.
- «Pantallas en la pila: 1» y los tres botones.
- «Escultura»: «Sala de Escultura», «Pantallas en la pila: 2». Pulsa dos veces «Me gusta»: «Me gusta: 2».
- «Ver obra destacada»: «Obra destacada de Escultura», 3.
- «Volver»: otra vez la sala, con «Me gusta: 2».
- Atrás (gesto): la lista. Vuelve a entrar en «Escultura»: «Me gusta: 0». Es lo esperado: al quitar la sala de la pila, su estado se tira con ella; solo se conserva mientras la sala sigue en la pila (como en el paso 4).
- «Volver al inicio» desde una obra: la lista, con 1.
Al terminar, elige otra vez app en el selector de arriba.
Para pensar: sin el decorador, ¿qué marcaría «Me gusta» en el paso 4? (0: la sala sale de la composición al apilar la obra y nadie guarda su rememberSaveable.)
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.museo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import kotlinx.serialization.Serializable
@Serializable
data object SalasKey : NavKey
@Serializable
data class SalaKey(val nombre: String) : NavKey
@Serializable
data class ObraKey(val sala: String) : NavKey
@Composable
fun Museo(modifier: Modifier = Modifier) {
val pila = rememberNavBackStack(SalasKey)
NavDisplay(
modifier = modifier.padding(16.dp),
backStack = pila,
onBack = { if (pila.size > 1) pila.removeAt(pila.lastIndex) },
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator()),
entryProvider = entryProvider {
entry<SalasKey> {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Pantallas en la pila: ${pila.size}")
listOf("Pintura", "Escultura", "Fotografía").forEach { nombre ->
Button(onClick = { pila.add(SalaKey(nombre)) }) { Text(nombre) }
}
}
}
entry<SalaKey> { clave ->
var meGusta by rememberSaveable { mutableIntStateOf(0) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Sala de ${clave.nombre}")
Text("Pantallas en la pila: ${pila.size}")
Button(onClick = { meGusta++ }) { Text("Me gusta: $meGusta") }
Button(onClick = { pila.add(ObraKey(clave.nombre)) }) { Text("Ver obra destacada") }
}
}
entry<ObraKey> { clave ->
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Obra destacada de ${clave.sala}")
Text("Pantallas en la pila: ${pila.size}")
Button(onClick = { pila.removeAt(pila.lastIndex) }) { Text("Volver") }
Button(onClick = { pila.clear(); pila.add(SalasKey) }) { Text("Volver al inicio") }
}
}
},
)
}
@Preview(showBackground = true)
@Composable
private fun MuseoPreview() {
MaterialTheme { Museo() }
}Las claves son data object (sin datos) y data class (con datos), las dos @Serializable. Cada entry dibuja una clave; pila.add navega, removeAt vuelve y clear() vacía. En pila.clear(); pila.add(SalasKey), el ; solo separa dos instrucciones escritas en la misma línea. El contador está dentro de la entrada de la sala, así que lo guarda el decorador de estado guardable.
Punto de control
- En la Terminal de Android Studio,
./gradlew :app:assembleGratisDebugtermina enBUILD SUCCESSFUL. ./gradlew :app:dependencies --configuration gratisDebugRuntimeClasspath(con| grep navigation3) lista las versiones de la instalación:navigation3-runtime:1.1.7,navigation3-ui:1.1.7ylifecycle-viewmodel-navigation3:2.11.0.- El museo funciona en el emulador como dice el ejercicio.
- La app de Marcador no ha cambiado: se ve igual que antes.
La lección siguiente, Navegación en Marcador, aplica esta pila a la lista de partidos, al partido y a los goleadores.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.En Navigation 3, ¿qué es la pila de pantallas y cómo se navega?
2.Las claves de Marcador llevan
@Serializabley implementanNavKey. ¿Para qué sirve eso?3.¿Qué aporta
rememberViewModelStoreNavEntryDecorator()?4.En una entrevista te piden comparar Navigation Compose 2 con Navigation 3. ¿Qué respuesta es correcta?