Tema 3. Navigation Compose

  • Bloque: B2 — Arquitectura MVVM y desarrollo de aplicaciones Android
  • Duración aproximada: 10 horas
  • RA2 — Desarrolla aplicaciones para dispositivos móviles analizando y empleando las tecnologías y librerías específicas.
Código Criterio
RA2-a Se ha generado la estructura de clases necesaria para la aplicación.
RA2-c Se han utilizado las clases necesarias para la conexión y comunicación con dispositivos inalámbricos.
RA2-g Se han realizado pruebas de interacción usuario-aplicación para optimizar las aplicaciones desarrolladas a partir de emuladores.
RA2-i Se han documentado los procesos necesarios para el desarrollo de las aplicaciones.

Dependencias necesarias#

 1// build.gradle.kts — plugins (nivel de proyecto o settings.gradle.kts)
 2plugins {
 3    id("org.jetbrains.kotlin.plugin.serialization") version "2.3.10" apply false
 4}
 5
 6// build.gradle.kts (app) — plugins del módulo
 7plugins {
 8    id("org.jetbrains.kotlin.plugin.serialization")
 9}
10
11// build.gradle.kts (app) — dependencias
12dependencies {
13    // Navigation Compose (API tipada disponible desde 2.8.0)
14    implementation("androidx.navigation:navigation-compose:2.9.7")
15
16    // Kotlin Serialization — imprescindible para las rutas @Serializable
17    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
18}

Compatibilidad de versiones: El plugin kotlin.plugin.serialization debe tener exactamente la misma versión que Kotlin. Si tu proyecto usa Kotlin 2.1.20, el plugin debe ser también 2.1.20. Una discrepancia producirá errores de compilación difíciles de diagnosticar.


1. Introducción: una sola Activity, múltiples pantallas#

En el Bloque 1 se vio el enfoque clásico de múltiples Activities: cada pantalla es una Activity, y se navega entre ellas con Intent. En el desarrollo moderno con Jetpack Compose, el patrón recomendado es la Single Activity Architecture: una única MainActivity que contiene un NavHost, y cada pantalla es un @Composable.

Enfoque clásico (Bloque 1)          Enfoque moderno (este tema)
─────────────────────────           ──────────────────────────
MainActivity                        MainActivity
ListadoActivity               →         setContent {
DetalleActivity                             NavHost {
FavoritosActivity                               Pantalla Listado
                                                Pantalla Detalle
                                                Pantalla Favoritos
                                            }
                                        }

Las ventajas del enfoque con NavHost son: navegación declarativa y gestionada automáticamente, gestión del botón Atrás sin código adicional, paso de argumentos tipado y seguro, animaciones de transición integradas, y compatibilidad directa con ViewModel por destino.


2. Componentes de Navigation Compose#

NavController es el objeto central de la navegación. Mantiene la pila de navegación (back stack) y proporciona métodos para navegar entre destinos. En Compose se obtiene con rememberNavController().

1// Crear el NavController — siempre en el nivel más alto posible
2// (normalmente en MainActivity o en el composable raíz)
3val navController = rememberNavController()

Nunca se debe pasar el NavController directamente a los composables de pantalla. En su lugar, se pasan lambdas de navegación (callbacks). Esto hace que las pantallas sean independientes de Navigation, lo que facilita su testeo y reutilización.

NavHost es el contenedor que muestra el contenido del destino actual según el grafo de navegación. Requiere el NavController y un destino de inicio:

 1NavHost(
 2    navController = navController,
 3    startDestination = Listado   // objeto @Serializable, no un String
 4) {
 5    composable<Listado> {
 6        PantallaListado(onNavegaADetalle = { id -> navController.navigate(Detalle(id)) })
 7    }
 8    composable<Detalle> { backStackEntry ->
 9        val ruta = backStackEntry.toRoute<Detalle>()
10        PantallaDetalle(peliculaId = ruta.id, onVolver = { navController.navigateUp() })
11    }
12}

