Navegación en Android con Navigation 3
Al terminar: Ir de la lista al detalle y volver, con un ViewModel propio por entrada de la pila.
Al terminar esta lección, androidApp tendrá instalada Navigation 3, las dos claves que identifican las pantallas de la app de Android y la ficha de un Pokémon ya dibujada, con sus márgenes respetados al abrirla. Todavía nadie abre la ficha: de eso trata la lección siguiente, La pila de pantallas en Android. Pero la app compila, y eso es lo que se comprueba al final.
Navigation 3 ya la conoces: la pila es una lista que tú posees, cada pantalla es una clave y NavDisplay dibuja la última. Lo que cambia en KMP es quién decide qué pantallas existen: el mapa vive en commonMain (Destination, de El mapa de pantallas compartido) y Android tendrá sus propias claves para dibujarlo con Compose. La traducción del mapa compartido a esas claves se escribe cuando haga falta, en Deep links en Android.
Todos los comandos se escriben en la Terminal de Android Studio, en ~/Pokedex-KMP. Los ficheros nuevos de androidApp se crean con el selector del panel: clic derecho sobre la carpeta androidApp → New → File y la ruta desde la carpeta del módulo (El módulo compartido y la prueba de humo). Para abrir uno que ya existe: Navigate → File… (Cmd+Shift+O) y su nombre.
Paso 1 · Instala Navigation 3 en androidApp
Objetivo: que androidApp conozca las claves, la pila y NavDisplay. Es la receta de Los ficheros de Gradle de tu proyecto, la misma que ya aplicaste a Navigation 3 en Navigation 3: la pila de pantallas. Aquí va en el módulo de la app y no en shared, porque las claves y NavDisplay son de Android.
1 · Qué instalas y por qué ahora. Cuatro librerías y un plugin, todo en androidApp:
navigation3-runtime:NavKey, la pila yrememberNavBackStack.navigation3-ui:NavDisplay, el composable que dibuja la pantalla de arriba de la pila.lifecycle-viewmodel-navigation3: da a cada pantalla de la pila su propio almacén deViewModel. Sin ella no existerememberViewModelStoreNavEntryDecorator, el decorador que usarás en la lección siguiente.material-icons-core: el icono de la flecha de volver. Material 3 no lo trae.- El plugin
kotlinxSerialization: genera el código de cada clase@Serializable. Las claves se guardan para sobrevivir a que el sistema mate el proceso (Rotación y muerte del proceso), así que el módulo que las escribe necesita el plugin. Ya está declarado en el catálogo y en elbuild.gradle.ktsde la raíz del proyecto (conapply false) porquesharedlo usa; aquí solo se aplica también enandroidApp.
La librería kotlinx-serialization-core, que es la que trae la anotación @Serializable, no se declara: navigation3-runtime la expone con api, y por eso llega sola (la diferencia con implementation está en Gradle: ficheros, catálogo y dependencias).
2 · La versión, en [versions]. Abre gradle/libs.versions.toml y añade al final de [versions] (la cabecera ya existe):
# Un solo número para runtime y ui: se publican juntas y suben a la vez.
navigation3 = "1.1.7"3 · Las librerías, en [libraries]. Al final de esa sección, en un bloque aparte:
# La pila y las claves (NavKey).
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
# NavDisplay: lo que dibuja la pantalla de arriba. Solo tiene versión real para Android.
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
# Un almacén de ViewModel por pantalla de la pila. Reutiliza la versión de lifecycle.
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" }
# El icono de la flecha. Sin versión: la pone el BOM de Compose (la lista de versiones que ya usas).
androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" }4 · El plugin, que ya está. [plugins] y el build.gradle.kts de la raíz no se tocan.
5 · Plugin y dependencias en el módulo. Abre androidApp/build.gradle.kts. Aplica el plugin junto a los otros dos (sin apply false: en el módulo sí se aplica) y añade cuatro líneas en dependencies { … }, debajo de la última implementation:
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinxSerialization) // nuevo: sin «apply false», en el módulo sí se aplica
alias(libs.plugins.composeCompiler)
}
dependencies {
// … lo que ya había (shared, Koin, Coil, Compose…) se queda igual
// implementation en las cuatro: las usa el código de la app, y nadie más.
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.compose.material.icons.core)
}6 · Sync Now, y comprueba. Pulsa Sync Now. Debe terminar sin rojo; si sale en rojo, repasa la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto. Para comprobar las librerías, pídele a Gradle la lista de lo que usa la app al ejecutarse, filtrada:
./gradlew :androidApp:dependencies --configuration debugRuntimeClasspath | grep -E "navigation3|material-icons-core"La barra | pasa el resultado de Gradle al comando grep -E, que se queda solo con las líneas que contienen alguna de las dos palabras separadas por |. Salen bastantes líneas, porque cada librería aparece en todos los sitios que la usan. Busca estas cuatro: androidx.navigation3:navigation3-runtime, androidx.navigation3:navigation3-ui, androidx.lifecycle:lifecycle-viewmodel-navigation3 y androidx.compose.material:material-icons-core. Las tres primeras con la versión que acabas de escribir (la de navigation3) o la de lifecycle que ya tenías, y la última con la que fija el BOM de Compose del proyecto. Lo que importa es que las cuatro salgan.
Paso 2 · Las claves, que son cosa de Android
El archivo. NavKeys.kt es la frontera entre el mapa compartido y Android. Navigation 3 no navega sobre tus tipos: navega sobre claves, objetos pequeños que implementan NavKey. Aquí viven las de Android. Créalo con la ruta:
src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/NavKeys.ktEl concepto. ListaKey y DetalleKey son los gemelos locales de Lista y Detalle. Una clave de Navigation 3 tiene que ser @Serializable, y se mantiene separada de Destination por lo que viste en la lección anterior: ponerle NavKey al mapa compartido haría depender al núcleo de una librería de AndroidX que iOS no usa. Lo que se paga es mantener las dos listas de pantallas, y la frontera entre ellas es este fichero.
Es el fichero entero. NavKey es la interfaz marcadora de la librería: no tiene métodos, solo avisa de que ese tipo puede ser una clave:
package com.sebastiannarvaez.pokedex.android.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
/**
* Las claves de Navigation 3, que son de Android.
*
* El mapa de pantallas vive en el modulo compartido; esto es solo su forma
* local. Se traduce en la frontera y no antes: meter `NavKey` en `commonMain`
* haria depender al nucleo de una libreria de AndroidX que iOS no usa.
*
* Son `@Serializable` porque Navigation 3 guarda la pila para sobrevivir a que
* el sistema mate el proceso: sin eso, volver a la app tras un rato en segundo
* plano devuelve a la pantalla inicial.
*/
@Serializable
data object ListaKey : NavKey
@Serializable
data class DetalleKey(val pokemonId: Int) : NavKeyTodavía no hay traducción de Destination a estas claves: cuando un enlace tenga que convertirse en pantalla (Deep links en Android) se añadirá aquí.
Paso 3 · La ficha de Android
El archivo. PokemonDetailScreen.kt es la pantalla de la ficha: una imagen grande con el botón de volver encima, el nombre, los tipos, la descripción, las medidas y las seis barras de estadísticas. Lee el mismo PokemonDetailViewModel de El ViewModel del detalle y tiene tres estados (cargando, error con reintento y ficha), los de PokemonDetailUiState. Créalo con la ruta:
src/main/kotlin/com/sebastiannarvaez/pokedex/android/detail/PokemonDetailScreen.ktSon unas 250 líneas de Compose, casi todo presentación (columnas, filas, tamaños, colores) parecida a la de la lista que copiaste en La lista en Android, así que no se escriben a mano: crea el fichero vacío, pulsa Copiar fichero (aquí debajo), haz clic en el fichero, selecciona todo (Cmd+A) y pega (Cmd+V), como en Del JSON al Pokémon: DTO y repositorio.
Hay dos trozos que sí enseñan algo. El primero, cómo entra el identificador de la ficha. No sale del grafo de Koin: lo trae la navegación.
La primera línea del cuerpo usa delegación (by), que ya viste en Delegación: by lazy: la lectura de estado se pide al objeto de la derecha, que es el que recoge el StateFlow.
@Composable
fun PokemonDetailScreen(
pokemonId: Int,
alVolver: () -> Unit,
modifier: Modifier = Modifier,
// El identificador no sale del grafo, lo trae la navegacion: por eso se
// pasa como parametro a Koin en vez de inyectarlo.
viewModel: PokemonDetailViewModel = koinViewModel { parametersOf(pokemonId) },
) {
val estado by viewModel.uiState.collectAsStateWithLifecycle()koinViewModel { parametersOf(pokemonId) } es la pareja del viewModel { (id: Int) -> … } que escribiste en Modules.kt: lo que va dentro de parametersOf llega a ese id.
El segundo son los márgenes del sistema. Con enableEdgeToEdge() (que el proyecto activa en MainActivity y le dice al sistema que deje a la app pintar hasta los bordes), la app dibuja de borde a borde, también bajo la barra de estado (la del reloj) y la de navegación (la de gestos o botones). A lo que ocupan esas barras se le llama insets, y el sistema no los aparta por ti. Un ejemplo solo para leer (no lo escribas en el proyecto), con datos ajenos a la Pokédex, una pantalla de receta, con las tres situaciones:
@Composable
fun RecetaDelDia(alVolver: () -> Unit) {
// Con enableEdgeToEdge() esta caja ocupa TODA la pantalla, también por debajo
// de la barra de estado (arriba) y de la de gestos (abajo).
Box(modifier = Modifier.fillMaxSize()) {
Text("Tortilla de patatas", modifier = Modifier.align(Alignment.Center))
// Sin relleno, este botón nacería en la esquina de la pantalla, tapado por la barra:
// Button(onClick = alVolver, modifier = Modifier.align(Alignment.TopStart)) { Text("Volver") }
// statusBarsPadding() pide la altura REAL de la barra de estado de este móvil
// y deja ese hueco encima: el botón empieza justo bajo la barra, sin taparse.
Button(
onClick = alVolver,
modifier = Modifier.statusBarsPadding().align(Alignment.TopStart),
) { Text("Volver") }
// navigationBarsPadding() hace lo mismo abajo, con la barra de gestos.
Text(
"Para cuatro personas",
modifier = Modifier.navigationBarsPadding().align(Alignment.BottomCenter),
)
}
}Esto es lo que verías, sin escribir nada: con la línea comentada puesta, es el botón tapado por la barra, montado sobre el reloj y los iconos; con statusBarsPadding(), justo bajo la barra, sin taparse. Y el texto de abajo, sin navigationBarsPadding(), quedaría medio tapado por la barra de gestos. (Que esas funciones «consuman» el inset quiere decir que, una vez aplicado el relleno, los de dentro ya no lo vuelven a aplicar.)
La ficha hace lo mismo con su botón de volver:
// statusBarsPadding y no un relleno fijo: con enableEdgeToEdge el
// contenido se pinta debajo de la barra de estado, y la altura de
// esa barra cambia con el dispositivo. Sin esto, el boton de volver
// queda encima del reloj.
FilledTonalIconButton(
onClick = alVolver,
modifier = Modifier.statusBarsPadding().padding(12.dp).align(Alignment.TopStart),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver")
}statusBarsPadding() reserva la altura real de la barra de estado. (Icons.AutoMirrored es el grupo de iconos que se voltean solos en los idiomas que se leen de derecha a izquierda, como el árabe: la flecha de volver apunta entonces al otro lado.) Abajo, la columna de datos lleva navigationBarsPadding() antes de su relleno inferior, por la misma razón con la otra barra. Mientras no escribas PokedexApp (lección siguiente), esta pantalla no se llama desde ninguna parte.
La ficha se desplaza hacia arriba y, al ejecutarla en la lección siguiente, verás qué pasa con el botón de volver al hacerlo.
Comprueba que todo compila. Aunque nadie abra aún la ficha, Gradle compila el módulo entero:
./gradlew :androidApp:compileDebugKotlinDebe terminar en BUILD SUCCESSFUL. Si queda algo en rojo, lo más probable es una de las cuatro dependencias del paso 1: la caja de arriba dice qué mensaje sale con cada una.
Punto de control
- Sync termina sin rojo y la orden de
dependenciesmuestra las cuatro librerías (navigation3-runtime,navigation3-ui,lifecycle-viewmodel-navigation3ymaterial-icons-core). androidApp/build.gradle.ktsaplica el pluginkotlinxSerialization(sinapply false).NavKeys.kttieneListaKeyyDetalleKey, las dos@Serializable.PokemonDetailScreen.ktexiste en la carpetadetaily./gradlew :androidApp:compileDebugKotlintermina enBUILD SUCCESSFUL.- Ejecutar la app todavía enseña la lista de siempre: la ficha aún no se abre desde ningún sitio.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.En Navigation 3, ¿qué guarda la pila hacia atrás?
2.Con
enableEdgeToEdge(), el botón de volver se pegaba a la barra de estado. ¿Por qué no se arregla con unpadding(24.dp)fijo?3.La ficha pide su ViewModel con
koinViewModel { parametersOf(pokemonId) }. ¿Con qué se corresponde en Marcador y enModules.kt?4.
ListaKeyyDetalleKeyson claves de Android y no elDestinationdel núcleo compartido. ¿Por qué no se usaDestinationdirectamente como clave?