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:

  1. 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")
  2. 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.
  3. 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.
  4. 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}
  5. 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, usar Bundle, Intent, o incluso patrones como ViewModel 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ía androidx.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 al NavHost.

    • 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 usar navigate() 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 el NavHost.

  • 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 a pantalla1.

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

  1. 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}
  2. 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}
  3. 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 del NavHost o en un scope compartido, por lo que todas las pantallas tendrán acceso al mismo ViewModel.

  4. 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}
  5. 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

  1. Se crea la clase DetalleViewModel que extenderá de ViewModel() 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}
  2. Se crea la nueva ruta con composable, haciendo uso del factory creado en el NavHost.

    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}
  3. 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") }
  4. 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

Fuentes


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Subsecciones de Tema 4: Navegación en Jetpack Compose

Ejemplo práctico 12: Aplicación con tres pantallas

Objetivo

Se creará una aplicación con tres pantallas (Home, Detalle, Configuración) que permita navegar entre ellas utilizando botones. Además, desde Configuración, se podrá volver a Home eliminando del stack a Detalle.

Configuración del proyecto

En primer lugar, deberás añadir al build.gradle.kts (Module :app) la librería Navigation Compose. Recuerda sincronizar.

1implementation("androidx.navigation:navigation-compose:2.9.2")

Composable MyAppNav

Se crea el método encargado de controlar la navegación, básico, en este ejemplo no se utilizan rutas con parámetros.

 1@Composable
 2fun MyAppNav() {
 3    val navController: NavHostController = rememberNavController()
 4
 5    NavHost(navController, startDestination = "home") {
 6        composable("home") { Home(navController) }
 7        composable("detail") { Detail(navController) }
 8        composable("config") { Config(navController) }
 9    }
10}

Composable Home

Representa la pantalla inicial.

 1@Composable
 2fun Home(navController: NavHostController) {
 3    Column (
 4        modifier = Modifier.fillMaxSize().padding(16.dp)
 5    ) {
 6        Text(
 7            text = "Home Screen",
 8            modifier = Modifier.fillMaxWidth().padding(8.dp)
 9        )
10
11        Button(
12            modifier = Modifier.fillMaxWidth().padding(8.dp),
13            onClick = { navController.navigate("detail") }) { Text("Go to Detail") }
14    }
15}

Composable Detail

 1@Composable
 2fun Detail(navController: NavHostController) {
 3    Column (
 4        modifier = Modifier.fillMaxSize().padding(16.dp)
 5    ) {
 6        Text(
 7            text = "Detail Screen",
 8            modifier = Modifier.fillMaxWidth().padding(8.dp)
 9        )
10
11        Button(
12            modifier = Modifier.fillMaxWidth().padding(8.dp),
13            onClick = { navController.navigate("config") }) { Text("Go to Configuration") }
14    }
15}

Composable Config

 1@Composable
 2fun Config(navController: NavHostController) {
 3    Column (
 4        modifier = Modifier.fillMaxSize().padding(16.dp)
 5    ) {
 6        Text(
 7            text = "Configuration Screen",
 8            modifier = Modifier.fillMaxWidth().padding(8.dp)
 9        )
10
11        Button(
12            modifier = Modifier.fillMaxWidth().padding(8.dp),
13            onClick = {
14                navController.navigate("home") {
15                    popUpTo("home") // Elimina hasta "home"
16                    launchSingleTop = true // Evita duplicados
17                }
18            }) { Text("Go to Home") }
19    }
20}

La diferencia entre los Composables anteriores está en el navigate(), popUpTo() se encarga de limpiar la pila, puedes probarlo con el botón atrás cuando estés nuevamente en Home, la aplicación se cerrará en lugar de volver a la pantalla anterior.

MainActivity

La clase MainActivity podría quedar como se muestra a continuación.

 1class MainActivity : ComponentActivity() {
 2    @OptIn(ExperimentalMaterial3Api::class)
 3    override fun onCreate(savedInstanceState: Bundle?) {
 4        super.onCreate(savedInstanceState)
 5        enableEdgeToEdge()
 6        setContent {
 7            ExampleT4_12Theme {
 8                Scaffold(
 9                    topBar = {
10                        TopAppBar(
11                            title = {
12                                Text(text = getString(R.string.app_name))
13                            },
14                            colors = topAppBarColors(
15                                containerColor = MaterialTheme.colorScheme.primaryContainer,
16                                titleContentColor = MaterialTheme.colorScheme.primary,
17                            )
18                        )
19                    },
20                    modifier = Modifier.fillMaxSize()
21                ) { innerPadding ->
22                    Column(
23                        modifier = Modifier.fillMaxSize().padding(innerPadding)
24                    ) {
25                        MyAppNav()
26                    }
27                }
28            }
29        }
30    }
31}

Código completo


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 13: Scope adecuado del ViewModel por destino con HILT

Objetivo

Se reproducirá la aplicación de ejemplo planteada en la documentación (punto 4.4.2.) utilizando inyección de dependencias con HILT.

Configuración del proyecto

La configuración de un proyecto Android Studio que haga uso de HILT es algo más compleja que añadir una simple librería, pero no es un inconveniente teniendo en cuenta la ayuda que proporciona. En primer lugar deberás añadir los plugins de KSP y HILT al build.gradle.kts (Project: ...) y sincronizar Gradle.

1plugins {
2    ...
3    id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false // KSP for annotation processing, used by libraries like Hilt.
4    id("com.google.dagger.hilt.android") version "2.57" apply false // Hilt for dependency injection.
5}