Back Stack (pila de navegación)#

Cada llamada a navController.navigate(destino) apila el nuevo destino encima del actual. El botón Atrás del dispositivo desapila el destino superior automáticamente:

Inicio:   [Listado]
navigate(Detalle(1)):  [Listado] → [Detalle(1)]
navigate(Favoritos):   [Listado] → [Detalle(1)] → [Favoritos]
navigateUp():          [Listado] → [Detalle(1)]
navigateUp():          [Listado]

3. Rutas tipadas con @Serializable#

3.1 La evolución de las rutas en Navigation Compose#

Antes de Navigation 2.8.0, las rutas eran cadenas de texto con argumentos embebidos como fragmentos de URL:

1// ❌ API antigua (antes de Navigation 2.8) — evitar en proyectos nuevos
2composable("detalle/{id}") { backStackEntry ->
3    val id = backStackEntry.arguments?.getString("id")?.toInt() ?: 0
4    PantallaDetalle(peliculaId = id)
5}
6navController.navigate("detalle/42")

Este enfoque presentaba varios problemas: los errores tipográficos en los strings producían crashes en tiempo de ejecución, el compilador no verificaba que los argumentos fueran correctos, y el código de extracción de argumentos era verbose y propenso a errores.

A partir de Navigation 2.8.0, las rutas se definen como clases y objetos de Kotlin anotados con @Serializable:

1// ✅ API tipada (Navigation 2.8+) — recomendada
2@Serializable
3data object Listado   // sin argumentos → data object
4
5@Serializable
6data class Detalle(val id: Int)   // con argumentos → data class
7
8navController.navigate(Detalle(id = 42))   // tipado, seguro en compilación

3.2 Reglas para definir rutas#

 1import kotlinx.serialization.Serializable
 2
 3// Sin argumentos → data object
 4@Serializable
 5data object Listado
 6
 7@Serializable
 8data object Favoritos
 9
10// Con argumento requerido → data class
11@Serializable
12data class Detalle(val id: Int)
13
14// Con argumento opcional (nullable) → tipo nullable con valor por defecto
15@Serializable
16data class Busqueda(val query: String? = null)
17
18// Con varios argumentos y valores por defecto
19@Serializable
20data class Edicion(
21    val id: Int,
22    val esNueva: Boolean = false
23)

Los tipos de argumento soportados nativamente son Int, Long, Float, Boolean, String y List<Int>. Para tipos personalizados (enums, data classes complejas) es necesario proporcionar un NavType personalizado, lo cual queda fuera del alcance de este tema.

¿Dónde definir las rutas? Se recomienda crear un archivo dedicado ui/navegacion/Rutas.kt con todas las definiciones @Serializable. Así, cualquier parte del código puede importarlas sin crear dependencias circulares.

3.3 Extraer argumentos con toRoute<T>()#

Dentro de un bloque composable<T>, se accede a los argumentos de la ruta actual con backStackEntry.toRoute<T>():

1composable<Detalle> { backStackEntry ->
2    // toRoute<T>() deserializa la ruta y devuelve el objeto con los argumentos
3    val ruta: Detalle = backStackEntry.toRoute<Detalle>()
4
5    PantallaDetalle(
6        peliculaId = ruta.id,
7        onVolver = { navController.navigateUp() }
8    )
9}

Desde un ViewModel, los mismos argumentos son accesibles a través de SavedStateHandle:

1class DetalleViewModel(
2    savedStateHandle: SavedStateHandle
3) : ViewModel() {
4    // toRoute<T>() también está disponible en SavedStateHandle
5    private val ruta: Detalle = savedStateHandle.toRoute<Detalle>()
6    val peliculaId: Int = ruta.id
7}

4. Callbacks de navegación en lugar del NavController#

