Saltar al contenido
K
Jetpack y arquitecturaPaso a paso35 min de lectura · 15 min de máquina Básico

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:

PiezaQué esEn Marcador
Clave (NavKey)Un objeto pequeño que identifica una pantalla y lleva sus argumentosPartidoKey(partidoId = 3)
PilaUna lista de claves. Navegar es add; volver es removeAt; pila.clear() la vacíapila.add(GoleadoresKey)
NavDisplayEl composable que dibuja la última clave de la pilaMuestra lista, partido o goleadores
entryProviderDice qué pantalla dibuja cada clase de claveentry<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.

ejemplo mínimo: dos pantallas (con comentarios)
@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.

DecoradorDa a cada entradaSin él
rememberSaveableStateHolderNavEntryDecorator()Su propio almacén de rememberSaveableAl volver de un partido, la lista pierde el scroll
rememberViewModelStoreNavEntryDecorator()Su propio almacén de ViewModelTodas 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.

ejemplo mínimo: los decoradores de NavDisplay (con comentarios)
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.

ejemplo mínimo: Navigation Compose 2
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.

Gradle Scripts
libs.versions.toml (Version Catalog "libs")se modifica: [versions] y [libraries]
build.gradle.kts (Module :app)se modifica: plugins y dependencies
Los dos ficheros de esta instalación, tal como los ves en la vista Android.

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 y rememberNavBackStack.
  • navigation3-ui (androidx.navigation3): NavDisplay y 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 de ViewModel. Es de la familia lifecycle, como lifecycle-runtime-ktx.
  • kotlinx-serialization-json: trae @Serializable. :torneo ya la tiene, pero con implementation, que no se transmite a los módulos que dependen de él, así que :app la declara también (la diferencia con api está 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:

catálogo: líneas nuevas (con comentarios)
[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) cambian

3 · 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:

build.gradle.kts (Module :app): lo nuevo (con comentarios)
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 gratisDebugRuntimeClasspath

Añ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.

  1. «Pantallas en la pila: 1» y los tres botones.
  2. «Escultura»: «Sala de Escultura», «Pantallas en la pila: 2». Pulsa dos veces «Me gusta»: «Me gusta: 2».
  3. «Ver obra destacada»: «Obra destacada de Escultura», 3.
  4. «Volver»: otra vez la sala, con «Me gusta: 2».
  5. 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).
  6. «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
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/museo/Museo.kt
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:assembleGratisDebug termina en BUILD 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.7 y lifecycle-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. 1.En Navigation 3, ¿qué es la pila de pantallas y cómo se navega?

  2. 2.Las claves de Marcador llevan @Serializable y implementan NavKey. ¿Para qué sirve eso?

  3. 3.¿Qué aporta rememberViewModelStoreNavEntryDecorator()?

  4. 4.En una entrevista te piden comparar Navigation Compose 2 con Navigation 3. ¿Qué respuesta es correcta?

Faltan 4 de 4