¿Qué es KSP? Es una herramienta que permite procesar anotaciones (@) de forma más eficiente que la anterior (KAPT) ya que está optimizada para Kotlin.

¿Cómo saber que versión utilizar? Para saber que versión debes utilizar, tendrás que consultar la URL, en la que deberás buscar tu versión de Kotlin según el archivo del proyecto libs.versions.toml en la propiedad kotlin. Por ejemplo, en este caso la versión es kotlin = "2.0.21", que coincide con el primer valor del plugin.

!> Cuidado con actualizar la versión de Kotlin, también deberás actualizar la versión del plugin KSP.

Ahora, en el build.gradle.kts (Module :app), en la sección de plugins añade los siguientes plugins. No sincronices todavía, no pasa nada, pero te dirá que falta la dependencia de HILT.

1plugins {
2    ...
3    id("com.google.devtools.ksp")
4    id("com.google.dagger.hilt.android")
5}

Siguiendo con este fichero, deberás añadir las siguientes librerías y, ahora sí, sincronizar.

1// Navigation Compose
2implementation("androidx.navigation:navigation-compose:2.9.2")
3
4// Hilt
5implementation("com.google.dagger:hilt-android:2.57")
6ksp("com.google.dagger:hilt-android-compiler:2.57") // For annotation processing.
7
8// Hilt integration with ViewModel for Compose
9implementation("androidx.hilt:hilt-navigation-compose:1.2.0")

Inicializa Hilt en la aplicación

Para inicializar HILT deberás crear una nueva clase que extienda de Application con la anotación @HiltAndroidApp.

1// App.kt
2
3@HiltAndroidApp
4class App: Application()

La anotación @HiltAndroidApp crea un contenedor de dependencias global asociado al ciclo de vida de la aplicación, permitiendo que cualquier Activity, Fragment, etc, pueda recibir dependencias de Hilt. Dicho de otro modo, convierte la aplicación en el punto central en el que HILT configura e inyecta las dependencias necesarias para el proyecto.

Ahora, para que sea la primera clase en crearse al lanzar la aplicación, deberás registrala en el AndroidManifest.xml.

1<application
2    android:name=".App"
3    android:allowBackup="true"
4    android:dataExtractionRules="@xml/data_extraction_rules"
5    ...>

Crea la siguiente sealed class para los destinos

1sealed class Screen(val route: String) {
2    object HomeScreen : Screen("home")
3    object DetailScreen : Screen("detail")
4}

Crea el ViewModel para el destino

Se crea el ViewModel adaptado al scope del destino, en este caso, el detalle.

1@HiltViewModel
2class DetalleViewModel @Inject constructor(
3    private val savedStateHandle: SavedStateHandle
4) : ViewModel() {
5
6    val id = savedStateHandle.get<String>("id") ?: "No ID"
7    val contenido = mutableStateOf("Contenido del ítem $id")
8}

Composable MyAppNav

Se crea el método encargado de controlar la navegación, en este caso, en la ruta detalle se utiliza la inyección con HILT.

 1@Composable
 2fun MyAppNav() {
 3    val navController = rememberNavController()
 4
 5    NavHost(navController = navController, startDestination = Screen.HomeScreen.route) {
 6        composable(Screen.HomeScreen.route) {
 7            HomeScreen(navController)
 8        }
 9        composable("detalle/{id}") { backStackEntry ->
10            val detalleViewModel: DetalleViewModel = hiltViewModel()
11            DetalleScreen(detalleViewModel)
12        }
13    }
14}

Composable Home

Representa la pantalla inicial.

 1@Composable
 2fun HomeScreen(navController: NavController) {
 3    Column(modifier = Modifier.padding(16.dp)) {
 4        Text("Home Screen")
 5        Button(
 6            onClick = {
 7                // Navegar a la pantalla de detalle con un ID ficticio
 8                navController.navigate("detalle/321")
 9            }
10        ) { Text("Detalle item") }
11    }
12}

Composable para el detalle

1@Composable
2fun DetalleScreen(viewModel: DetalleViewModel) {
3    Column(modifier = Modifier.padding(16.dp)) {
4        Text("ID: ${viewModel.id}")
5        Text("Contenido: ${viewModel.contenido.value}")
6    }
7}

MainActivity

La clase MainActivity podría quedar como se muestra a continuación. Observa la anotación @AndroidEntryPoint, esta le indica a Hilt que una clase de Android (como Activity, Fragment, View, etc.) será un punto de entrada para la inyección de dependencias. Básicamente habilita la inyección automática de dependencias en una clase de Android, gestionando su ciclo de vida y las instancias necesarias.

 1@AndroidEntryPoint
 2class MainActivity : ComponentActivity() {
 3    @OptIn(ExperimentalMaterial3Api::class)
 4    override fun onCreate(savedInstanceState: Bundle?) {
 5        super.onCreate(savedInstanceState)
 6        enableEdgeToEdge()
 7        setContent {
 8            ExampleT4_13Theme {
 9                Scaffold(
10                    topBar = {
11                        TopAppBar(
12                            title = { Text(getString(R.string.app_name)) },
13                            colors = topAppBarColors(
14                                containerColor = MaterialTheme.colorScheme.primaryContainer,
15                                titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
16                            )
17                        )
18                    },
19                    modifier = Modifier.fillMaxSize()
20                ) { innerPadding ->
21                    Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
22                        MyAppNav()
23                    }
24                }
25            }
26        }
27    }
28}

Código completo