La documentación oficial de Android recomienda explícitamente no pasar el NavController directamente a los composables de pantalla. En su lugar, cada pantalla recibe lambdas de navegación como parámetros:

 1// ✅ Patrón recomendado — la pantalla no sabe nada de Navigation
 2@Composable
 3fun PantallaDetalle(
 4    peliculaId: Int,
 5    onVolver: () -> Unit,
 6    onCompartir: (String) -> Unit,
 7    viewModel: DetalleViewModel = viewModel(factory = DetalleViewModel.Factory)
 8) {
 9    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
10
11    Scaffold(
12        topBar = {
13            TopAppBar(
14                title = { Text("Detalle") },
15                navigationIcon = {
16                    IconButton(onClick = onVolver) {
17                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver")
18                    }
19                }
20            )
21        }
22    ) { padding ->
23        // contenido de la pantalla...
24    }
25}
1// ❌ Antipatrón — la pantalla está acoplada a Navigation
2@Composable
3fun PantallaDetalleMal(
4    peliculaId: Int,
5    navController: NavController   // difícil de testear, acoplado a Navigation
6) {
7    // ...
8    Button(onClick = { navController.navigateUp() }) { Text("Volver") }
9}

Las ventajas de los callbacks son que las pantallas se pueden testear sin instanciar un NavController, se pueden usar en previews de Android Studio sin configuración adicional, y las pantallas son reutilizables independientemente del grafo de navegación.


5. Grafo de navegación de AppFlix#

5.1 Definición de rutas#

1// ─── ui/navegacion/Rutas.kt ────────────────────────────────────────────────
2import kotlinx.serialization.Serializable
3
4@Serializable data object PantallaInicio
5@Serializable data object PantallaListado
6@Serializable data class PantallaDetalle(val id: Int)
7@Serializable data object PantallaFavoritos

5.2 NavHost completo de AppFlix#

 1// ─── ui/navegacion/AppNavigation.kt ────────────────────────────────────────────────
 2@Composable
 3fun AppNavigation(
 4    navController: NavHostController = rememberNavController()
 5) {
 6    Scaffold(
 7        bottomBar = {
 8            AppFlixBottomBar(navController = navController)
 9        }
10    ) { innerPadding ->
11        NavHost(
12            navController = navController,
13            startDestination = PantallaListado,
14            modifier = Modifier.padding(innerPadding)
15        ) {
16
17            composable<PantallaListado> {
18                PantallaListado(
19                    onNavegaADetalle = { id ->
20                        navController.navigate(PantallaDetalle(id = id))
21                    }
22                )
23            }
24
25            composable<PantallaDetalle> { backStackEntry ->
26                val ruta = backStackEntry.toRoute<PantallaDetalle>()
27                PantallaDetalle(
28                    peliculaId = ruta.id,
29                    onVolver = { navController.navigateUp() }
30                )
31            }
32
33            composable<PantallaFavoritos> {
34                PantallaFavoritos(
35                    onNavegaADetalle = { id ->
36                        navController.navigate(PantallaDetalle(id = id))
37                    }
38                )
39            }
40        }
41    }
42}

5.3 Inicialización en MainActivity#

 1// MainActivity.kt
 2class MainActivity : ComponentActivity() {
 3    override fun onCreate(savedInstanceState: Bundle?) {
 4        super.onCreate(savedInstanceState)
 5        setContent {
 6            AppFlixTheme {
 7                AppNavigation()
 8            }
 9        }
10    }
11}

6. Navegación con BottomNavigationBar#

La barra de navegación inferior permite al usuario cambiar entre las secciones principales de la app. Se integra con el NavController mediante currentBackStackEntryAsState(), que devuelve el destino actual de forma reactiva.

