Tema 4: Navegación en Jetpack Compose
Objetivos de este tema
- Conocer las alternativas de navegación en Android: actividades múltiples vs pantallas en Compose.
- Aprender a crear y vincular actividades, enviar datos y recibir resultados.
- Introducción a Navigation Compose:
NavHost
,NavController
, rutas y argumentos. - Gestionar la navegación con
ViewModel
en el contexto de MVVM y Clean Architecture. - Comparar cuándo elegir múltiples actividades o una sola actividad con varias pantallas.
4.1. Introducción a Navigation Compose
Navigation Compose es una extensión de la Jetpack Navigation Architecture Component, está adaptada para trabajar con Jetpack Compose, y permite gestionar la navegación entre pantallas o destinos (screens) de forma declarativa, simplificando así el manejo de la pila de navegación y mejorando la estructura del proyecto.
Gracias a la integración con Jetpack Compose, Navigation Compose facilita la navegación entre diferentes Composables sin necesidad de usar múltiples actividades o fragmentos, algo muy habitual en aplicaciones tradicionales que utilizan vistas basadas en XML.
4.1.1. Beneficios frente a múltiples actividades
El uso de Navigation Compose frente al uso de múltiples actividades ofrece varias ventajas:
-
Menor complejidad del proyecto
- En vez de tener múltiples Activity, todo el flujo de navegación se maneja desde una única Activity que contiene los Composable destino.
- Esto reduce la complejidad del proyecto y mejora la mantenibilidad del código.
Con múltiples Activity:
1val intent = Intent(this, DetailsActivity::class.java) 2startActivity(intent)
Con Navigation Compose:
1navController.navigate("details")
-
Mejor experiencia de usuario
- La navegación entre pantallas es más rápida y fluida, ya que no se necesitan procesos con alto coste como puede ser el inicio de nuevas Activity.
- Se evitan recreaciones innecesarias de componentes.
-
Uso eficiente de recursos
- Menos uso de memoria y recursos del sistema, ya que no se crean múltiples contextos o ciclos de vida de Activity.
-
Navegación declarativa
- Se adapta perfectamente al paradigma declarativo de Jetpack Compose, permitiendo definir la navegación de forma clara y sencilla en el código.
Ejemplo de declaración:
1NavHost(navController, startDestination = "home") { 2 composable("home") { HomeScreen() } 3 composable("profile") { ProfileScreen() } 4}
-
Mejor gestión del estado
- Facilita la gestión del estado compartido entre pantallas dentro de una misma Activity, lo cual es más complicado cuando se usan múltiples actividades.
Ambas pantallas compartirán el mismo
SharedViewModel
:1val viewModel: SharedViewModel = viewModel() 2 3composable("screenA") { 4 ScreenA(viewModel) 5} 6 7composable("screenB") { 8 ScreenB(viewModel) 9}
Cuando tienes múltiples
Activity
, compartir datos entre ellas requiere serializar objetos, usarBundle
,Intent
, o incluso patrones comoViewModel
compartidos con un alcance específico.
Comparativa rápida
Característica | Múltiples Activity | Navigation Compose |
---|---|---|
Cantidad de archivos | Mayor | Menor |
Gestión del estado | Compleja | Más simple |
Velocidad de navegación | Más lenta | Más rápida |
Consumo de recursos | Alto | Bajo |
Integración con Jetpack Compose | No nativa | Integación total |
4.2. Definición de destinos (Composable)
En Jetpack Compose, los destinos (Composables) son las pantallas o vistas a las que se puede navegar dentro de una aplicación. La navegación se gestiona mediante la librería Navigation Component, pero adaptada para Compose, para ello se añadirá la dependencia al Gradle.
1// Navigation Compose
2implementation("androidx.navigation:navigation-compose:2.9.2")
¿Cómo se define un destino?
Un destino se define dentro del NavHost
, asociado a una ruta de navegación. Cada destino puede mostrar un Composable diferente, en el punto anterior ya se ha podido ver.
NavHost
-
Es el contenedor que gestiona los destinos de navegación.
-
Define qué Composable deberá mostrarse según la ruta.
-
Se crea mediante el componente
NavHost
proporcionado por la libreríaandroidx.navigation:navigation-compose
.Ejemplo básico de
NavHost
:1val navController = rememberNavController() 2 3NavHost(navController = navController, startDestination = "home") { 4 composable("home") { 5 HomeScreen(navController) 6 } 7 composable("detail") { 8 DetailScreen() 9 } 10}
Este ejemplo define dos destinos: “home” y “detail”, cada uno mostrando un Composable diferente.
-
4.2.1. Rutas con parámetros
Las rutas con parámetros permiten navegar a destinos dinámicos, pasando valores como parte de la ruta. Esto es útil para mostrar información específica, como un producto, usuario o noticia.
-
Rutas
-
Cada pantalla (destino) debe tener una ruta asociada, que será un identificador único (string).
-
Se pueden utilizar rutas simples como “home” o con parámetros como “details/{id}”.
Sintaxis de ruta básica:
1composable("aboutit"){ AboutIt() }
Sintaxis de ruta con parámetro:
1composable("detail/{id}")
Este ejemplo muestra el parámetro {id}, que es dinámico y podrá recuperarse dentro del Composable.
Navegar pasando el parámetro ID:
1// Desde una pantalla anterior 2navController.navigate("detail/123")
Pasar el parámetro al destino:
1composable("detail/{id}") { backStackEntry -> 2 val idProducto = backStackEntry.arguments?.getString("id") 3 idProducto?.let { 4 DetailScreen(id = it.toInt()) 5 } 6}
!> Importante: Deberás asegúrate de convertir el valor si fuese necesario a otro tipo, como
Int
.
-
-
NavController
-
Es el encargado de gestionar la navegación entre destinos.
-
Se obtiene con
rememberNavController()
y se pasa alNavHost
. -
Se utiliza para navegar entre pantallas, por ejemplo:
navController.navigate("details")
.Navegación desde un botón:
1Button(onClick = { navController.navigate("aboutit") }) { 2 Text("Acerca de...") 3}
-
Resumen rápido
Concepto | Descripción |
---|---|
NavController |
Controla la navegación entre destinos. |
NavHost |
Contenedor que define las pantallas y sus rutas. |
composable() |
Define una pantalla dentro del NavHost. |
Rutas | Identificadores únicos que representan cada pantalla. |
4.2.2. Paso de datos entre pantallas
El paso de datos entre pantallas es algo muy común en las aplicaciones. En Jetpack Compose, esto se puede hacer de varias formas, pero la más común es mediante parámetros en la ruta o usando argumentos explícitos.
Ejemplo completo: paso de datos entre pantallas
Pantalla principal (lista de productos):
1@Composable
2fun HomeScreen(navController: NavHostController) {
3 val productos = listOf("Producto 1", "Producto 2", "Producto 3", "Producto 4", "Producto 5")
4
5 Column(
6 modifier = Modifier.fillMaxSize().padding(16.dp),
7 verticalArrangement = Arrangement.spacedBy(8.dp)
8 ) {
9 Button(onClick = { navController.navigate("aboutit") }) {
10 Text("Acerca de...")
11 }
12 LazyColumn(
13 contentPadding = PaddingValues(8.dp),
14 verticalArrangement = Arrangement.spacedBy(8.dp)
15 ) {
16 items(productos) { producto ->
17 Card(
18 modifier = Modifier.fillMaxWidth()
19 .clickable {
20 navController.navigate("detail/${productos.indexOf(producto) + 1}")
21 }.padding(4.dp)
22 ) {
23 Text(
24 text = producto,
25 modifier = Modifier.padding(16.dp)
26 )
27 }
28 }
29 }
30 }
31}
Pantalla detalle (recibe el ID):
1@Composable
2fun DetailScreen(navBackStackEntry: NavBackStackEntry) {
3 val idProducto = navBackStackEntry.arguments?.getString("id")
4
5 Text(
6 text = "Mostrando detalles del producto con ID: $idProducto",
7 modifier = Modifier.padding(16.dp)
8 )
9}
Pantalla “Acerca de”:
1@Composable
2fun AboutIt() {
3 Text(
4 text = "App creada por Javier Carrasco para la documentación de T4.2",
5 modifier = Modifier.padding(16.dp)
6 )
7}
El NavHost
en el método Composable Navigation()
quedaría así:
1@Composable
2fun Navigation() {
3 // Aquí se definirían las rutas de navegación.
4 // Por ejemplo, usando NavHost y composable.
5 val navController: NavHostController = rememberNavController()
6
7 NavHost(navController = navController, startDestination = "home") {
8 composable("home") { HomeScreen(navController) }
9 composable("aboutit"){ AboutIt() }
10 composable("detail/{id}") { backStackEntry ->
11 // Aquí se recibe el parámetro id de la ruta.
12 val idProducto = backStackEntry.arguments?.getString("id")
13 // Se puede usar el idProducto para mostrar detalles específicos.
14 idProducto?.let {
15 DetailScreen(backStackEntry)
16 }
17 }
18 }
19}
Información
Se utiliza idProducto?.let
para comprobar que se pasa el parámetro y no sea nulo.
Por último, la clase MainActivity
podría quedar así:
1class MainActivity : ComponentActivity() {
2 @OptIn(ExperimentalMaterial3Api::class)
3 override fun onCreate(savedInstanceState: Bundle?) {
4 super.onCreate(savedInstanceState)
5 enableEdgeToEdge()
6 setContent {
7 DocumentationT4_2Theme {
8 Scaffold(
9 topBar = {TopAppBar(title = { Text("Documentación T4.2") })},
10 modifier = Modifier.fillMaxSize()
11 ) { innerPadding ->
12 Column(Modifier.padding(innerPadding).fillMaxSize()) {
13 Navigation()
14 }
15 }
16 }
17 }
18 }
19}
Si buscas más separación entre destinos cuando la complejidad de estos aumenta, puedes llevarte los métodos HomeScreen()
y DetailsScreen()
a ficheros separados.
Resumen rápido
Concepto | Descripción |
---|---|
Destino (composable ) |
Es una pantalla que se muestra al navegar, definida con composable("ruta") . |
Ruta con parámetro | Se define como "ruta/{param}" y se recupera con backStackEntry.arguments . |
Paso de datos | Se realiza a través de parámetros en la ruta o mediante argumentos extras. |
Integración | Se puede usar en MVVM para cargar datos desde ViewModel , ROOM o Retrofit2 (se verá en próximos temas). |
4.3. Navegación controlada con NavController
Como ya se ha comentado, la navegación en Jetpack Compose entre pantallas se gestiona mediante la librería Navigation Compose
, la cual permite crear una jerarquía de pantallas y navegar entre ellas de forma sencilla.
Para controlar la navegación, se utiliza el objeto NavController
, que da acceso a métodos como navigate()
, popBackStack()
o navigateUp()
. Estos métodos permiten gestionar la pila de navegación y el comportamiento del botón de retroceso del dispositivo.
4.3.1. navigate(), popBackStack(), navigateUp()
-
navigate()
El método
navigate()
se utiliza para ir de una pantalla a otra. Primero se deberá definir las rutas de navegación, y luego usarnavigate()
pasando la ruta destino. Ya has tenido contacto con este método en puntos anteriores.Ejemplo:
1@Composable 2fun MyNavigation() { 3 val navController: NavHostController = rememberNavController() 4 5 NavHost(navController, startDestination = "pantalla1") { 6 composable("pantalla1") { Pantalla1(navController) } 7 } 8}
Código de
Pantalla1
:1@Composable 2fun Pantalla1(navController: NavController) { 3 Column( 4 modifier = Modifier.fillMaxSize(), 5 horizontalAlignment = Alignment.CenterHorizontally, 6 verticalArrangement = Arrangement.Center 7 ) { 8 Text("Pantalla 1") 9 Button(onClick = { navController.navigate("pantalla2") }) { 10 Text("Ir a pantalla 2") 11 } 12 } 13}
-
popBackStack()
Este método eliminará la última pantalla del stack (pila) de navegación y vuelve a la pantalla anterior.
Código de
Pantalla2
:1@Composable 2fun Pantalla2(navController: NavController) { 3 Column( 4 modifier = Modifier.fillMaxSize(), 5 horizontalAlignment = Alignment.CenterHorizontally, 6 verticalArrangement = Arrangement.Center 7 ) { 8 Text("Pantalla 2") 9 Button(onClick = { navController.popBackStack() }) { 10 Text("Volver a la pantalla 1") 11 } 12 } 13}
Recuerda añadir el composable para
Pantalla2
en elNavHost
. -
navigateUp()
Este método funciona de forma similar a
popBackStack()
, pero se usa generalmente cuando hay una jerarquía anidada de pantallas, como en pantallas de detalles o en navegación por pestañas.Ejemplo:
1Button(onClick = { navController.navigateUp() }) { 2 Text("Navegar hacia arriba") 3}
Información
navigateUp()
puede no funcionar si no estás en una ruta con padre definido. Para la mayoría de casos, popBackStack()
es suficiente.
4.3.2. Manejo del stack y el botón “atrás”
El stack de navegación es como una pila donde se van guardando las pantallas por las que se pasan. Cada vez que se utiliza navigate()
, la nueva pantalla se apila encima. Al pulsar el botón “atrás”, se desapila la última pantalla.
Ejemplo del stack:
- Inicialmente:
pantalla1
. - Navegas a
pantalla2
: el stack es [pantalla1
,pantalla2
]. - Pulsas atrás: se elimina
pantalla2
y vuelves apantalla1
.
Personalizar el comportamiento del botón “atrás”:
Por defecto, Android gestiona el botón de retroceso con el stack de navegación. Pero si quieres hacer algo especial al pulsarlo (como mostrar un diálogo antes de salir), se puede usar BackHandler
para modificar el comportamiento básico.
Ejemplo:
1@Composable
2fun Pantalla2(navController: NavController) {
3 val ctxt = LocalContext.current
4
5 // Manejo del botón de retroceso
6 BackHandler {
7 // Aquí se define lo que ocurre al pulsar atrás
8 navController.popBackStack()
9
10 Toast.makeText(ctxt, "Volviendo a pantalla 1", Toast.LENGTH_SHORT).show()
11 Log.d("Pantalla2", "Back button pressed")
12 }
13
14 /* ... */
15}
Información
BackHandler
permite interceptar el evento del botón “atrás” y definir un comportamiento personalizado.
Resumen de los métodos
Método | Función |
---|---|
navigate(route) |
Va a otra pantalla. |
popBackStack() |
Vuelve a la pantalla anterior. |
navigateUp() |
Vuelve a la pantalla padre (si existe jerarquía). |
BackHandler {} |
Controla el botón de retroceso del dispositivo. |
Con NavController
y sus métodos, tendrás el control total sobre la navegación de la app. Esto es fundamental cuando trabajas con arquitecturas como MVVM o Clean Architecture, ya que la navegación puede estar controlada desde el ViewModel
.
Ejemplos prácticos
Ejemplo práctico 12 Aplicación con tres pantallas
4.4. Navegación con ViewModel y estado
En aplicaciones Android modernas que usan arquitecturas como MVVM (Modelo-Vista-ViewModel), es fundamental gestionar correctamente el estado de la navegación y compartirlo entre pantallas cuando sea necesario.
En Jetpack Compose se puede usar ViewModel
para mantener el estado de la aplicación, incluso para cambiar de pantalla. Esto permite evitar que se pierdan datos al navegar o al rotar la pantalla.
4.4.1. Compartir estado entre pantallas
Imagina que tienes una pantalla de formulario (FormScreen
) y otra de resumen (SummaryScreen
). Quieres que los datos introducidos en el formulario estén disponibles en la pantalla de resumen.
Usar un ViewModel compartido
Un ViewModel
puede ser compartido entre pantallas si se crea en un ámbito (scope) común, como el de la navegación completa.
Además de la librería de Navigation Compose será necesaria la librería para la gestión del ciclo de vida y el uso de ViewModel
.
1implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
Ejemplo: compartir datos entre pantallas
-
Se define el
ViewModel
, en este caso en un fichero independiente.1class SharedViewModel : ViewModel() { 2 var name = mutableStateOf("") 3 private set 4 5 fun updateNombre(value: String) { 6 name.value = value 7 } 8}
-
Se definen las pantallas y rutas, también por separado.
1sealed class Screen(val route: String) { 2 object FormScreen : Screen("form") 3 object SummaryScreen : Screen("summary") 4}
-
Configuración de la navegación.
En el fichero
MainActivity.kt
, fuera de la clase, se crea el siguiente método para gestionar la navegación de la aplicación.1@Composable 2fun MyAppNav() { 3 val navController = rememberNavController() 4 val sharedViewModel: SharedViewModel = viewModel() 5 6 NavHost(navController = navController, startDestination = Screen.FormScreen.route) { 7 composable(Screen.FormScreen.route) { 8 FormScreen(navController, sharedViewModel) 9 } 10 composable(Screen.SummaryScreen.route) { 11 SummaryScreen(sharedViewModel) 12 } 13 } 14}
Información
Aquí se crea el
SharedViewModel
fuera delNavHost
o en un scope compartido, por lo que todas las pantallas tendrán acceso al mismoViewModel
. -
Pantalla formulario.
Además de la gestión de la navegación, se ha añadido en el siguiente bloque la gestión del error del campo vacío.
1@Composable 2fun FormScreen(navController: NavController, viewModel: SharedViewModel) { 3 var input by remember { mutableStateOf("") } 4 var errorMessage by remember { mutableStateOf("") } 5 var isError by remember { mutableStateOf(false) } 6 7 Column(modifier = Modifier.padding(16.dp)) { 8 OutlinedTextField( 9 value = input, 10 onValueChange = { input = it; isError = false; errorMessage = "" }, 11 label = { Text("Introduce tu nombre") }, 12 modifier = Modifier.fillMaxWidth(), 13 singleLine = true, 14 trailingIcon = { // Icono de error opcional. 15 if (isError) 16 Icon(Icons.Default.Info, contentDescription = "Error", tint = MaterialTheme.colorScheme.error) 17 }, 18 supportingText = { // Texto de apoyo para mostrar mensajes de error. 19 if (isError) 20 Text(errorMessage, color = MaterialTheme.colorScheme.error) 21 }, 22 isError = isError // Control de error visual. 23 ) 24 Button(onClick = { 25 if (input.isNotEmpty()) { 26 viewModel.updateNombre(input) 27 navController.navigate(Screen.SummaryScreen.route) 28 } else { 29 // Aquí podrías mostrar un mensaje de error si el campo está vacío 30 isError = true 31 errorMessage = "El campo no puede estar vacío" 32 } 33 }) { 34 Text("Siguiente") 35 } 36 } 37}
-
Pantalla resumen.
1@Composable 2fun SummaryScreen(viewModel: SharedViewModel) { 3 Column(modifier = Modifier.padding(16.dp)) { 4 Text("Tu nombre es: ${viewModel.name.value}") 5 } 6}
4.4.2. Scope adecuado del ViewModel por destino
En algunas ocasiones no se necesita que el ViewModel
esté compartido entre todas las pantallas, sino que su alcance (scope) esté limitado a un destino específico de la navegación.
Jetpack Compose permite asociar un ViewModel
a un destino específico usando el viewModel()
dentro del composable
.
¿Cuándo hacerlo? Cuando se necesita que el estado solo exista mientras se está en esa pantalla, y que se reinicie si se vuelve a ella más tarde.
Información
Nunca debe pasarse al ViewModel
parámetros sin crear ViewModelProvider.Factory
o añadir inyección de dependencias con Hilt.
Versión ViewModelProvider.Factory
-
Se crea la clase
DetalleViewModel
que extenderá deViewModel()
y se añade el factory.1class DetalleViewModel(id: String) : ViewModel() { 2 val itemId = id 3 val contenido = mutableStateOf("Contenido del ítem $itemId") 4} 5 6class DetalleViewModelFactory(private val id: String) : ViewModelProvider.Factory { 7 override fun <T : ViewModel> create(modelClass: Class<T>): T { 8 @Suppress("UNCHECKED_CAST") 9 return DetalleViewModel(id) as T 10 } 11}
-
Se crea la nueva ruta con
composable
, haciendo uso del factory creado en elNavHost
.1composable("detalle/{id}") { backStackEntry -> 2 val id = backStackEntry.arguments?.getString("id") ?: "default" // Valor por defecto si no se pasa un ID. 3 val factory = DetalleViewModelFactory(id) // Crear una instancia del ViewModel con el ID recibido. 4 val detalleViewModel: DetalleViewModel = viewModel(factory = factory) // Usar el factory para crear el ViewModel. 5 6 DetalleScreen(detalleViewModel) 7}
-
Se hace la llamada desde un botón (por ejemplo).
1Button( 2 onClick = { 3 // Navegar a la pantalla de detalle con un ID ficticio 4 navController.navigate("detalle/321") 5 } 6) { Text("Detalle item") }
-
Pantalla detalle.
1@Composable 2fun DetalleScreen(viewModel: DetalleViewModel) { 3 Column(modifier = Modifier.padding(16.dp)) { 4 Text("ID: ${viewModel.itemId}") 5 Text("Contenido: ${viewModel.contenido.value}") 6 } 7}
Resumen de scopes de ViewModel
Scope del ViewModel |
Uso |
---|---|
Compartido globalmente | Para compartir datos entre pantallas (ej: formulario-resumen). |
Por destino (composable) | Para que el ViewModel solo viva mientras estás en esa pantalla. |
Con clave única (por ejemplo por ID) | Para tener un ViewModel diferente por cada ítem (ej: detalles de productos). |
Ejemplos prácticos
Ejemplo práctico 13 Scope adecuado del ViewModel por destino con HILT