6.1 Definición de ítems del BottomBar#

 1// ─── ui/navegacion/ItemsNavegacion.kt ────────────────────────────────────────────────
 2import androidx.compose.material.icons.Icons
 3import androidx.compose.material.icons.filled.*
 4import androidx.compose.ui.graphics.vector.ImageVector
 5
 6data class ItemNavegacion<T : Any>(
 7    val etiqueta: String,
 8    val ruta: T,
 9    val icono: ImageVector,
10    val iconoSeleccionado: ImageVector = icono
11)
12
13val itemsNavegacionPrincipal = listOf(
14    ItemNavegacion(
15        etiqueta = "Catálogo",
16        ruta = PantallaListado,
17        icono = Icons.Default.Movie,
18        iconoSeleccionado = Icons.Filled.Movie
19    ),
20    ItemNavegacion(
21        etiqueta = "Favoritos",
22        ruta = PantallaFavoritos,
23        icono = Icons.Default.FavoriteBorder,
24        iconoSeleccionado = Icons.Filled.Favorite
25    )
26)

6.2 Composable del BottomBar#

 1// ─── ui/navegacion/AppFlixBottomBar.kt ────────────────────────────────────────────────
 2@Composable
 3fun AppFlixBottomBar(navController: NavController) {
 4    // currentBackStackEntryAsState: State reactivo del destino actual
 5    // Cuando el usuario navega, este State cambia y la barra se recompone
 6    val navBackStackEntry by navController.currentBackStackEntryAsState()
 7    val destinoActual = navBackStackEntry?.destination
 8
 9    // Solo mostrar el BottomBar en las pantallas principales
10    // (no en PantallaDetalle, por ejemplo)
11    val mostrarBottomBar = itemsNavegacionPrincipal.any { item ->
12        destinoActual?.hierarchy?.any { it.hasRoute(item.ruta::class) } == true
13    }
14
15    if (mostrarBottomBar) {
16        NavigationBar {
17            itemsNavegacionPrincipal.forEach { item ->
18                // hierarchy: recorre la cadena de destinos desde el actual hasta la raíz
19                // Útil con grafos anidados para resaltar el ítem correcto
20                val seleccionado = destinoActual?.hierarchy?.any {
21                    it.hasRoute(item.ruta::class)
22                } == true
23
24                NavigationBarItem(
25                    selected = seleccionado,
26                    onClick = {
27                        navController.navigate(item.ruta) {
28                            // popUpTo: vuelve al inicio antes de navegar
29                            // evita acumular destinos en la pila al cambiar de pestaña
30                            popUpTo(navController.graph.findStartDestination().id) {
31                                saveState = true   // guarda el estado de la pantalla anterior
32                            }
33                            // launchSingleTop: no apila múltiples copias del mismo destino
34                            launchSingleTop = true
35                            // restoreState: restaura el estado guardado con saveState
36                            restoreState = true
37                        }
38                    },
39                    icon = {
40                        Icon(
41                            imageVector = if (seleccionado) item.iconoSeleccionado else item.icono,
42                            contentDescription = item.etiqueta
43                        )
44                    },
45                    label = { Text(item.etiqueta) }
46                )
47            }
48        }
49    }
50}

popUpTo, launchSingleTop y restoreState: Estos tres parámetros del bloque navigate { } trabajan juntos para implementar el comportamiento estándar de un BottomBar. Sin ellos, cada toque en un ítem apilaría un nuevo destino, haciendo que el botón Atrás tuviera que recorrer toda la pila antes de salir de la app.


7. Gestión de la pila de navegación#

7.1 navigateUp() vs popBackStack()#

Ambas funciones navegan hacia atrás, pero con diferencias importantes:

navController.navigateUp() navController.popBackStack()
Comportamiento si la pila está vacía No hace nada (seguro) La pantalla puede quedar vacía
Con deep links Navega a la app anterior Solo desapila destinos propios
Uso recomendado Botón Atrás en TopAppBar Lógica de navegación programática
1// ✅ Para el botón Atrás en la barra superior → navigateUp()
2IconButton(onClick = { navController.navigateUp() }) {
3    Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver")
4}
5
6// ✅ Para volver al listado desde cualquier punto → popBackStack tipado
7navController.popBackStack<PantallaListado>(inclusive = false)

7.2 Opciones de navigate { }#

El bloque lambda de navigate { } permite configurar cómo se modifica la pila al navegar:

1navController.navigate(PantallaListado) {
2    // Eliminar todo hasta PantallaListado antes de navegar
3    popUpTo<PantallaListado> {
4        inclusive = true    // true: también elimina PantallaListado
5        saveState = true    // guarda el estado para poder restaurarlo
6    }
7    launchSingleTop = true  // no crear una nueva instancia si ya está en la cima
8    restoreState = true     // restaurar estado guardado previamente con saveState
9}

8. ViewModel por destino y ViewModel compartido#

8.1 ViewModel por destino#

Cuando se llama a viewModel() dentro de un bloque composable<T>, el ViewModel se crea con el scope del destino de navegación. Se destruye automáticamente al salir del destino (al hacer navigateUp() o popBackStack()):

 1NavHost(...) {
 2    composable<PantallaDetalle> { backStackEntry ->
 3        val ruta = backStackEntry.toRoute<PantallaDetalle>()
 4
 5        // Este ViewModel se destruye al salir de PantallaDetalle
 6        val viewModel: DetalleViewModel = viewModel(
 7            factory = DetalleViewModel.factoryConId(ruta.id)
 8        )
 9        PantallaDetalle(viewModel = viewModel, onVolver = { navController.navigateUp() })
10    }
11}

8.2 ViewModel compartido entre destinos#

Para compartir un ViewModel entre varias pantallas (por ejemplo, un flujo de formulario de varios pasos), se alcanza el scope del grafo padre con navController.getBackStackEntry<T>(). El ViewModel se destruye cuando el grafo padre sale de la pila:

 1// Rutas del flujo de edición
 2@Serializable data object GrafoPedido      // identificador del grafo anidado
 3@Serializable data object PasoSeleccion
 4@Serializable data object PasoConfirmacion
 5
 6NavHost(...) {
 7    // Grafo anidado — las pantallas dentro comparten el mismo ViewModel
 8    navigation<GrafoPedido>(startDestination = PasoSeleccion) {
 9
10        composable<PasoSeleccion> { backStackEntry ->
11            val entradaPadre = remember(backStackEntry) {
12                navController.getBackStackEntry<GrafoPedido>()
13            }
14            // El Factory puede ser el mismo para ambas pantallas
15            val viewModelCompartido: PedidoViewModel = viewModel(
16                viewModelStoreOwner = entradaPadre,
17                factory = PedidoViewModel.Factory
18            )
19            PantallaPasoSeleccion(
20                viewModel = viewModelCompartido,
21                onSiguiente = { navController.navigate(PasoConfirmacion) }
22            )
23        }
24
25        composable<PasoConfirmacion> { backStackEntry ->
26            val entradaPadre = remember(backStackEntry) {
27                navController.getBackStackEntry<GrafoPedido>()
28            }
29            val viewModelCompartido: PedidoViewModel = viewModel(
30                viewModelStoreOwner = entradaPadre,
31                factory = PedidoViewModel.Factory
32            )
33            PantallaPasoConfirmacion(
34                viewModel = viewModelCompartido,
35                onConfirmar = { navController.popBackStack<GrafoPedido>(inclusive = true) }
36            )
37        }
38    }
39}

El remember(backStackEntry) es esencial para evitar llamar a getBackStackEntry() en cada recomposición, lo que produciría comportamientos inesperados.


9. Ejemplo completo: AppFlix con navegación integrada#

A continuación se muestra la integración completa de AppFlix con el sistema de navegación tipada. Este código integra todo lo desarrollado en T2 (ViewModel + StateFlow) con la navegación de este tema.

  1// ─── data/model/Pelicula.kt ────────────────────────────────────────────────
  2data class Pelicula(
  3    val id: Int,
  4    val titulo: String,
  5    val genero: String,
  6    val puntuacion: Double,
  7    val sinopsis: String = "",
  8    val esFavorita: Boolean = false
  9)
 10
 11// ─── ui/navegacion/Rutas.kt ────────────────────────────────────────────────
 12@Serializable data object PantallaListado
 13@Serializable data class  PantallaDetalle(val id: Int)
 14@Serializable data object PantallaFavoritos
 15
 16// ─── ui/detalle/DetalleUiState.kt ─────────────────────────────────────────
 17sealed class DetalleUiState {
 18    data object Cargando : DetalleUiState()
 19    data class Exito(val pelicula: Pelicula) : DetalleUiState()
 20    data object NoEncontrado : DetalleUiState()
 21}
 22
 23// ─── ui/detalle/DetalleViewModel.kt ───────────────────────────────────────
 24class DetalleViewModel(
 25    private val peliculaId: Int,
 26    private val repository: PeliculasRepository
 27) : ViewModel() {
 28
 29    private val _uiState = MutableStateFlow<DetalleUiState>(DetalleUiState.Cargando)
 30    val uiState: StateFlow<DetalleUiState> = _uiState.asStateFlow()
 31
 32    init {
 33        cargarDetalle()
 34    }
 35
 36    private fun cargarDetalle() {
 37        viewModelScope.launch {
 38            val pelicula = repository.getPeliculaPorId(peliculaId)
 39            _uiState.value = if (pelicula != null) DetalleUiState.Exito(pelicula)
 40                             else DetalleUiState.NoEncontrado
 41        }
 42    }
 43
 44    companion object {
 45        fun factoryConId(id: Int): ViewModelProvider.Factory = viewModelFactory {
 46            initializer {
 47                DetalleViewModel(
 48                    peliculaId = id,
 49                    repository = PeliculasRepository()
 50                )
 51            }
 52        }
 53    }
 54}
 55
 56// ─── ui/detalle/PantallaDetalle.kt ────────────────────────────────────────
 57@Composable
 58fun PantallaDetalle(
 59    peliculaId: Int,
 60    onVolver: () -> Unit,
 61    viewModel: DetalleViewModel = viewModel(factory = DetalleViewModel.factoryConId(peliculaId))
 62) {
 63    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
 64
 65    Scaffold(
 66        topBar = {
 67            TopAppBar(
 68                title = {
 69                    val titulo = (uiState as? DetalleUiState.Exito)?.pelicula?.titulo ?: "Detalle"
 70                    Text(titulo)
 71                },
 72                navigationIcon = {
 73                    IconButton(onClick = onVolver) {
 74                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver")
 75                    }
 76                }
 77            )
 78        }
 79    ) { padding ->
 80        Box(modifier = Modifier.fillMaxSize().padding(padding)) {
 81            when (val estado = uiState) {
 82                is DetalleUiState.Cargando ->
 83                    CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
 84
 85                is DetalleUiState.Exito -> {
 86                    Column(
 87                        modifier = Modifier
 88                            .fillMaxSize()
 89                            .padding(24.dp),
 90                        verticalArrangement = Arrangement.spacedBy(12.dp)
 91                    ) {
 92                        Text(
 93                            text = estado.pelicula.titulo,
 94                            style = MaterialTheme.typography.headlineMedium
 95                        )
 96                        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
 97                            AssistChip(
 98                                onClick = {},
 99                                label = { Text(estado.pelicula.genero) }
100                            )
101                            AssistChip(
102                                onClick = {},
103                                label = { Text("★ ${estado.pelicula.puntuacion}") }
104                            )
105                        }
106                        HorizontalDivider()
107                        Text(
108                            text = estado.pelicula.sinopsis,
109                            style = MaterialTheme.typography.bodyLarge
110                        )
111                    }
112                }
113
114                is DetalleUiState.NoEncontrado -> {
115                    Column(
116                        modifier = Modifier.fillMaxSize().padding(32.dp),
117                        horizontalAlignment = Alignment.CenterHorizontally,
118                        verticalArrangement = Arrangement.Center
119                    ) {
120                        Icon(
121                            Icons.Default.SearchOff,
122                            contentDescription = null,
123                            modifier = Modifier.size(64.dp),
124                            tint = MaterialTheme.colorScheme.onSurfaceVariant
125                        )
126                        Spacer(modifier = Modifier.height(16.dp))
127                        Text("Película no encontrada")
128                        Spacer(modifier = Modifier.height(16.dp))
129                        Button(onClick = onVolver) { Text("Volver al catálogo") }
130                    }
131                }
132            }
133        }
134    }
135}
136
137// ─── ui/navegacion/AppNavigation.kt ───────────────────────────────────────
138@Composable
139fun AppNavigation(
140    navController: NavHostController = rememberNavController()
141) {
142    Scaffold(
143        bottomBar = { AppFlixBottomBar(navController = navController) }
144    ) { innerPadding ->
145        NavHost(
146            navController = navController,
147            startDestination = PantallaListado,
148            modifier = Modifier.padding(innerPadding)
149        ) {
150            composable<PantallaListado> {
151                PantallaListado(
152                    onNavegaADetalle = { id ->
153                        navController.navigate(PantallaDetalle(id = id))
154                    }
155                )
156            }
157
158            composable<PantallaDetalle> { backStackEntry ->
159                val ruta = backStackEntry.toRoute<PantallaDetalle>()
160                PantallaDetalle(
161                    peliculaId = ruta.id,
162                    onVolver = { navController.navigateUp() }
163                )
164            }
165
166            composable<PantallaFavoritos> {
167                PantallaFavoritos(
168                    onNavegaADetalle = { id ->
169                        navController.navigate(PantallaDetalle(id = id))
170                    }
171                )
172            }
173        }
174    }
175}

Diagrama de navegación: AppFlix al final de T3#

[PantallaListado] ──navigate(PantallaDetalle(id))──► [PantallaDetalle]
       ▲                                                      │
       │◄──────────────────navigateUp()───────────────────────┘
BottomBar ──── navigate(PantallaFavoritos) ──► [PantallaFavoritos]
                    ◄──navigate(PantallaDetalle(id))──┘

Actividades prácticas#

Actividad 3.1 — Integración de la navegación en AppFlix (5 h) Conectar los ViewModel desarrollados en T2 con el sistema de navegación: definir las rutas @Serializable, crear el NavHost, conectar los callbacks de navegación en cada pantalla, y añadir el BottomBar con las dos secciones principales. Verificar que el estado de la pantalla de listado (búsqueda activa, favoritos marcados) se conserva al volver desde el detalle.

Actividad 3.2 — Hito vertebrador H3 (5 h) AppFlix debe funcionar de extremo a extremo: listado → detalle → volver, búsqueda con resultados filtrados, toggle de favoritos persistente durante la sesión y navegación con BottomBar que conserva el estado de cada sección. Documentar el grafo de navegación con un diagrama.


Pruebas de evaluación#

Prueba T3.1 — Análisis de grafo de navegación (20 min) Se muestra el grafo de navegación de una app desconocida (diagrama + código del NavHost). Deberás identificar: qué rutas tienen argumentos, qué pantallas comparten ViewModel, dónde se usa popUpTo y por qué, y qué ocurriría si se quitase launchSingleTop del BottomBar.

Prueba T3.2 — Corrección de bugs de navegación (20 min) Se entrega un NavHost con tres errores: rutas definidas como String en lugar de @Serializable, el NavController pasado directamente como parámetro a un Composable de pantalla, y navigateUp() usado donde se debería usar popBackStack<X>(). Deberás identificar, explicar y corregir los errores.

Prueba T3.3 — Defensa oral (5 min por alumno) Se preguntará sobre el código del H3: "¿Por qué aquí usas navigateUp() y no popBackStack()?", "¿Qué ocurriría si el usuario llega a la PantallaDetalle desde una notificación (deep link) y pulsa Atrás?", "¿Por qué defines las rutas como data object y no como object?"


Referencias#

Calendar  Última modificación: viernes, 26 de junio de 2026