Subsecciones de PMDM
Tema 0: Introducción a Kotlin
Objetivos de este tema
- Comprender los fundamentos del lenguaje Kotlin, sus características y ventajas frente a otros lenguajes como Java.
- Escribir y ejecutar programas básicos en Kotlin, utilizando la función principal main.
- Diferenciar entre variables mutables (
var
) e inmutables (val
) y comprender su importancia para la gestión de la inmutabilidad.
- Aplicar el manejo seguro de nulos (null safety) para prevenir errores comunes como
NullPointerException
.
- Definir y utilizar clases, incluidas
data class
, sealed class
, y clases con constructores primarios y secundarios.
- Entender y utilizar objetos (
object
) y los companion object
para crear singletons y miembros estáticos.
- Declarar y trabajar con funciones, incluidas las funciones de extensión.
- Gestionar colecciones (listas, mapas y conjuntos) tanto inmutables como mutables.
- Crear y utilizar enumerados (
enum
) para definir conjuntos de constantes con propiedades asociadas.
- Comprender el uso del operador Elvis (
?:
) y el manejo de estructuras de control como when
.
- Desarrollar un pensamiento crítico y seguro al escribir código Kotlin, utilizando prácticas modernas y efectivas.
0.1. Introducción
Kotlin es un lenguaje moderno y conciso, diseñado para ser seguro y fácil de usar. Es el lenguaje oficial de Android y combina perfectamente con Java.
0.2. Primer programa en Kotlin
1fun main(args: Array<String>) {
2 println("Hello World!")
3}
- La función
main
es el punto de entrada.
println
imprime un mensaje en consola.
0.3. Expresividad
Kotlin reduce de manera significativa el código repetitivo de Java.
Java:
1public class Artista {
2 private long id;
3 private String nombre;
4 private String url;
5 private String mbid;
6
7 public long getId() { return id; }
8 public void setId(long id) { this.id = id; }
9
10 public String getNombre() { return nombre; }
11 public void setNombre(String nombre) { this.nombre = nombre; }
12
13 public String getUrl() { return url; }
14 public void setUrl(String url) { this.url = url; }
15
16 public String getMbid() { return mbid; }
17 public void setMbid(String mbid) { this.mbid = mbid; }
18
19 @Override
20 public String toString() {
21 return "Artista{id=" + id + ", nombre='" + nombre + '\'' +
22 ", url='" + url + '\'' + ", mbid='" + mbid + '\'' + '}';
23 }
24}
Kotlin:
1data class Artista(
2 var id: Long,
3 var nombre: String,
4 var url: String,
5 var mbid: String
6)
- Una
data class
genera de manera automática los métodos toString
, equals
, hashCode
, etc.
0.4. Variables y tipos
1val i: Int = 42 // Constante inmutable
2var mutableNum = 10 // Variable mutable
3val d: Double = i.toDouble()
4val c: Char = 'c'
5val iFromChar = c.code
val
define constantes, var
permite cambios.
- Los tipos se infieren de manera automática, es decir, no siempre es necesario declararlos.
Operaciones bitwise (bit a bit)
1val bitwiseOr = FLAG1 or FLAG2
2val bitwiseAnd = FLAG1 and FLAG2
- En Kotlin se usan operadores simbólicos.
Acceso y recorrido de cadenas
1val s = "Ejemplo"
2val c = s[3] // Accede a 'm'
3val s2 = "Example"
4for (c2 in s2) print(c2) // Recorre cada carácter
Null safety
En Java, el código generalmente es defensivo, por lo que se debe comprobar en todo momento que no se produzca un null
y prevenir NullPointerException
. Kotlin, en cambio, es null safety, lo que significa que se puede definir si un objeto puede o no ser null
utilizando para ello el operador ?. Fíjate en los siguientes ejemplos.
1fun main(args: Array<String>) {
2 // No compilaría, Artista no puede ser nulo.
3 var notNullArtista: Artista = null
4
5 // Artista puede ser nulo.
6 val artista: Artista? = null
7
8 // No compilará, artista podría ser nulo.
9 artista.toString()
10
11 // Mostrará por pantalla artista si es distinto de nulo.
12 artista?.toString()
13
14 // No necesitaríamos utilizar el operador ? si previamente
15 // comprobamos si es nulo.
16 if (artista != null) {
17 artista.toString()
18 }
19 // Esta operación la utilizaremos si estamos completamente seguros
20 // que no será nulo. En caso contrario se producirá una excepción.
21 artista!!.toString()
22
23 // También podríamos utilizar el operador Elvis (?:) para dar
24 // una alternativa en caso que el objeto sea nulo.
25 val nombre = artista?.nombre ?: "vacío"
26}
- La doble exclamación (
!!
) se utiliza para indicar al compilador que ese objeto no será nulo, evitando así posibles comprobraciones.
0.5. Control de flujo en Kotlin
Kotlin incluye diversas estructuras de control de flujo, como todos los lenguajes, que permiten gestionar la ejecución del código según condiciones, repeticiones o casos específicos.
if
- else
Forma básica:
1val a = 5
2val b = 10
3if (a > b) {
4 println("a es mayor que b")
5} else {
6 println("a no es mayor que b")
7}
Como expresión, devolviendo un valor:
1val max = if (a > b) a else b
2println("El máximo es $max")
when
La sentencia when
podría decirse que es el equivalente de switch
en Java, aunque con algunas diferencias:
when
no necesita la sentencia break
, switch
sí.
when
se puede utilizar para comprobar datos de un rango (1..6
), switch
no.
when
es más flexible que switch
.
when
permite la verificación de tipos, switch
no.
when
permite diferentes tipos de verificación de tipos de datos, switch
no.
switch
tiene limitaciones y solo admite tipos primitivos, enum
y string
, when
no.
Forma básica:
1val x = 3
2when (x) {
3 1 -> println("Uno")
4 2 -> println("Dos")
5 3 -> println("Tres")
6 else -> println("Otro número")
7}
Con múltiples condiciones y rangos:
1when (x) {
2 1, 2 -> println("Uno o Dos")
3 in 3..5 -> println("Entre 3 y 5")
4 else -> println("Otro")
5}
Como expresión (devolviendo un valor):
1val mensaje = when (x) {
2 1 -> "Uno"
3 2 -> "Dos"
4 else -> "Otro"
5}
6println(mensaje)
for
Recorrer una lista:
1val lista = listOf("A", "B", "C")
2for (item in lista) {
3 println(item)
4}
Recorrer un rango:
1for (i in 1..5) {
2 println(i)
3}
Con índices:
1for ((index, value) in lista.withIndex()) {
2 println("Elemento $index: $value")
3}
while
1var contador = 0
2while (contador < 3) {
3 println("Contador: $contador")
4 contador++
5}
do-while
1var numero = 0
2do {
3 println("Número: $numero")
4 numero++
5} while (numero < 3)
Otras sentencias útiles
1for (i in 1..5) {
2 if (i == 3) break
3 println(i)
4}
continue
: Salta a la siguiente iteración:
1for (i in 1..5) {
2 if (i == 3) continue
3 println(i)
4}
return
: Finaliza la ejecución de una función y devuelve un valor:
1fun obtenerValor(x: Int): String {
2 if (x < 0) return "Negativo"
3 return "No negativo"
4}
0.6. Clases y constructores
1class Persona(nombre: String, apellido: String) {
2 var nombre: String = nombre
3 set(value) { field = if (value.isEmpty()) "Sin nombre" else value }
4 get() = "Nombre: $field"
5
6 var apellido: String = apellido
7 set(value) { field = if (value.isEmpty()) "Sin apellido" else value }
8 get() = "Apellido: " + field.uppercase()
9
10 var edad: Int = 0
11 set(value) { field = if (value < 0) 0 else value }
12
13 var anyo: Int = 0
14
15 constructor(nombre: String, apellido: String, edad: Int) : this(nombre, apellido) { this.edad = edad }
16 constructor(nombre: String, apellido: String, edad: Int, anyo: Int) : this(nombre, apellido, edad) { this.anyo = anyo }
17}
- El uso de
constructor
permite definir constructores primarios y secundarios. El uso de this
en el contructor tras los dos puntos (:
) invoca al constructor en línea de la clase.
field
accede al respaldo interno de la propiedad cuando se sobrecargan los getters y setters.
init
También se dispone en Kotlin del bloque init
, es un inicializador que se ejecutará de manera automática al crearse una instancia de la clase, inmediatamente después del constructor primario. Es especialmente útil para realizar operaciones de inicialización complejas, validaciones o cálculos adicionales con los parámetros del constructor. Se puede declarar más de un bloque init
, y se ejecutarán en el orden en que aparecen en el código. Aunque las propiedades pueden inicializarse directamente, init
permite incluir lógica adicional.
1class Persona(nombre: String, apellido: String) {
2 var nombreCompleto: String
3
4 init {
5 // Se ejecutará tras el constructor primario
6 nombreCompleto = "$nombre $apellido"
7 println("Inicializando Persona: $nombreCompleto")
8 }
9}
10
11fun main() {
12 val persona = Persona("Patricia", "Aracil")
13 println(persona.nombreCompleto)
14}
Salida esperada:
1Inicializando Persona: Patricia Aracil
2Patricia Aracil
Herencia
Por defecto, las clases en Kotlin son final
, es decir, no se pueden heredar. Para permitir la herencia, se debe marcar explícitamente con open
.
1open class Persona(val nombre: String) {
2 fun presentarse() = println("Hola, soy $nombre")
3}
4
5class Estudiante(nombre: String, val curso: String) : Persona(nombre) {
6 fun mostrarCurso() = println("Curso: $curso")
7}
8
9fun main() {
10 val estudiante = Estudiante("Carlos", "2º DAM")
11 estudiante.presentarse() // Hereda de Persona
12 estudiante.mostrarCurso()
13}
open class
permite que otra clase herede de Persona
.
- Estudiante heredará las propiedades y funciones públicas de
Persona
.
0.7. Data classes y desestructuración
1data class Person(val name: String, val surname: String, val age: Int)
2val (nombre, apellido, edad) = Person("Javier", "Carrasco", 45)
- La desestructuración permite extraer valores fácilmente.
0.8. Sealed classes
1sealed class Vehiculo(var nRuedas: Int)
2data class Motocicleta(var ruedas: Int = 2) : Vehiculo(ruedas)
3data class Turismo(var ruedas: Int = 4, var puertas: Int = 2) : Vehiculo(ruedas)
4
5fun tipoVehiculo(vehiculo: Vehiculo): String {
6 return when (vehiculo) {
7 is Motocicleta -> "Es del tipo Motocicleta"
8 is Turismo -> "Es del tipo Turismo"
9 }
10}
Las sealed classes en Kotlin permiten restringir el número de subclases que una clase puede tener (heredar), asegurando así, que todas las subclases se declaren en el mismo archivo. Esto proporciona mayor seguridad en tiempo de compilación, ya que el compilador sabe todos los posibles tipos de subclases y puede verificar el uso exhaustivo de estas en expresiones como when
. Son ideales para representar jerarquías cerradas, donde cada tipo o variante está bien definido y no se puede extender fuera del contexto previsto. Un ejemplo común es cuando se desea modelar un estado finito o conjunto limitado de resultados como una respuesta de red o un tipo de mensaje.
0.9. Pair
1val parUno = Pair("Hola", "Mundo")
2val parDos = Pair("Adiós amigos", 150)
3val (usuario, contrasenya) = Pair("javier", "kotlin")
- Útil para devolver dos valores.
0.10. Funciones
1fun sayHello() = "Hi!" // Compacta
2fun sayHello(name: String, surname: String) = "Hello $name $surname"
Extensiones
1fun Fragment.toast(message: CharSequence, duration: Int = Toast.LENGTH_SHORT) {
2 Toast.makeText(this.context, message, duration).show()
3}
- Permite extender clases sin heredar.
0.11. Colecciones
Listas
1val list = listOf("A", "B", "C") // Inmutable
2val mutableList = mutableListOf("A", "B")
3mutableList.add("C")
Mapas
1val map = mapOf(1 to "Uno", 2 to "Dos")
2val mutableMap = mutableMapOf(1 to "Uno")
3mutableMap[2] = "Dos"
Sets
1val set = setOf(1, 2, 3)
2val mutableSet = mutableSetOf(1, 2)
3mutableSet.add(3)
0.12. Enumerados
1enum class DiasSemana(val numero: Int, val estado: String) {
2 LUNES(1, "Trabajando"),
3 MARTES(2, "Trabajando"),
4 MIERCOLES(3, "Trabajando"),
5 JUEVES(4, "Trabajando"),
6 VIERNES(5, "Trabajando"),
7 SABADO(6, "Descanso"),
8 DOMINGO(7, "Descanso")
9}
10DiasSemana.values().forEach { println("${it.numero} - ${it.name} - ${it.estado}") }
0.13. Objetos y Companion Object
Un object
permite declarar un objeto como una única instancia (singleton) sin necesidad de definir una clase y crear instancias separadas. Es ideal para definir constantes, utilidades, o estructuras que no requieran múltiples copias. Los objetos también pueden tener propiedades, funciones, inicializadores (init) e incluso implementar interfaces. Además, se pueden utilizar para definir companion objects
, que actuarán como miembros estáticos compartidos entre todas las instancias de una clase. Esto facilita organizar código relacionado y compartirlo de forma global, sin perder las ventajas del enfoque orientado a objetos de Kotlin.
1object MiObjeto {
2 val usuario = "Javier"
3 val base_URL = "https://miweb.com"
4 fun mostrar() = println("Función de MiObjeto")
5}
Los companion object
son un objeto declarado dentro de una clase que permite definir miembros estáticos, es decir, propiedades y métodos compartidos por todas las instancias de la clase. Funciona como acompañante (de ahí el nombre) a la clase que lo contiene, y permite acceder a sus miembros directamente a través del nombre de la clase, sin necesidad de crear instancias de esta. Puede resultar útil para crear, constantes o utilidades comunes, manteniendo una sintaxis clara.
1class Empleados(val nombre: String, val apellido: String) {
2 var idEmpleado: Int
3 init { println("Init clase Empleado"); idEmpleado = numEmpleados++ }
4 companion object { var numEmpleados = 0 }
5}
0.14. Funciones avanzadas para colecciones en Kotlin
Kotlin ofrece potentes herramientas funcionales para manipular colecciones de forma eficiente, legible y concisa. A continuación, se muestran las funciones más relevantes con ejemplos detallados.
filter
Filtra elementos que cumplan una condición específica.
1val numeros = listOf(1, 2, 3, 4, 5)
2val pares = numeros.filter { it % 2 == 0 }
3println(pares) // [2, 4]
Descripción: Selecciona elementos para los que la condición es verdadera.
map
Transforma cada elemento.
1val nombres = listOf("Ana", "Luis", "Eva")
2val mayusculas = nombres.map { it.uppercase() }
3println(mayusculas) // [ANA, LUIS, EVA]
Descripción: Aplica una transformación a cada elemento.
sortedBy
y sortedDescending
Ordena elementos por un criterio.
1val personas = listOf(Persona("Ana", "Pérez"), Persona("Luis", "Gómez"))
2val ordenadas = personas.sortedBy { it.nombre }
Descripción: Ordena ascendente o descendente por una clave.
groupBy
Agrupa elementos por una clave.
1val animales = listOf("gato", "perro", "gallina", "caballo")
2val agrupados = animales.groupBy { it.first() }
Descripción: Agrupa por el resultado de una función clave.
any
y all
Comprueba condiciones.
1val edades = listOf(18, 20, 25)
2println(edades.any { it >= 21 }) // true
3println(edades.all { it >= 18 }) // true
Descripción: any
verifica si alguno cumple, all
si todos cumplen.
count
Cuenta elementos que cumplen una condición.
1println(edades.count { it >= 21 }) // 1
distinct
y distinctBy
Elimina duplicados.
1val duplicados = listOf(1, 2, 2, 3, 3, 3)
2println(duplicados.distinct()) // [1, 2, 3]
take
y drop
Selecciona o descarta elementos.
1val lista = listOf(1, 2, 3, 4, 5)
2println(lista.take(3)) // [1, 2, 3]
3println(lista.drop(2)) // [3, 4, 5]
zip
y unzip
Combina listas.
1val nombres = listOf("Ana", "Luis")
2val edades = listOf(25, 30)
3val combinados = nombres.zip(edades)
4println(combinados) // [(Ana, 25), (Luis, 30)]
flatten
Aplana listas de listas, es decir, convierte una lista de listas (colección anidada) en una única lista.
1val listas = listOf(listOf(1, 2), listOf(3, 4))
2println(listas.flatten()) // [1, 2, 3, 4]
reduce
y fold
Acumulan elementos.
1val suma = numeros.reduce { acc, num -> acc + num }
2val sumaConInicial = numeros.fold(10) { acc, num -> acc + num }
Ejemplo combinando funciones avanzadas
Ahora se combinarán varias funciones para transformar una lista de números:
1val numeros = listOf(5, 3, 8, 1, 9, 2)
2
3val resultado = numeros
4 .filter { it % 2 != 0 } // Solo impares
5 .sortedDescending() // Orden descendente
6 .map { it * 2 } // Multiplica por 2
7 .take(2) // Toma los dos primeros
8 .fold(0) { acc, num -> acc + num } // Suma acumulativa
9
10println("Resultado: $resultado")
- Lista inicial: [5, 3, 8, 1, 9, 2]
- Paso 1 (
filter
): [5, 3, 1, 9]
- Paso 2 (
sortedDescending
): [9, 5, 3, 1]
- Paso 3 (
map
): [18, 10, 6, 2]
- Paso 4 (
take
): [18, 10]
- Paso 5 (
fold
): 0 + 18 + 10 = 28
Resultado final: 28
Fuentes
Tema 1: Introducción a Jetpack Compose
Objetivos de este tema
- Conocer Jetpack Compose y las diferencias con el sistema tradicional basado en vistas (XML).
- Identificar las ventajas de utilizar Jetpack Compose para el desarrollo de interfaces.
- Explorar y comprender la relación entre Kotlin y Jetpack Compose.
- Establecer la estructura básica de un proyecto Compose.
- Distinguir el flujo de trabajo del compilador y el runtime en la composición de interfaces.
- Construir interfaces básicas utilizando funciones Composables y layouts principales.
- Entender el proceso de recomposición y cómo se gestiona el estado en Compose.
1.1. Fundamentos de Compose
1.1.1. ¿Qué es Jetpack Compose?
Jetpack Compose es el framework moderno de Android que permite construir interfaces de usuario de forma declarativa. Diseñado para escribir UI de forma más intuitiva, menos propenso a errores y totalmente integrable con el lenguaje de programación Kotlin.
Se basa en tres ideas clave:
- UI declarativa: la idea es describir qué se quiere mostrar, en lugar de dibujarlo con XML.
- Reactividad: se actualiza automáticamente cuando los datos cambian.
- Menos código: no se utiliza XML ni
findViewById
.
1.1.2. Diferencias entre el sistema basado en vistas y Jetpack Compose
Sistema basado en vistas (XML) |
Jetpack Compose |
XML separado de lógica |
Código unificado en Kotlin |
findViewById necesario o ViewBinding |
No requiere vinculación manual |
Inflado de vistas |
Composición directa en runtime |
Mucho código |
Sintaxis más concisa |
Acoplamiento más rígido |
Modularidad y reutilización nativa |
1.1.3. Ventajas de Jetpack Compose
- Declarativo: se define la UI como función del estado, esto significa que la interfaz de usuario se crea a partir del estado de los datos actuales, si estos cambian la UI se actualiza.
- Menos código: elimina gran parte del boilerplate.
- Testing más sencillo: las funciones Composables pueden probarse directamente.
- Integración total con Kotlin.
- Mejora el rendimiento en muchos casos gracias a la recomposición eficiente.
- Animaciones fáciles de implementar.
- Migración progresiva: puede utilizarse junto con el sistema de vistas tradicional.
Ejemplo 1.1. UI reactiva con estado
Este ejemplo muestra un saludo reactivo. Al pulsar el botón, el estado (nombre) cambia y Compose actualiza automáticamente la interfaz, mostrando “Hola, Patricia” sin que haya que modificar directamente el Text
.
1import android.os.Bundle
2import androidx.activity.ComponentActivity
3import androidx.activity.compose.setContent
4import androidx.activity.enableEdgeToEdge
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.height
10import androidx.compose.foundation.layout.padding
11import androidx.compose.material3.Button
12import androidx.compose.material3.MaterialTheme
13import androidx.compose.material3.Text
14import androidx.compose.runtime.Composable
15import androidx.compose.runtime.getValue
16import androidx.compose.runtime.mutableStateOf
17import androidx.compose.runtime.remember
18import androidx.compose.runtime.setValue
19import androidx.compose.ui.Alignment
20import androidx.compose.ui.Modifier
21import androidx.compose.ui.unit.dp
22
23class MainActivity : ComponentActivity() {
24 override fun onCreate(savedInstanceState: Bundle?) {
25 super.onCreate(savedInstanceState)
26 enableEdgeToEdge()
27 setContent {
28 MaterialTheme {
29 SaludoInteractivo()
30 }
31 }
32 }
33}
34
35@Composable
36fun SaludoInteractivo() {
37 var nombre by remember { mutableStateOf("Javier") }
38 Column(
39 modifier = Modifier
40 .fillMaxSize()
41 .padding(16.dp),
42 verticalArrangement = Arrangement.Center, // Centra verticalmente
43 horizontalAlignment = Alignment.CenterHorizontally // Centra horizontalmente
44 ) {
45 Text(
46 text = "Hola, $nombre", style = MaterialTheme.typography.headlineMedium
47 )
48
49 Spacer(modifier = Modifier.height(8.dp))
50
51 Button(onClick = { nombre = "Patricia" }) {
52 Text(text = "Cambiar nombre")
53 }
54 }
55}
1.2. Arquitectura de Compose
1.2.1. Componentes clave: Runtime, UI, Compiler
Jetpack Compose se divide en tres módulos que trabajan conjuntamente y con tareas claramente definidas:
- Compose Compiler: plugin del compilador de Kotlin que transforma las funciones etiquetadas como
@Composable
en código optimizado y eficiente para ser ejecutado sobre la plataforma Android. Además de inyectar la lógica interna necesaria para hacer que la UI sea reactiva y eficiente.
- Compose Runtime: el motor de ejecución que mantiene el estado, gestiona las recomposiciones y decide qué UI debe actualizarse. Es capaz de saber qué partes de la UI deben volver a generarse.
- Compose UI: contiene los elementos visuales como Text, Button, Row, Column, etc. Aquí está el trabajo directo del desarrollador para construir interfaces. A nivel interno trabaja con la librería gráfica de Android.
Estos tres módulos trabajan de forma desacoplada, lo que permite actualizar o extender cada parte por separado.
Modularidad y escalabilidad
Gracias a esta arquitectura desacoplada, Jetpack Compose es:
- Extensible: puedes crear tus propios elementos UI (
@Composable
personalizados) o incluso reemplazar partes del runtime si lo necesitas.
- Testable: puedes probar el runtime o la UI por separado.
- Ligero y escalable: puedes incluir solo los módulos necesarios.
Ejemplo 1.2. Modularidad
El siguiente ejemplo permite añadir una TopAppBar
estableciendo el título de la aplicación a través del recurso String.
1import androidx.compose.material3.TopAppBar
2
3...
4
5@Composable
6fun MyAppTopAppBar(topAppBarText: String) {
7 TopAppBar(
8 title = {
9 Text(
10 text = topAppBarText,
11 textAlign = TextAlign.Left,
12 modifier = Modifier
13 .fillMaxSize()
14 .wrapContentSize(Alignment.CenterStart),
15 )
16 }
17 )
18}
Para mostrarla añade la llamada al método saludoInteractivo()
.
1@Composable
2fun SaludoInteractivo() {
3 var nombre by remember { mutableStateOf("Javier") }
4
5 myAppTopAppBar(stringResource(R.string.app_name))
6
7 Column(
8 ...
9 )
10 ...
11}
Debes tener en cuenta que esta no será la mejor manera de mostrar una TopAppBar
, pero de momento puede servir. Más adelante se verá el componente Scaffold
.
1.3.1. Funcionamiento del compilador en Compose
Cuando se escribe una función etiquetada con @Composable
, el compilador no la ejecuta tal cual. En lugar de eso, el plugin de Compose para Kotlin modifica esa función y añade el código adicional necesario para gestionar:
- El control de recomposición
- El seguimiento del estado
- La eficiencia en la actualización de la UI
1.3.2. Composable Compiler Plugin
- Se integra en el proceso de compilación de Kotlin.
- Convierte funciones
@Composable
en llamadas más complejas que pueden ser monitorizadas por el runtime.
- Inyecta parámetros invisibles como el Composer, que rastrea si una función necesita recomponerse o no.
Oberseva el siguiente método:
1@Composable
2fun Saludo(nombre: String) {
3 Text("Hola, $nombre")
4}
El resultado del compilador será algo parecido al siguiente método:
1fun Saludo(nombre: String, composer: Composer, changed: Int) {
2 if (composer.shouldRecompose(changed)) {
3 Text("Hola, $nombre")
4 }
5}
Este código resultado no se verá, pero es el encargado del funcionamiento óptimo y eficiente para Compose.
El compilador de Compose trabaja en la fase de IR (Intermediate Representation) de Kotlin, dónde se realizarán transformaciones como:
- Inyección de lógica para recomposición condicional.
- División de Composables en múltiples fases si contienen múltiples niveles de recomposición.
- Manejo de claves y grupos para optimizar la reconstrucción de UI.

1.4. Conexión con el tiempo de ejecución
1.4.1. Cómo se interpretan y ejecutan los Composables
Cuando un método etiquetado como @Composable
se llama desde setContent
o desde otro Composable, no se dibujará directamente en pantalla, sino que entra en juego el runtime de Compose:
- Interpretará la estructura del árbol Composable.
- Evaluará si debe recomponer por cambios de estado.
- Utilizará el sistema de slots para decidir qué partes de la UI deberán redibujarse.
1.4.2. Sistema de slots y control de recomposición
El sistema de slots (Slot Table) es la estructura interna encargada de:
- Representar cada nodo del árbol de la UI (Text, Button, Column…).
- Registrar qué parte del árbol corresponde a qué parte del estado.
- Guardar el orden y la identidad de cada elemento para poder hacer una recomposición eficiente.
Podría verse como un índice dinámico del árbol de UI.
La recomposición se produce cuando un valor observado cambia (por ejemplo, una variable del tipo remember { mutableStateOf(...) }
), Compose marca los Composables afectados como sucios (dirty). En la siguiente fase del frame, solo esos Composables se vuelven a ejecutar.
El encargado de esto es el runtime, sin que el desarrollador tenga que intervenir manualmente.
¿Y cómo sabe Compose qué recomponer para ser eficiente?
- Cada método Composable recibe información sobre su “posición” en el árbol.
- El runtime asigna un grupo de recomposición a cada llamada Composable.
- Si el estado relevante cambia, solo ese grupo se vuelve a ejecutar.
Esto hace que Compose sea más eficiente que sistemas anteriores.
Ejemplo 1.4. Recomposición
1@Composable
2fun Contador() {
3 var valor by remember { mutableStateOf(0) }
4
5 Column {
6 Text("Valor: $valor")
7 Button(onClick = { valor++ }) {
8 Text("Incrementar")
9 }
10 }
11}
¿Qué ocurre en este ejemplo cuando se pulsa el botón?
- Únicamente se recompondrá
Text("Valor: $valor")
.
- El botón permanece intacto.
- Todo esto es decidido por el runtime con ayuda de la Slot Table.

1.5. Introducción a la UI de Compose
1.5.1. Estructura básica de un @Composable
En Jetpack Compose, la UI se construye a partir de los métodos marcados con la anotación @Composable
. Deberás tener en cuenta que estos métodos:
- No devuelven nada (
Unit
).
- Describen cómo se mostrará la interfaz.
- Se pueden ser anidar y reutilizar.
Un ejemplo básico:
1@Composable
2fun Saludo(nombre: String) {
3 Text(text = "Hola, $nombre")
4}
Para mostrar la composición de esta etiqueta (Text
), deberá llamarse desde dentro de setContent{}
en una Activity.
1setContent {
2 Saludo("Jetpack Compose")
3}
1.5.2. Layouts básicos en Compose
Jetpack Compose ofrece varios contenedores flexibles para la organización de los elementos de UI. Los tres layouts básicos son:
Colum
Este layout organiza los elementos verticalmente, uno debajo de otro.
1@Composable
2fun EjemploColumn() {
3 Column(modifier = Modifier.padding(16.dp)) {
4 Text("Primera línea")
5 Text("Segunda línea")
6 }
7}
Row
Este organiza los elementos horizontalmente, de izquierda a derecha.
1@Composable
2fun EjemploRow() {
3 Row(modifier = Modifier.padding(16.dp)) {
4 Text("Izquierda")
5 Spacer(modifier = Modifier.width(8.dp))
6 Text("Derecha")
7 }
8}
Box
Este layout superpone los elementos, estando siempre encima de todos el último.
1@Composable
2fun EjemploBox() {
3 Box(modifier = Modifier.size(100.dp)) {
4 Text("Fondo", modifier = Modifier.align(Alignment.Center))
5 Box(modifier = Modifier
6 .size(40.dp)
7 .background(Color.Red)
8 .align(Alignment.BottomEnd))
9 }
10}
Puedes probarlo en Android Studio y añadir justo antes de la etiqueta @Composable
la etiqueta @Preview(showBackground = true)
para ver una previsualización sin necesidad de ejecutar la aplicación.

1.6. Composición y recomposición
1.6.1. ¿Qué es la recomposición?
La recomposición de Jetpack Compose es el proceso por el cual el sistema vuelve a ejecutar funciones @Composable
con el fin de actualizar la interfaz de usuario (UI) como respuesta a cambios en el estado. Esto permite que la UI muestre siempre el estado actual de la aplicación.
Por ejemplo, si una variable de estado cambia, Compose identificará las partes de la UI que dependen de ese estado y volverá a ejecutar solo los métodos @Composable
para refrescar la pantalla.
1.6.2. Triggers de recomposición
La recomposición en Jetpack Compose se activa cuando:
- Se producen cambios en variables de estado: Al modificar una variable creada con
mutableStateOf
, Compose detecta el cambio y recompone los métodos que la utilizan.
- Hay nuevos valores en parámetros de funciones
@Composable
: Al llamar a un método @Composable
con diferentes argumentos, se considera que su entrada ha cambiado y se recompone.
- Cambios en claves de listas: Al modificar la clave de un elemento en una lista, Compose puede recomponer ese elemento específico.
Destacar que Compose optimiza este proceso, recomponiendo únicamente las partes necesarias de la UI. Por ejemplo, si una lista de elementos cambia en orden pero no en contenido, Compose puede evitar recomponer los elementos que no han cambiado.
1.7. Estado y diferenciación inteligente
1.7.1. Estado observable
En Jetpack Compose, el estado representa cualquier dato que puede cambiar y debe reflejarse en la UI. Cuando un estado cambia, Compose vuelve a ejecutar los métodos @Composable
que dependen de ese estado para actualizar la UI según corresponda.
Para gestionar el estado de una manera eficiente, Compose facilita varias APIs:
- remember: Almacena un valor en memoria durante la composición, útil para conservar el estado entre recomposiciones.
1val contador = remember { mutableStateOf(0) }
- mutableStateOf: Crea un objeto mutable que Compose observa, cuando cambia su valor se produce la recomposición.
1var texto by remember { mutableStateOf("") }
Declaración |
Reactivo |
Recomposición |
Adecuado para estado UI |
by remember { mutableStateOf(false) } |
✅ Sí |
✅ Automática |
✅ Sí |
remember { false } |
❌ No |
❌ No automática |
❌ No |
- derivedStateOf: Permite derivar un nuevo estado a partir de otros estados. Solo se actualizará cuando el resultado derivado cambie, lo que evita recomposiciones innecesarias.
1val esTextoLargo by remember {
2 derivedStateOf { texto.length > 10 }
3}
El uso de estas APIs pueden ayudar a optimizar la recomposición y evitar así, recomposiciones innecesarias.
Consejo, evitar operaciones complejas en métodos @Composable
, los métodos @Composable
deben ser rápidos y sin efectos secundarios. Las operaciones intensivas deben realizarse fuera de estas funciones y sus resultados deben pasarse como parámetros.
1.7.2. Diferenciación y control inteligente
Compose optimiza las recomposiciones mediante un sistema de diferenciación inteligente. Lo que significa que solo las partes de la UI que dependen de un estado que ha cambiado se vuelven a componer.
Para aprovechar esta diferenciación:
-
Minimiza el alcance del estado: Define los estados en los niveles más bajos posible del árbol de Composables, limitando así las recomposiciones.
-
Evita operaciones costosas en composables: Realiza cálculos intensivos fuera de las funciones @Composable
y pasa los resultados como parámetros.
-
Usa remember
y derivedStateOf
adecuadamente: Estas funciones ayudan a conservar valores y evitar recomposiciones innecesarias.
Ejemplo 1.7. Recomposición
1@Composable
2fun EjemploEstado() {
3 var texto by remember { mutableStateOf("") }
4 val esTextoLargo by remember {
5 derivedStateOf { texto.length > 10 }
6 }
7
8 Column(modifier = Modifier.padding(16.dp)) {
9 TextField(
10 value = texto,
11 onValueChange = { texto = it },
12 label = { Text("Ingrese texto") }
13 )
14 if (esTextoLargo) {
15 Text("El texto es largo")
16 }
17 }
18}
En este código de ejemplo, la variable esTextoLargo
se actualiza únicamente cuando la longitud de texto
cambie y supere la longitud establecida, evitando las recomposiciones innecesarias.
1.8. Naturaleza y propiedades de las funciones componibles
1.8.1. Reglas de los Composables
Las funciones @Composable
son la parte principal de Jetpack Compose. Estas funciones deberán cumplir ciertas reglas que garanticen una UI eficiente y predecible:
- Anotación obligatoria: Toda función que construya UI deberá estar anotada como
@Composable
.
- No deben devolver valores: Como norma general, este tipo de funciones no devolverán ningún valor, deben describir como se mostrará la UI.
- Llamadas a otras composables: Pueden llamar a otras funciones
@Composable
para construir interfaces más complejas.
- Sin efectos secundarios: Deben ser “puras”, es decir, no deben modificar el estado global ni realizar operaciones que afecten fuera del alcance del método.
Estas reglas básicas garantizan que Compose pueda realizar una gestión eficientemente de la recomposición y mantener una UI coherente.
1.8.2. Efectos secundarios y pureza
En el paradigma declarativo de Compose, que las funciones @Composable
sean puras es muy importante. Esto significa que, dadas las mismas entradas, siempre deben producir la misma salida sin causar efectos secundarios.

Si fuese necesario realizar operaciones que requieran efectos secundarios, Compose proporciona APIs específicas:
- LaunchedEffect: Ejecuta una operación de suspensión cuando una clave específica cambia.
1LaunchedEffect(key1 = clave) {
2 // Operación de suspensión
3}
-
rememberUpdatedState: Permite acceder al valor más reciente de una variable dentro de un efecto.
-
DisposableEffect: Realiza una operación cuando el Composable entra en la composición y limpia cuando sale.
Estas herramientas permiten manejar efectos secundarios de una manera controlada, manteniendo la integridad del sistema de composición.
1.8.3. Buenas prácticas
Para escribir funciones @Composable
de manera eficiente:
-
Mantén la pureza: No modifiques estados globales o realices operaciones que modifiquen elementos fuera del alcance de la función.
-
Descomposición en funciones pequeñas: Facilita la lectura y reutilización del código.
-
Evita recomposiciones innecesarias: Utiliza remember
y derivedStateOf
para memorizar valores y evitar recomposiciones innecesarias.
-
Utiliza las APIs adecuadas para manejar efectos secundarios: Como LaunchedEffect
o DisposableEffect
.
Seguir estas prácticas garantiza una UI eficiente y un código fácil de mantener.
1.9. Estrategias de migración desde Views
1.9.1. Interoperabilidad entre Views y Compose
Jetpack Compose se diseño para coexistir con el sistema tradicional de vistas, basado en Views (XML), esto permite una migración progresiva y controlada.
1.9.2. AndroidView: Incluir Views en Compose
Es posible reutilizar componentes existentes basados en Views dentro de una interfaz construida con Compose, para ello se utiliza el Composable AndroidView
.
1@Composable
2fun VistaPersonalizada() {
3 AndroidView(
4 factory = { context ->
5 TextView(context).apply {
6 text = "Texto desde una View tradicional"
7 }
8 }
9 )
10}
Puede resultar útil cuando se necesita incorporar widgets personalizados, o bibliotecas que no tienen un equivalente en Compose.
1.9.3. ComposeView: Incluir Compose en layouts de Views
Por otra lado, existe la posibilidad de insertar contenido de Compose en una jerarquía de Views existente, para lo que se utilizará ComposeView.
Vista XML:
1<androidx.compose.ui.platform.ComposeView
2 android:id="@+id/compose_view"
3 android:layout_width="match_parent"
4 android:layout_height="wrap_content" />
Desde Kotlin:
1val composeView = findViewById<ComposeView>(R.id.compose_view)
2composeView.setContent {
3 Text("Contenido de Compose dentro de una View")
4}
De esta manera, es posible introducir nuevas funcionalidades haciendo uso de Compose sin reescribir código de las pantallas existentes.
1.9.4. Migración progresiva
Si te ves en la situación de ralizar una migración a Jetpack Compose, se recomienda realizarla de manera progresiva, permitiendo que Compose y Views coexistan en el mismo proyecto hasta que la aplicación esté completamente migrada.
- Construir nuevas pantallas con Compose: Desarrolla las nuevas funcionalidades directamente con Compose, aprovechando sus beneficios desde el inicio.
- Identificar componentes reutilizables: Crea bibliotecas de componentes UI comunes en Compose, fomentando así la reutilización y manteniendo una fuente única de verdad.
- Reemplazar pantallas existentes gradualmente: Migra las pantallas existentes una a una, comenzando por las más sencillas, o aquellas que requieran cambios, asegurando una transición controlada.
Este enfoque mantiene la estabilidad de la aplicación mientras se produce el cambio a la nueva tecnología.
Consideraciones adicionales
- Compatibilidad con temas y estilos: Asegúrarte que los temas definidos en Views son compatibles, o adaptados a Compose para mantener una apariencia coherente.
- Gestión del ciclo de vida: Ten en cuenta el ciclo de vida de los componentes al integrar Compose y Views, especialmente en actividades y fragments.
- Pruebas y depuración: Actualiza las pruebas existentes y considera nuevas estrategias de testing para componentes en Compose.
1.10. Ciclo de vida de una aplicación móvil
1.10.1. Ciclo de vida de una Activity
Las aplicaciones móviles en Android están sujetas a un ciclo de vida gestionado por el sistema operativo:
onCreate()
-> Se inicializan componentes y UI.
onStart()
-> La Activity es visible, pero no interactúa con el usuario todavía.
onResume()
-> La Activity ya está en primer plano y permite interacciones con el usuario.
onPause()
-> Pierde el foco, momento en el que se puede guardar datos o pausar tareas.
onStop()
-> La Activity ya no es visible y se liberan recursos pesados.
onDestroy()
-> Se destruye la Activity y se limpian los recursos finales.
Conocer el ciclo de vida es vital para manejar recursos, permisos y situaciones como rotaciones, cambios de configuración o interrupciones.
1.10.2. Ciclo de vida de un Composable
El runtime de Compose dispone su propio ciclo de vida, este se compone de tres fases fundamentales:
- Enter the Composition: punto de inicio, es cuando la función @
Composable
se ejecuta por primera vez.
- Recomposition: vuelve a ejecutarse si el estado cambia, actualizando solo aquello que es necesario.
- Leave the Composition: se elimina del árbol UI y se liberan los recursos asociados.

1.10.3. Relación entre ciclos
Aunque separados, estos ciclos interactúan entre sí en aplicaciones Compose:
- Las recomposiciones ocurren dentro del contexto de la Activity que controla la Composition.
- Si se destruye la Activity, se abandona la Composition y todos los efectos son cancelados(
DisposableEffect
, LaunchedEffect
, etc.).
- Para reaccionar a eventos del ciclo de vida de la Activity dentro de Compose, se puede observar
Lifecycle
usando APIs como LifecycleEventEffect
o lifecycle.currentStateAsState()
del módulo lifecycle-runtime-compose
.
Ejemplo práctico
1@Composable
2fun CiclosDeVida() {
3 // Se utiliza lifecycleOwner para observar el ciclo de vida de la actividad o fragmento.
4 val lifecycleOwner = LocalLifecycleOwner.current
5 // Se obtiene el estado actual del ciclo de vida como un estado Compose.
6 val estado = lifecycleOwner.lifecycle.currentStateAsState()
7
8 // Se muestra el estado actual del ciclo de vida.
9 Log.d("CiclosDeVida", "Estado del ciclo de vida: ${estado.value}")
10 Text(
11 text = "Estado del ciclo de vida: ${estado.value}",
12 modifier = Modifier.padding(16.dp)
13 )
14}
Una posible salida por el Logcat sería:
2025...-18617 CiclosDeVida es.javiercarrasco.examplet01b D Estado del ciclo de vida: RESUMED
2025...-18617 CiclosDeVida es.javiercarrasco.examplet01b D Estado del ciclo de vida: STARTED
2025...-18617 CiclosDeVida es.javiercarrasco.examplet01b D Estado del ciclo de vida: CREATED
2025...-18617 CiclosDeVida es.javiercarrasco.examplet01b D Estado del ciclo de vida: RESUMED
Fuentes
Tema 2: Interfaz de usuario
Objetivos de este tema
- Comprender y aplicar modificadores de Jetpack Compose para ajustar el estilo, disposición y comportamiento de los elementos de la UI.
- Diseñar y personalizar layouts adaptados a diferentes contextos mediante
Modifier.layout
, medidas intrínsecas y restricciones.
- Analizar el proceso de renderizado en Compose.
- Utilizar componentes gráficos como
Canvas
y graphicsLayer
para enriquecer la experiencia visual con formas, transformaciones y animaciones.
- Estructurar pantallas completas usando
Scaffold
, barras de herramientas (TopAppBar
) y acciones flotantes (FAB).
- Crear listas eficientes y reutilizables con
LazyColumn
y LazyRow
(alternativa moderna a RecyclerView).
- Implementar elementos interactivos esenciales como
Snackbar
, Toast
, cuadros de diálogo (AlertDialog
) y menús desplegables (Spinner
).
- Gestionar la navegación entre pantallas utilizando tanto múltiples Activities, como la solución moderna basada en
Navigation Compose
.
- Evaluar cuándo conviene usar una arquitectura basada en múltiples actividades frente a una navegación controlada dentro de una única actividad.
2.1. Modificadores
Los modificadores en Jetpack Compose son objetos que permiten modificar, o extender el comportamiento y la apariencia de un elemento Composable, como puede ser su tamaño, padding, clics, animaciones o aspecto gráfico. Son un componente esencial para la creación de interfaces en Compose.
2.1.1. Sintaxis de encadenamiento
La sintaxis de los modificadores está basada en el encadenamiento de funciones, similar a la programación funcional. Se aplica utilizando el operador punto(.
) sobre el parámetro modifier
que acepta cada Composable.
1Text(
2 text = "Hola mundo",
3 modifier = Modifier
4 .padding(16.dp)
5 .background(Color.LightGray)
6 .clickable { /* acción */ }
7)
Este código de ejemplo, muestra un Text
que tiene un padding, un fondo gris y responde al clic.
- Todos los modificadores devuelven un nuevo
Modifier
, lo que permite que su encadenamiento sea fluido.
- Como se verá a continuación, el orden importa.
2.1.2. Orden de aplicación y optimización
Destacar que en Compose, el orden de los modificadores afectará directamente al resultado visual y funcional.
1// El padding se aplica antes que el fondo.
2Modifier
3 .padding(16.dp)
4 .background(Color.Red)
Utilizando este orden se quedará el fondo ajustado al contenido sin incluir el padding. Sin embargo:
1// El fondo se aplica antes del padding.
2Modifier
3 .background(Color.Red)
4 .padding(16.dp)
En este caso el fondo abarca también el espacio del padding, ya que se aplicará primero.
Información
Los modificadores se aplicarán de arriba hacia abajo según el orden escrito, es decir, de izquierda a derecha en el renderizado visual.
Optimización
Jetpack Compose está diseñado para optimizar los modificadores comunes, como padding, size, offset o background, pero:
- Aplicar muchos modificadores anidados de manera innecesaria puede aumentar el número de nodos en la jerarquía (
LayoutNode
).
- Es recomendable agrupar modificadores relacionados y evitar repeticiones.
2.1.3. Modificadores internos vs personalizados
Modificadores internos
Los modificadores internos son los que proporciona Compose, entre los más comunes:
padding()
background()
fillMaxWidth()
, height()
, size()
clickable()
offset()
graphicsLayer()
Totalmente optimizados y recomendados cuando se ajustan a las necesidades.
Modificadores personalizados
Es posible crear tus propios modificadores cuando sea necesario encapsular lógica de presentación, o comportamiento, para simplificar, mejorar la legibilidad y reutilizar código. Por ejemplo:
1fun Modifier.tarjetaRedonda(): Modifier = this
2 .padding(8.dp)
3 .clip(RoundedCornerShape(16.dp))
4 .background(Color.White)
Este modificador personalizado se utilizaría de la siguiente forma:
1Box(modifier = Modifier.tarjetaRedonda())
Consejo
Puedes también crear modificadores más complejos utilizando funciones como Modifier.drawBehind
, Modifier.composed
, o incluso Modifier.pointerInput
para gestos personalizados.
Ejemplo 2.1. Modificadores
El siguiente ejemplo muestra el encadenamiento de modificadores, el orden en padding y background y un modificador personalizado (tarjetaRedonda
).
Para verlo en funcionamiento puedes pegar este código en cualquier @Composable
de Android Studio o en el componente de vista previa (@Preview
).
1@Composable
2fun ModificadoresDemo() {
3 var isHovered by remember { mutableStateOf(false) }
4
5 Column(
6 modifier = Modifier
7 .fillMaxSize()
8 .padding(16.dp),
9 verticalArrangement = Arrangement.spacedBy(20.dp)
10 ) {
11 Text("1. Orden: Padding antes de Background")
12 Box(
13 modifier = Modifier
14 .padding(16.dp)
15 .background(Color.Red)
16 ) {
17 Text(
18 "Texto con padding interno",
19 modifier = Modifier.padding(8.dp)
20 )
21 }
22
23 Text("2. Orden: Background antes de Padding")
24 Box(
25 modifier = Modifier
26 .background(Color.Green)
27 .padding(16.dp)
28 ) {
29 Text(
30 "Texto con fondo más grande",
31 modifier = Modifier.padding(8.dp)
32 )
33 }
34
35 Text("3. Modificador personalizado: tarjetaRedonda")
36 Box(
37 modifier = Modifier.tarjetaRedonda()
38 ) {
39 Text(
40 "Texto dentro de tarjeta",
41 modifier = Modifier.padding(16.dp)
42 )
43 }
44
45 val context = LocalContext.current
46 Text("4. Con `clickable` (logcat)")
47 Box(
48 modifier = Modifier
49 .tarjetaRedonda()
50 .clickable {
51 Log.d("Compose", "Tarjeta clicada")
52 Toast.makeText(context, "Tarjeta clicada", Toast.LENGTH_SHORT).show()
53 }
54 ) {
55 Text(
56 "Haz clic en esta tarjeta",
57 modifier = Modifier.padding(16.dp)
58 )
59 }
60 }
61}
62
63// Modificador personalizado
64fun Modifier.tarjetaRedonda(): Modifier = this
65 .padding(8.dp)
66 .clip(RoundedCornerShape(16.dp))
67 .background(Color.White)
68 .border(2.dp, Color.Gray, RoundedCornerShape(16.dp))
Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente código:
1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaModificadores() {
4 ModificadoresDemo()
5}
Información
En este ejemplo se introduce el concepto de Context, su uso en Compose varía. Se utiliza LocalContext.current
, que permite acceder al contexto de Android dentro de un método Composable.
2.2. Layout personalizado con Modifier.layout
Modifier.layout
permite crear Composables con disposición personalizada, es decir, que no dependen de los layouts predeterminados como Column
, Row
o Box
, sino que definen sus propias medidas y ubicación del contenido.
2.2.1. Cómo crear layouts con reglas propias
Conceptos clave
Modifier.layout { measurable, constraints -> ... }
permite un control total sobre cómo se mide y posiciona un composable hijo.
- Trabaja directamente con el ciclo de composición y disposición:
Measurable.measure() → Placeable.place()
.
- Se pueden implementar reglas personalizadas: alineaciones, offsets, centrar, limitar tamaño, aplicar rotaciones manuales, etc.
Estructura general de uso
1Modifier.layout { measurable, constraints ->
2 val placeable = measurable.measure(constraints)
3 layout(placeable.width, placeable.height) {
4 placeable.place(x = 0, y = 0)
5 }
6}
Detalles importantes
measurable
: representa el contenido hijo.
constraints
: definen el tamaño máximo y mínimo permitido.
placeable
: resultado de medir el hijo.
layout(width, height)
: define el tamaño del layout padre.
place(x, y)
: define la posición del hijo.
Recomendaciones de uso
Tipo |
Control |
Uso |
Column / Row |
Medio |
Layouts comunes |
Box |
Bajo |
Superposición simple |
Modifier.layout |
Alto |
Casos muy personalizados |
Ejemplos prácticos
2.3. Árbol de Layout y fases de renderizado
En este punto tratará de entenderse como funciona internamente el sistema de renderizado en Jetpack Compose a través del layout tree, o LayoutNode, y las tres fases clave del proceso de renderizado: composición, disposición y dibujo.
2.3.1. ¿Qué es el Layout Tree?
En Jetpack Compose, cada elemento visible es representado como un nodo dentro del layout tree, o LayoutNode. Este árbol definerá:
- La jerarquía visual de la UI.
- Cómo se calculan y distribuyen tamaños.
- El orden en que se dibujan y posicionan los elementos.
Column
├── Text("Título")
├── Row
│ ├── Icon
│ └── Text("Etiqueta")
└── Button("Aceptar")
Cada nodo tiene una relación padre-hijo, lo que permite al sistema navegar, componer y organizar la interfaz.
2.3.2. Fases de renderizado: composición, disposición, dibujo
Los tres pasos fundamentales que realiza Jetpack Compose para renderizar la UI:
Fase 1: Composición (measure)
- Cada nodo hijo se compone en función de una serie de restricciones (Constraints) que vienen impuestas por el padre.
- Debe decidirse el tamaño que debe ocupar el elemento.
Por ejemplo: Un Text
dentro de un Box
será compuesto con un ancho máximo igual al del elemento contenedor, el Box
.
1val placeable = measurable.measure(constraints)
Fase 2: Disposición (place)
- Una vez compuesto, cada Placeable se colocará en una posición concreta (x, y) dentro del contenedor padre.
- Se decide dónde irá ubicado el hijo.
Fase 3: Dibujo (draw)
- Para finalizar, el sistema dibujará los elementos en pantalla.
- Se aplicarán colores, bordes, sombras, imágenes, animaciones, etc.
Se realiza después de componer y ubicar. En esta última fase se puede intervenir utilizando modificadores como:
1Modifier.drawBehind { ... }
Esquema del ciclo simplificado
Renderizado (UI declarativa)
↓
Composición (measure)
↓
Disposición (place)
↓
Dibujo (draw)
2.3.3. Herramientas relacionadas
- Layout Inspector (Android Studio): permite ver el LayoutNode en tiempo real.
- Modifier.layout: permite intervenir directamente en measure y place.
- Modifier.drawBehind, Modifier.graphicsLayer: permiten intervenir en la fase de dibujo.
2.4. Intrínsecos y restricciones
Es necesario conocer que son las medidas intrínsecas en Jetpack Compose para saber cuándo es útil utilizarlas y cómo se pueden gestionar restricciones de tamaño, utilizando Composables como BoxWithConstraints
.
2.4.1. Las medidas intrínsecas
Las medidas intrínsecas son una forma de calcular el tamaño mínimo o máximo que necesita un composable sin tener en cuenta las restricciones del padre. Se pueden usar para ajustar el layout en función del contenido, en lugar de limitarse por fillMaxWidth
, wrapContentHeight
, etc.
En Jetpack Compose, se pueden utilizar:
Modifier.width(IntrinsicSize.Min)
Modifier.width(IntrinsicSize.Max)
Modifier.height(IntrinsicSize.Min)
Modifier.height(IntrinsicSize.Max)
El siguiente ejemplo asegura que ambos textos tengan la altura del más alto, gracias a IntrinsicSize.Min
.
1@Composable
2fun DemoIntrinsicSize() {
3 Row(
4 modifier = Modifier.height(IntrinsicSize.Min)
5 ) {
6 Text(
7 text = "Texto alto",
8 modifier = Modifier
9 .background(Color.Red)
10 .padding(8.dp)
11 )
12 HorizontalDivider(
13 color = Color.Black,
14 modifier = Modifier
15 .fillMaxHeight()
16 .width(1.dp)
17 )
18 Text(
19 text = "Texto más corto",
20 modifier = Modifier
21 .background(Color.Green)
22 .padding(8.dp)
23 )
24 }
25}

2.4.2. Limitaciones de las medidas intrínsecas
- Coste alto de rendimiento: requieren múltiples pases de medición, por lo que se deben evitar en listas largas o layouts complejos.
- Es preferible el uso de
BoxWithConstraints
o Modifier.layout
cuando el tamaño se predecible o haya que adaptarlo manualmente.
2.4.3. Uso de BoxWithConstraints
BoxWithConstraints
permite acceder y modificar las restricciones de tamaño desde dentro del composable, lo que facilita la creación de interfaces adaptativas.
1@SuppressLint("UnusedBoxWithConstraintsScope")
2@Composable
3fun CajaResponsiva() {
4 BoxWithConstraints(
5 modifier = Modifier.fillMaxWidth(),
6 contentAlignment = Alignment.Center
7 ) {
8 if (maxWidth < 300.dp)
9 Text("Pantalla pequeña")
10 else Text("Pantalla grande")
11 }
12}
Se puede usar maxWidth
, minWidth
, maxHeight
y minHeight
para realizar evaluaciones lógicas en tiempo de composición.
Diferencias
Enfoque |
Uso |
IntrinsicSize.Min / Max |
Ajusta el tamaño según el contenido. |
BoxWithConstraints |
Adapta la UI al tamaño del contenedor (responsive). |
Para resumir, las medidas intrínsecas permiten que un composable se adapte a su contenido, debiéndose utilizar con cuidado por razones de rendimiento. BoxWithConstraints
es preferible para layouts adaptativos o responsive.
Ejemplos prácticos
2.5. Lienzo y gráficos
Ahora se verá cómo utilizar Canvas
en Jetpack Compose para dibujar elementos gráficos personalizados como líneas, formas y colores, y comprender cómo se integra esta fase con el ciclo de composición.
2.5.1. ¿Qué es Canvas?
Canvas
es un composable especial que permite dibujar directamente sobre la pantalla utilizando para ello primitivas gráficas:
- Líneas
- Rectángulos
- Círculos
- Texto
- Imágenes y gradientes
Esta API es similar a la de Canvas
tradicional de Android (vistas), pero adaptada a Compose y Kotlin.
2.5.2. Uso básico de Canvas
El siguiente código dibuja un rectángulo azul de 100x100 en la posición (20,20).
1import androidx.compose.foundation.Canvas
2...
3
4@Composable
5fun EjemploCanvas() {
6 Canvas(modifier = Modifier.size(200.dp)) {
7 drawRect(
8 color = Color.Blue,
9 topLeft = Offset(20f, 20f),
10 size = Size(100f, 100f)
11 )
12 }
13}

2.5.3. Primitivas
Método |
Descripción |
drawRect() |
Dibuja un rectángulo con tamaño y posición. |
drawCircle() |
Dibuja un círculo dado centro y radio. |
drawLine() |
Dibuja una línea entre dos puntos. |
drawPath() |
Dibuja una figura compleja con líneas y curvas. |
drawText() |
Dibuja texto (requiere herramientas adicionales). |
Ejemplo 2.5. Canvas sencillo
1@Composable
2fun EjemploCanvasSencillo() {
3 Canvas(modifier = Modifier.size(200.dp)) {
4 // Fondo gris claro
5 drawRect(Color.LightGray, size = size)
6
7 // Círculo rojo en el centro
8 drawCircle(
9 color = Color.Red,
10 radius = 50f,
11 center = center
12 )
13
14 // Línea diagonal azul
15 drawLine(
16 color = Color.Blue,
17 start = Offset(0f, 0f),
18 end = Offset(size.width, size.height),
19 strokeWidth = 4f
20 )
21 }
22}
23```~
24
25Si quieres que en lugar de dibujar un cuadro de 200 x 200, ocupe toda la pantalla, sustituye `Modifier.size(200.dp)` por `Modifier.fillMaxSize()`,
26
27Visualización del ejemplo:
28
29```kotlin { lineNos="inline" title="Kotlin" }
30@Preview(showBackground = true)
31@Composable
32fun VistaPreviaCanvas() {
33 EjemploCanvasSencillo()
34}
Como puedes observar, el uso de Canvas
es puramente decorativo, para dibujos personalizados, gráficos o animaciones. Debes tener en cuenta que el orden de las operaciones es importante, cada llamada a un draw...
se superpone a las anteriores. Puedes combinar Canvas
con otros layouts y modificadores (padding, clip, etc.).
Ejemplos prácticos
2.6. Capas de dibujo y graphicsLayer
El modificador graphicsLayer
en Jetpack Compose permite transformaciones a nivel de capa modificando propiedades como rotación, escala, alfa, traslación, etc.
2.6.1. ¿Qué es graphicsLayer
?
graphicsLayer
crea una capa de composición separada para el elemento, permitiendo así transformaciones y efectos visuales que no afectan a otros nodos.
- Es ideal para animaciones, efectos complejos o para optimizar redibujados cuando se aplican múltiples transformaciones.
Propiedades comunes
Propiedad |
Descripción |
alpha |
Opacidad (0.0 = transparente, 1.0 = opaco) |
rotationX , rotationY , rotationZ |
Rotación en grados |
scaleX , scaleY |
Escalado |
translationX , translationY |
Traslación en píxeles |
shadowElevation |
Sombra en píxeles (solo para elevación Z) |
cameraDistance |
Distancia de cámara para efectos 3D |
Ejemplo 2.6. Rotar y escalar un Box
1@Composable
2fun EjemploGraphicsLayer() {
3 Box(
4 modifier = Modifier
5 .size(200.dp)
6 .graphicsLayer(
7 rotationZ = 45f, // Rotación 45 grados
8 scaleX = 1.5f, // Escala 1.5x en X
9 scaleY = 1.5f, // Escala 1.5x en Y
10 alpha = 0.8f, // Opacidad 80%
11 shadowElevation = 16f // Sombra
12 )
13 .background(Color.Red),
14 contentAlignment = Alignment.Center
15 ) {
16 Text("Transformado", color = Color.White, fontSize = 16.sp)
17 }
18}
19
20@Preview(showBackground = true)
21@Composable
22fun VistaPreviaGraphicsLayer() {
23 EjemploGraphicsLayer()
24}
2.6.2. Animaciones con graphicsLayer
Se puede utilizar animateFloatAsState
para animar propiedades de la capa, como la rotación.
1@Composable
2fun EjemploAnimacionGraphicsLayer() {
3 var rotar by remember { mutableStateOf(false) }
4 val rotacionAnimada by animateFloatAsState(targetValue = if (rotar) 360f else 0f)
5
6 Box(
7 modifier = Modifier
8 .size(200.dp)
9 .graphicsLayer(
10 rotationZ = rotacionAnimada,
11 scaleX = 1.2f,
12 scaleY = 1.2f
13 )
14 .background(Color.Blue)
15 .clickable { rotar = !rotar },
16 contentAlignment = Alignment.Center
17 ) {
18 Text("Haz clic", color = Color.White)
19 }
20}
Ejemplos prácticos
2.7. Estructura visual con Scaffold y TopAppBar
El componente Scaffold
se utiliza para estructurar pantallas completas en Compose, organizando elementos como TopAppBar
, BottomAppBar
, FloatingActionButton
y el contenido principal de la UI mediante secciones.
2.7.1. ¿Qué es Scaffold?
Scaffold
es un contenedor base que permite estructurar la pantalla mediante secciones predefinidas.
Permite organizar los elementos comunes de una app:
- Barra superior (topBar)
- Barra inferior (bottomBar)
- Botón flotante (floatingActionButton)
- Snackbar (snackbarHost)
- Contenido principal (content)
1Scaffold(
2 topBar = { TopAppBar(...) },
3 bottomBar = { BottomAppBar(...) },
4 floatingActionButton = { FloatingActionButton { ... } },
5 content = { paddingValues ->
6 // Contenido principal con padding
7 }
8)
2.7.2. Uso básico de Scaffold con TopAppBar
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun EjemploScaffoldBasico() {
4 Scaffold(
5 topBar = {
6 TopAppBar(
7 title = { Text("Mi App") },
8 colors = topAppBarColors(
9 containerColor = MaterialTheme.colorScheme.primaryContainer,
10 titleContentColor = MaterialTheme.colorScheme.primary,
11 )
12 )
13 },
14 content = { padding ->
15 Box(
16 modifier = Modifier
17 .fillMaxSize()
18 .padding(padding),
19 contentAlignment = Alignment.Center
20 ) {
21 Text("Contenido principal")
22 }
23 }
24 )
25}
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun EjemploScaffoldBasico() {
4 Scaffold(
5 topBar = {
6 TopAppBar(
7 title = { Text("Mi App") },
8 colors = topAppBarColors(
9 containerColor = MaterialTheme.colorScheme.primaryContainer,
10 titleContentColor = MaterialTheme.colorScheme.primary,
11 )
12 )
13 },
14 bottomBar = {
15 BottomAppBar {
16 Text("Barra inferior", modifier = Modifier.padding(16.dp))
17 }
18 },
19 floatingActionButton = {
20 FloatingActionButton(onClick = { /* Acción */ }) {
21 Icon(Icons.Default.Add, contentDescription = "Añadir")
22 }
23 },
24 floatingActionButtonPosition = FabPosition.End,
25 ) { innerPadding ->
26 Column(
27 modifier = Modifier.padding(innerPadding),
28 verticalArrangement = Arrangement.spacedBy(16.dp),
29 ) {
30 Text(
31 modifier = Modifier.padding(8.dp),
32 text = """
33 This is an example of a scaffold. It uses the Scaffold composable's parameters to create a screen with a simple top app bar, bottom app bar, and floating action button.
34
35 It also contains some basic inner content, such as this text.
36
37 You have pressed the floating action button 3 times.
38 """.trimIndent(),
39 )
40 }
41 }
42}
2.7.4. Organización de pantallas por secciones
Scaffold
divide cada parte de la UI en una sección, facilitando la legibilidad y el mantenimiento del código.
- Permite diseñar pantallas complejas con contenido adaptado a dispositivos (tablets, móviles).
Sección |
Uso |
topBar |
Barra superior (menú, título, acciones) |
bottomBar |
Barra inferior (navegación, información) |
floatingActionButton |
Botón flotante para acción principal |
snackbarHost |
Mostrar notificaciones flotantes |
content |
Contenido principal de la pantalla |
Ejemplos prácticos
2.8. Elementos básicos de la UI
Este punto tratará los componentes básicos de Jetpack Compose, haciendo una correlación con alugnos de sus equivalente en vistas (XML) de TextView
, EditText
, CheckBox
, RadioButton
, ImageView
, controlando su estado, estilo e interacción.
2.8.1. Texto o etiqueta: Text
(equivalente a TextView
)
1@Composable
2fun EjemploText() {
3 Text(
4 text = "Hola, Compose!",
5 style = MaterialTheme.typography.titleLarge,
6 color = Color(0xFF1E88E5),
7 modifier = Modifier.fillMaxWidth().padding(8.dp)
8 )
9}
Se configura el estilo y color utilizando MaterialTheme
y Color
.
2.8.2. Cuadro de texto: TextField
(equivalente a EditText
)
1@Composable
2fun EjemploTextField() {
3 var nombre by remember { mutableStateOf("") }
4
5 Column(modifier = Modifier.wrapContentHeight().padding(16.dp)) {
6 TextField(
7 modifier = Modifier.fillMaxWidth(),
8 value = nombre,
9 onValueChange = { nombre = it },
10 label = { Text("Nombre") },
11 placeholder = { Text("Escribe tu nombre") } // Equivalente a hint en XML
12 )
13 Spacer(Modifier.height(16.dp))
14 Text("Hola, $nombre")
15 }
16}
El estado está gestionado con remember
y mutableStateOf
, se usa de label
para asignarle una etiqueta al campo y placeholder
para indicar las instrucciones, sería el equivalente a hint
para las vistas XML. El input es reactivo al estado, apareciendo el texto en un Text
separado por un Spacer
.
Puedes cambiar el estilo del cuadro de texto utilizando OutlinedTextField
en lugar de TextField
.
2.8.3. Casilla: Checkbox
1@Composable
2fun EjemploCheckbox() {
3 var marcado by remember { mutableStateOf(false) }
4
5 Row(
6 verticalAlignment = Alignment.CenterVertically,
7 modifier = Modifier.fillMaxWidth().padding(16.dp)
8 ) {
9 Checkbox(
10 checked = marcado,
11 onCheckedChange = { marcado = it }
12 )
13 Spacer(Modifier.width(8.dp))
14 Text(text = if (marcado) "Marcado" else "No marcado")
15 }
16}
Actualiza el estado al marcar la casilla y se puede añadir texto descriptivo junto con un Text
.
1@Composable
2fun EjemploRadioButton() {
3 val opciones = listOf("Opción A", "Opción B", "Opción C")
4 var seleccion by remember { mutableStateOf(opciones[0]) }
5
6 Column(Modifier
7 .selectableGroup()
8 .padding(16.dp)) {
9 opciones.forEach { texto ->
10 Row(
11 verticalAlignment = Alignment.CenterVertically,
12 modifier = Modifier
13 .fillMaxWidth()
14 .selectable(
15 selected = (texto == seleccion),
16 onClick = { seleccion = texto },
17 role = Role.RadioButton
18 )
19 .padding(8.dp)
20 ) {
21 RadioButton(
22 selected = (texto == seleccion),
23 onClick = null
24 )
25 Spacer(Modifier.width(8.dp))
26 Text(text = texto)
27 }
28 }
29 Text("Seleccionado: $seleccion", modifier = Modifier.padding(top = 8.dp))
30 }
31}
Se hace uso de selectableGroup
para accesibilidad y agrupación y muestra la opción seleccionada.
2.8.5. Interruptor: Switch
1@Composable
2fun EjemploSwitch() {
3 var activado by remember { mutableStateOf(false) }
4
5 Row(
6 verticalAlignment = Alignment.CenterVertically,
7 modifier = Modifier.fillMaxWidth().padding(16.dp)
8 ) {
9 Switch(
10 checked = activado,
11 onCheckedChange = { activado = it }
12 )
13 Spacer(Modifier.width(8.dp))
14 Text(text = if (activado) "Activado" else "Desactivado")
15 }
16}
Alterna valores booleanos de forma visual y accesible.
2.8.6. Imagen: Image
(equivalente a ImageView
)
1@Composable
2fun EjemploImage() {
3 Image(
4 painter = painterResource(id = R.drawable.ic_launcher_foreground),
5 contentDescription = "Ejemplo de imagen",
6 modifier = Modifier
7 .size(150.dp)
8 .clip(RoundedCornerShape(8.dp))
9 .border(2.dp, Color.Gray, RoundedCornerShape(8.dp)),
10 contentScale = ContentScale.Crop
11 )
12}
Se pueden cargar imágenes desde recursos (R
), además, se puede recortar (clip
), redondear esquinas y aplicar borde.
2.8.7. Cargar imágenes desde URL con librerías externas
Para estos casos es necesario el uso de Internet, por lo que la aplicación deberá tener declarado dicho permiso en el Manifest. Además se añade un segundo permiso para permitir a estas librerías hacer comprobaciones del estado de la red.
1<uses-permission android:name="android.permission.INTERNET" />
2<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
GlideImage
GlideImage
pertenece a la librería Glide
, actualmente la integración oficial con Jetpack Compose está en fase beta experimental, por lo que su uso puede ser algo impredecible.
En primer lugar se deberá añadir la siguiente dependencia al Gradle
y sincronizar.
1// Glide for image loading
2implementation("com.github.bumptech.glide:compose:1.0.0-alpha.1")
Para aplicar Glide
, añade el siguiente método Composable:
1@OptIn(ExperimentalGlideComposeApi::class)
2@Composable
3fun ImagenConGlide(imageUrl: String) {
4 GlideImage(
5 model = imageUrl,
6 contentDescription = "Imagen con Glide",
7 modifier = Modifier
8 .padding(16.dp)
9 .size(200.dp)
10 .clip(RoundedCornerShape(12.dp)),
11 contentScale = ContentScale.Crop
12 )
13}
Como puedes observar, se carga la imagen en model
, se añade una descripción para la imagen y se modifica el contenedor de la imagen con el padding, el tamaño y se recorta (clip
) redondeando las esquinas. Por último se escala la imagen.
Para utilizar este método bastará con pasar la URL por parámetro al llamarlo.
1ImagenConGlide(imageUrl = "https://via.placeholder.com/300")
AsyncImage
Otra opción para cargar imágenes desde URL en Jetpack Compose es usando la librería Coil
y el Composable AsyncImage
. En primer lugar se deberá añadir las siguientes dependencias al Gradle
y sincronizar.
1// Coil for image loading
2implementation("io.coil-kt.coil3:coil-compose:3.2.0")
3implementation("io.coil-kt.coil3:coil-network-okhttp:3.2.0")
El método Composable para cargar una imagen desde una URL puede ser como se muestra a continuación:
1@Composable
2fun ImagenConAsyncImage(imageUrl: String) {
3 AsyncImage(
4 model = ImageRequest.Builder(LocalContext.current)
5 .data(imageUrl)
6 .crossfade(true)
7 .build(),
8 contentDescription = "Imagen con Coil",
9 placeholder = painterResource(R.drawable.loading),
10 error = painterResource(R.drawable.error),
11 contentScale = ContentScale.Crop,
12 modifier = Modifier
13 .padding(16.dp)
14 .size(200.dp)
15 .clip(RoundedCornerShape(12.dp))
16 .border(2.dp, Color.Gray, RoundedCornerShape(12.dp))
17 )
18}
Esta es una versión más compleja, ya que se utiliza ImageRequest
para habilitar crossfade
, utilizando LocalContext.current
para construirlo. Además, se especifica placeholder
y error
. A diferencia de Glide, se puede añadir un borde a la imagen.
Para utilizar este método bastará con pasar la URL por parámetro al llamarlo.
1ImagenConAsyncImage(imageUrl = "https://via.placeholder.com/300")
Conclusiones
Coil
actualmente tiene una integración estable, uso sencillo, tamaño reducido y una API moderna para Compose.
Glide
tiene mejor rendimiento para GIFs o caching, y también tiene integración con Compose, pero hay que tener en cuenta que está en beta y puede requerir cambios futuros. Es más pesado que Coil, pero tiene mejor rendimiento en listas.
El desplazamiento de contenido en Compose puede aplicarse directamente sobre Column
y Row
con los modificadores verticalScroll
y horizontalScroll
, que equivalen al clásico ScrollView
en XML.
Un scroll de este tipo, no es lazy, como se verá más adelante, carga todo el contenido.
1Column(
2 modifier = Modifier
3 .fillMaxSize()
4 .verticalScroll(rememberScrollState())
5 .padding(innerPadding)
6) {
7 ...
8}
9```~
10
11```kotlin { lineNos="inline" title="Kotlin" }
12Row(
13 modifier = Modifier
14 .fillMaxWidth()
15 .horizontalScroll(rememberScrollState())
16 .padding(16.dp)
17) {
18 ...
19}
El uso recomendado de este elemento es para vistas fijas, tipo formularios o de contenido corto pero que no cabe en la parte visible.
2.9. Listas con LazyColumn y LazyRow
Para crear listas eficientes y escalables en Jetpack Compose se utiliza LazyColumn
y LazyRow
, estos vienen a ser los equivalentes modernos a RecyclerView
.
2.9.1. Diferencias clave respecto a RecyclerView
Si conocéis RecyclerView
para vistas XML, la siguiente tabla os aclarará algunos conceptos.
Apartado |
RecyclerView |
LazyColumn / LazyRow |
Arquitectura |
Basado en ViewHolder + Adapter |
Declarativo, sin adapter |
Layout |
XML + inflado manual |
Composable |
Ciclo de vida |
Fragment / Activity |
Composable puro |
Reutilización |
Sí, con pool de vistas |
Sí, de forma implícita y lazy |
Configuración |
Compleja (LayoutManager , Adapter ) |
Muy simple (items o itemsIndexed ) |
Escalabilidad |
Muy buena |
Excelente para listas dinámicas |
2.9.2. Estructura básica con items
Por ejemplo, se crea un companion que se pasará en la llamada al método desde onCreate()
que contrendrá la lista de datos.
1class MainActivity : ComponentActivity() {
2 companion object {
3 val itemsList = List(100) { "Item #$it" }
4 }
5 ...
6}
El Composable que permite crear una lista básica utilizando items
puede ser como se muestra:
1@Composable
2fun ExampleLazyColum(itemsList: List<String>) {
3 LazyColumn(
4 modifier = Modifier.fillMaxSize(),
5 verticalArrangement = Arrangement.spacedBy(8.dp) // Espacio entre los elementos.
6 ) {
7 items(itemsList) { item ->
8 Text(text = item)
9 }
10 }
11}
Se suele utilizar para listas de elementos de tipo simple o complejo, y puede usarse con o sin key para mejorar el rendimiento.
2.9.3. Utilizando itemsIndexed
Añade la siguiente lista al companion:
1val dias = listOf("Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo")
El Composable utilizando itemsIndexed
podría ser como se muestra:
1fun ExampleLazyColum2(dias: List<String>) {
2 LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
3 itemsIndexed(dias) { index, dia ->
4 Text("[$index] Día: $dia")
5 }
6 }
7}
Esta versión permite acceder al índice y al valor en cada iteración. Perfecto para numeraciones, saltos condicionales o detección del último elemento.
2.9.4. Delegación de eventos y selección de ítems
Para este ejemplo, se añade al companion una nueva lista.
1val frutas = listOf(
2 "Manzana",
3 "Pera",
4 "Naranja",
5 "Plátano",
6 "Fresa",
7 "Kiwi",
8 "Mango",
9 "Piña",
10 "Uva",
11 "Sandía"
12)
Y se crea el siguiente método Composable.
1@Composable
2fun ListaConSeleccion(frutas: List<String>) {
3 var seleccionada by remember { mutableStateOf<String?>(null) }
4
5 LazyColumn {
6 items(frutas) { fruta ->
7 Text(
8 text = fruta,
9 modifier = Modifier
10 .fillMaxWidth()
11 .clickable { // Acción al hacer clic.
12 seleccionada = fruta
13 Log.d("Seleccionada", "Fruta seleccionada: $seleccionada")
14 }
15 .background(if (seleccionada == fruta) Color.LightGray else Color.Transparent)
16 .padding(16.dp)
17 )
18 }
19 }
20}
Se delega el evento de clic utilizando Modifier.clickable
, y se gestiona el estado de selección en la vista, sin necesidad de adapter, simplemente controlando la selección en la propiedad background
.
2.9.5. Optimización de listas grandes
Si la lista a utilizar tiene elementos con IDs únicos se utilizará items(..., key = { it.id })
:
1items(listaUsuarios, key = { it.id }) { usuario -> ... }
- Utiliza
remember
y derivedStateOf
para evitar recomposiciones innecesarias.
- Controla la composición de elementos pesados o animados.
2.9.6. Ejemplo completo con LazyColumn
Crea la siguiente lista en el companion de la clase:
1val usuarios = listOf(
2 "Ana",
3 "Luis",
4 "Carlos",
5 "Lucía",
6 "María",
7 "Javier",
8 "Patricia",
9 "Sofía",
10 "Pedro",
11 "Laura",
12 "David",
13 "Isabel"
14)
Siguiendo los ejemplos anteriores, se creará el siguiente Composable.
1@Composable
2fun ListaUsuarios(usuarios: List<String>) {
3 val context = LocalContext.current
4
5 LazyColumn(
6 contentPadding = PaddingValues(12.dp),
7 verticalArrangement = Arrangement.spacedBy(8.dp)
8 ) {
9 items(usuarios, key = { it }) { nombre ->
10 Card(
11 modifier = Modifier
12 .fillMaxWidth()
13 .clickable {
14 Toast.makeText(
15 context,
16 "Usuario seleccionado: $nombre",
17 Toast.LENGTH_SHORT
18 ).show()
19 }
20 .padding(4.dp)
21 ) {
22 Row {
23 Image(
24 painter = painterResource(id = R.drawable.ic_launcher_foreground),
25 contentDescription = "Imagen de usuario",
26 modifier = Modifier
27 .wrapContentSize()
28 .size(50.dp)
29 )
30 Text(
31 text = nombre,
32 modifier = Modifier.fillMaxSize().padding(16.dp),
33 style = MaterialTheme.typography.bodyLarge
34 )
35 }
36 }
37 }
38 }
39}
- En este caso se ha utilizado
items
para crear una lista de tarjetas con los nombres de los usuarios.
- Cada tarjeta es clicable y muestra un
Toast
al seleccionarla, recuerda que hay que recuperar el contexto actual para poder mostrarlo.
- El uso de
key = { it }
asegura que cada elemento tenga una clave única, si por ejemplo, Ana estuviese duplicada, se produciría el siguiente error:
java.lang.IllegalArgumentException: Key "Ana" was already used. If you are using LazyColumn/Row please make sure you provide a unique key for each item.
1LazyRow {
2 items(listOf("🍎", "🍊", "🍌", "🍇", "🍏", "🍋", "🥑", "🍉", "🍓")) { fruta ->
3 Text(fruta, fontSize = 48.sp, modifier = Modifier.padding(8.dp))
4 }
5}
2.10. Notificaciones visuales: Snackbar y Toast
En este punto se verá cómo mostrar notificaciones visuales al usuario utilizando Snackbar
(propio de Compose y Material Design) y Toast
(clásico de Android), viendo su uso e implementación de forma correcta en Compose.
2.10.1. Diferencias clave entre Snackbar
y Toast
Características |
Snackbar |
Toast |
Visibilidad |
Dentro de la UI (Scaffold) |
Flotante, fuera del árbol Compose |
Interactivo |
✅ Soporta acciones (con botón) |
❌ No permite interacción |
Estilo Material |
✅ Integra con temas de Compose Material |
❌ Estilo clásico de Android |
Control desde Compose |
✅ Totalmente declarativo |
⚠️ Necesita Context |
Uso recomendado |
Mensajes importantes o con acción |
Mensajes breves e informativos |
2.10.2. Mostrar un Snackbar
con acción
1@Composable
2fun SnackbarConAccionEjemplo() {
3 val snackbarHostState = remember { SnackbarHostState() }
4 val scope = rememberCoroutineScope()
5
6 Scaffold(
7 snackbarHost = { SnackbarHost(snackbarHostState) },
8 floatingActionButton = {
9 FloatingActionButton(onClick = {
10 scope.launch {
11 val resultado = snackbarHostState.showSnackbar(
12 message = "Se ha borrado un elemento",
13 actionLabel = "Deshacer",
14 duration = SnackbarDuration.Short
15 )
16 // Manejo del resultado del Snackbar.
17 if (resultado == SnackbarResult.ActionPerformed) {
18 Log.d("SNACKBAR", "El usuario pulsó Deshacer")
19 }
20 }
21 }) {
22 Icon(Icons.Default.Delete, contentDescription = "Eliminar")
23 }
24 }
25 ) { innerPadding ->
26 Box(
27 modifier = Modifier
28 .padding(innerPadding)
29 .fillMaxSize(),
30 contentAlignment = Alignment.Center
31 ) {
32 Text("Haz clic en el FAB para mostrar el Snackbar.")
33 }
34 }
35}
SnackbarHostState
mantiene el estado del Snackbar
, con el método showSnackbar()
se lanza una corutina para mostrarlo en pantalla. A continuación, se comprueba (if) si el usuario pulsa la acción con SnackbarResult
y la constante ActionPerformed
, para comprobar que el usuario no la puede utilizarse Dismissed
.
1if (resultado == SnackbarResult.Dismissed) {
2 Log.d("SNACKBAR", "El usuario descartó el Snackbar")
3}
2.10.3. Mostrar un Toast
en Compose
1@Composable
2fun ToastEjemplo() {
3 val context = LocalContext.current
4
5 Button(onClick = {
6 Toast.makeText(context, "Mensaje desde Toast", Toast.LENGTH_SHORT).show()
7 }) {
8 Text("Mostrar Toast")
9 }
10}
Para este case es neceario usar LocalContext.current
para acceder a un Context que pueda ser utilizado. El Toast
se muestra como en el sistema clásico de vistas de Android y no depende de Scaffold
.
Ejemplo 2.10. Combinando ambos
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun EjemploSnackbarYToast() {
4 val context = LocalContext.current
5 val snackbarHostState = remember { SnackbarHostState() }
6 val scope = rememberCoroutineScope()
7
8 Scaffold(
9 snackbarHost = { SnackbarHost(snackbarHostState) },
10 topBar = {
11 TopAppBar(title = { Text("Notificaciones") })
12 },
13 content = { padding ->
14 Column(
15 modifier = Modifier
16 .fillMaxSize()
17 .padding(padding)
18 .padding(16.dp),
19 verticalArrangement = Arrangement.spacedBy(16.dp) // Espacio entre los elementos.
20 ) {
21 Button(
22 modifier = Modifier.fillMaxWidth(),
23 onClick = {
24 scope.launch {
25 snackbarHostState.showSnackbar("Esto es un Snackbar")
26 }
27 }) {
28 Text("Mostrar Snackbar")
29 }
30
31 Button(
32 modifier = Modifier.fillMaxWidth(),
33 onClick = {
34 Toast.makeText(context, "Esto es un Toast", Toast.LENGTH_SHORT).show()
35 }) {
36 Text("Mostrar Toast")
37 }
38 }
39 }
40 )
41}
Recomendaciones
- Utiliza
Snackbar
dentro de Scaffold
.
- Usa
Toast
para mostrar información rápida.
- No muestres múltiples
Toast
seguidos (no cancelables por el usuario).
- Se recomienda observar el resultado del
Snackbar
si se utilizan acciones.
2.11. Menús en Jetpack Compose
Ahora se verá como implementar diferentes tipos de menús en Jetpack Compose, desde los más simples hasta los personalizados o en cascada, tratando de comprender cuándo usar cada uno.
1@Composable
2fun MenuBasico() {
3 var expanded by remember { mutableStateOf(false) }
4 val context = LocalContext.current // Contexto para mostrar Toast.
5
6 Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
7 IconButton(onClick = { expanded = !expanded }) {
8 Icon(Icons.Default.MoreVert, contentDescription = "Más opciones") // Icono 3 puntos.
9 }
10 DropdownMenu(
11 expanded = expanded,
12 onDismissRequest = { expanded = false }
13 ) {
14 DropdownMenuItem(text = { Text("Opción 1") }, onClick = { /*...*/ })
15 DropdownMenuItem(text = { Text("Opción 2") }, onClick = {
16 Toast.makeText(context, "Opción 2 seleccionada", Toast.LENGTH_SHORT).show()
17 expanded = false // Cierra el menú al seleccionar una opción.
18 })
19 }
20 }
21}

Este menú está formado por DropdownMenu
, el menú en sí, y DropdownMenuItem
, las opciones del menú. El popup aparecerá anclado al elemento que dispara el menú, en este caso el IconButton
.
2.11.2. Menú en overflow de TopAppBar
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun TopAppBarConMenu() {
4 var showMenu by remember { mutableStateOf(false) }
5
6 Scaffold(
7 topBar = {
8 TopAppBar(
9 title = { Text("AppBar con Menú") },
10 actions = {
11 IconButton(onClick = { /* acción principal */ }) {
12 Icon(Icons.Default.Share, contentDescription = "Compartir")
13 }
14 IconButton(onClick = { showMenu = !showMenu }) {
15 Icon(Icons.Default.MoreVert, contentDescription = "Más")
16 }
17 DropdownMenu(
18 expanded = showMenu,
19 onDismissRequest = { showMenu = false }
20 ) {
21 DropdownMenuItem(text = { Text("Guardar") }, onClick = { /* ... */ })
22 DropdownMenuItem(text = { Text("Eliminar") }, onClick = { /* ... */ })
23 }
24 }
25 )
26 }
27 ) { innerPadding ->
28 /* ... */
29 }
30}
Este patrón es ideal para pantallas con un menú de opciones en la barra superior. Si observas el código, es el mismo que el utilizado para el menú básico.
Ahora bien, lo ideal sería reutilizar código, algo bastante habitual en los menús, creando un método que contenga la estructura del menú, dejando el Scaffold
más limpio. En primer lugar se crearía el Composable que crea el menú.
1@Composable
2fun AppBarOverflowMenu(onSave: () -> Unit) { // Callback para guardar.
3 var showMenu by remember { mutableStateOf(false) }
4
5 IconButton(onClick = { /* acción principal */ }) {
6 Icon(Icons.Default.Share, contentDescription = "Compartir")
7 }
8 IconButton(onClick = { showMenu = !showMenu }) {
9 Icon(Icons.Default.MoreVert, contentDescription = "Más opciones") // Icono 3 puntos.
10 }
11 DropdownMenu(
12 expanded = showMenu,
13 onDismissRequest = { showMenu = false }
14 ) {
15 DropdownMenuItem(text = { Text("Guardar") }, onClick = { /* ... */ })
16 DropdownMenuItem(text = { Text("Eliminar") }, onClick = {
17 showMenu = false // Cierra el menú al seleccionar una opción.
18 onSave() // Llama al callback para guardar.
19 })
20 }
21}
En este código se introduce la delegación del callback para hacerlo más reutilizable. El Scaffold
quedaría más limpio, extrayendo el callback, además, se lleva fuera, dejándolo más sencillo.
1setContent {
2 Example_t02_11Theme {
3 Scaffold(
4 topBar = {
5 TopAppBar(
6 title = { Text("Menús") },
7 actions = {
8 AppBarOverflowMenu(onSave = showToast)
9 }
10 )
11 }
12 )
13 { innerPadding ->
14 /* ... */
15 }
16 }
17}
El objeto showToast
quedaría como una propiedad de la clase MainActivity
.
1class MainActivity : ComponentActivity() {
2 private val showToast: () -> Unit = {
3 Toast.makeText(this, "Guardado correctamente", Toast.LENGTH_SHORT).show()
4 }
5 ...
6}
Ventajas de este sistema o esquema de uso:
- Reusabilidad: El menú está encapsulado y puede utilizarse en varias pantallas.
- Claridad: Separa la lógica del menú, facilitando su lectura y pruebas.
- Flexibilidad: Se pueden pasar callbacks (
onSave
) para definir acciones desde cada pantalla.
1@Composable
2fun MenuLargo() {
3 var expanded by remember { mutableStateOf(false) }
4 val opciones = List(50) { "Opción ${it + 1}" }
5 val context = LocalContext.current // Contexto para mostrar Toast.
6
7 Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
8 // Botón para mostrar el menú desplegable.
9 IconButton(onClick = { expanded = !expanded }) {
10 Icon(Icons.Default.MoreVert, contentDescription = "Más opciones")
11 }
12 DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
13 opciones.forEach { opc ->
14 DropdownMenuItem(text = { Text(opc) }, onClick = {
15 Toast.makeText(context, "$opc seleccionada", Toast.LENGTH_SHORT).show()
16 expanded = false // Cierra el menú al seleccionar una opción.
17 })
18 }
19 }
20 }
21}
Si el menú sobrepasa el espacio disponible, automáticamente se activa el scroll.
2.11.4. Menú con iconos y divisores
1@Composable
2fun MenuConDetalles() {
3 var expanded by remember { mutableStateOf(false) }
4
5 Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
6 IconButton(onClick = { expanded = !expanded }) {
7 Icon(Icons.Default.MoreVert, contentDescription = "Menú")
8 }
9 DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
10 DropdownMenuItem(text = { Text("Perfil") },
11 leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
12 onClick = { /*...*/ })
13 HorizontalDivider(
14 modifier = Modifier.padding(horizontal = 8.dp), // Espacio horizontal (opcional).
15 thickness = 1.dp, // Espacio entre los elementos del menú (opcional).
16 color = Color.Red // Color del divisor (opcional).
17 )
18 DropdownMenuItem(text = { Text("Configuración") },
19 leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
20 onClick = { /*...*/ })
21 }
22 }
23}
Esta puede ser una buena opción para menús organizados, con secciones y elementos visuales.
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun MenuSpinner() {
4 val opciones = listOf("Rojo", "Verde", "Azul", "Amarillo")
5 var expanded by remember { mutableStateOf(false) }
6 var seleccion by remember { mutableStateOf(opciones[0]) }
7
8 ExposedDropdownMenuBox(
9 expanded = expanded,
10 onExpandedChange = { expanded = !expanded },
11 modifier = Modifier.fillMaxWidth()
12 ) {
13 TextField(
14 value = seleccion,
15 onValueChange = {},
16 readOnly = true,
17 label = { Text("Color favorito") },
18 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
19 modifier = Modifier
20 .fillMaxWidth()
21 .menuAnchor(MenuAnchorType.PrimaryEditable, true)
22 )
23 ExposedDropdownMenu(
24 expanded = expanded,
25 onDismissRequest = { expanded = false }
26 ) {
27 opciones.forEach { color ->
28 DropdownMenuItem(
29 text = { Text(color) },
30 onClick = {
31 seleccion = color
32 expanded = false
33 }
34 )
35 }
36 }
37 }
38}
Permite mostrar el elemento seleccionado en un TextField
o OutlinedTextField
, puede venir bien para selección de una dimensión fija.
2.11.6. Menú expuesto editable (autocomplete)
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun SpinnerAutocomplete() {
4 val opciones = listOf("Alicante", "Barcelona", "Bilbao", "Madrid", "Valencia", "Zaragoza")
5 var expanded by remember { mutableStateOf(false) }
6 var text by remember { mutableStateOf("") }
7 val filtradas = opciones.filter { it.contains(text, true) }
8
9 ExposedDropdownMenuBox(
10 expanded = expanded && filtradas.isNotEmpty(),
11 onExpandedChange = { expanded = !expanded }) {
12 OutlinedTextField(
13 value = text,
14 onValueChange = {
15 text = it
16 expanded = true
17 },
18 singleLine = true,
19 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
20 label = { Text("Ciudad") },
21 modifier = Modifier
22 .fillMaxWidth()
23 .menuAnchor(MenuAnchorType.PrimaryEditable, true) // Ancla el menú al campo de texto.
24 )
25 ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
26 filtradas.forEach { ciudad ->
27 DropdownMenuItem(text = { Text(ciudad) }, onClick = {
28 text = ciudad
29 expanded = false
30 })
31 }
32 }
33 }
34}
Este menú permite escribir para filtrar las opciones, muy útil para selecciones extensas. También se conoce como autocompletar.
Estos son un tipo de menú que actualmente no se encuentran en el core de Compose, pero pueden añadirse utilizando la librería cascade-compose
.
1implementation("me.saket.cascade:cascade-compose:2.3.0")
Este tipo de menú puede ser una buena opción para añadirlo a la TopAppBar
.
1@Composable
2fun CascadeMenu() {
3 var expanded by remember { mutableStateOf(false) }
4 val context = LocalContext.current
5
6 Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
7 IconButton(onClick = { expanded = !expanded }) {
8 Icon(Icons.Default.MoreVert, contentDescription = "Menú cascada")
9 }
10
11 CascadeDropdownMenu(
12 expanded = expanded,
13 onDismissRequest = { expanded = false }
14 ) {
15 // Item principal con submenú
16 DropdownMenuItem(
17 text = { Text("Opciones Avanzadas ▸") },
18 children = {
19 DropdownMenuItem(
20 text = { Text("Sub‑opción 1") },
21 onClick = {
22 expanded = false
23 Toast.makeText(context, "Sub‑opción 1", Toast.LENGTH_SHORT).show()
24 }
25 )
26 DropdownMenuItem(
27 text = { Text("Sub‑opción 2") },
28 onClick = {
29 expanded = false
30 Toast.makeText(context, "Sub‑opción 2", Toast.LENGTH_SHORT).show()
31 }
32 )
33 }
34 )
35 // Otro item principal
36 DropdownMenuItem(
37 text = { Text("Acerca de") },
38 onClick = {
39 expanded = false
40 Toast.makeText(context, "Acerca de", Toast.LENGTH_SHORT).show()
41 }
42 )
43 }
44 }
45}
Las ventajas de utilizar esta librería, principalmente, es la simplificación a la hora de crear menús jerárquicos. Además, añade animaciones en la expansión y contracción de los menús y es compatible con Material Design y Compose.
Ejemplos prácticos
2.12. Cuadros de diálogo (AlertDialog)
Este punto pretendre introducir la creación y gestión de cuadros de diálogo en Jetpack Compose con AlertDialog
(MaterialAlertDialog
versión para vistas), y entender cómo personalizarlos para diferentes contextos: confirmación, alerta, formulario, etc.
Los diálogos son un componente modal que interrumpe el flujo de la interfaz para presentar información importante o solicitar una acción del usuario.
El siguiente código muestra un cuadro de diálogo muy simple, de uso informativo.
1val openInfoDialog = remember { mutableStateOf(false) }
2
3/* ... */
4
5Button(onClick = { openInfoDialog.value = true }) {
6 Text("Mostrar info")
7}
8
9if (openInfoDialog.value) {
10 AlertDialog(
11 onDismissRequest = { openInfoDialog.value = false }, // Se cierra el diálogo al tocar fuera de él.
12 title = { Text("Información") },
13 text = { Text("Esta es una notificación informativa.") },
14 confirmButton = {
15 TextButton(onClick = { openInfoDialog.value = false }) {
16 Text(LocalContext.current.getString(android.R.string.ok))
17 }
18 }
19 )
20}
2.12.2. Cuadro de diálogo básico
El siguiente código muestra el método Composable para mostrar un cuadro de diálogo, además, se añaden los parámetros necesarios para delegar las acciones, un posible icono y el texto. Esto hará del componente más reutilizable.
1@Composable
2fun AlertDialogExample(
3 onDismissRequest: () -> Unit,
4 onConfirmation: () -> Unit,
5 dialogTitle: String,
6 dialogText: String,
7 icon: ImageVector = Icons.Default.Warning,
8) {
9 AlertDialog(
10 icon = { Icon(icon, contentDescription = "Example Icon") },
11 title = { Text(text = dialogTitle) },
12 text = { Text(text = dialogText) },
13 properties = DialogProperties(
14 dismissOnBackPress = false, // Se evita el cierre al presionar atrás.
15 dismissOnClickOutside = false // Se evita el cierre al tocar fuera del diálogo.
16 ),
17 onDismissRequest = { onDismissRequest() }, // Se llama a la función de cierre del diálogo si no está bloqueado el cierre.
18 confirmButton = {
19 TextButton(onClick = { onConfirmation() }) {
20 Text(LocalContext.current.getString(android.R.string.ok))
21 }
22 },
23 dismissButton = {
24 TextButton(onClick = { onDismissRequest() }) {
25 Text(LocalContext.current.getString(android.R.string.cancel))
26 }
27 }
28 )
29}
El uso de este cuadro de diálogo será como se muestra a continuación.
1@ExperimentalMaterial3Api
2@Composable
3fun PantallaPrincipal() {
4 val openAlertDialog = remember { mutableStateOf(false) }
5
6 Scaffold(
7 topBar = {
8 TopAppBar(
9 title = { Text("Cuadros de diálogo") },
10 colors = topAppBarColors(
11 containerColor = MaterialTheme.colorScheme.primaryContainer,
12 titleContentColor = MaterialTheme.colorScheme.primary,
13 )
14 )
15 },
16 modifier = Modifier.fillMaxSize()
17 ) { innerPadding ->
18 Column(
19 modifier = Modifier
20 .fillMaxSize()
21 .padding(innerPadding),
22 horizontalAlignment = Alignment.CenterHorizontally,
23 verticalArrangement = Arrangement.Center
24 ) {
25 Button(onClick = { openAlertDialog.value = true }) {
26 Text("Mostrar diálogo")
27 }
28
29 if (openAlertDialog.value) {
30 AlertDialogExample(
31 onDismissRequest = { openAlertDialog.value = false },
32 onConfirmation = {
33 openAlertDialog.value = false
34 println("Confirmación recibida")
35 },
36 dialogTitle = "Título del Diálogo",
37 dialogText = "Este es un ejemplo de cuadro de diálogo en Jetpack Compose.",
38 icon = Icons.Default.Info
39 )
40 }
41 }
42 }
43}
Observa como se dejan los callbacks preparados para que hagan las acciones según la situación.
2.12.3. Cuadro de diálogo personalizado
Ahora se verá como mostrar un cuadro de diálogo personalizado, añadiendo elementos más allá de texto.
1@Composable
2fun CustomDialog(onSave: (String) -> Unit) {
3 val abierto: MutableState<Boolean> = remember { mutableStateOf(false) }
4 var texto by remember { mutableStateOf("") }
5
6 Column(horizontalAlignment = Alignment.CenterHorizontally) {
7 Button(onClick = { abierto.value = true }) {
8 Text("Nuevo elemento")
9 }
10
11 if (abierto.value) {
12 AlertDialog(
13 onDismissRequest = { abierto.value = false },
14 title = { Text("Crear elemento") },
15 text = {
16 Column {
17 OutlinedTextField(
18 label = { Text("Introduce un nombre") },
19 value = texto,
20 onValueChange = { texto = it },
21 singleLine = true
22 )
23 }
24 },
25 confirmButton = {
26 TextButton(onClick = {
27 if (texto.isNotBlank()) {
28 onSave(texto)
29 abierto.value = false
30 texto = ""
31 }
32 }) {
33 Text("Guardar")
34 }
35 },
36 dismissButton = {
37 TextButton(onClick = {
38 abierto.value = false
39 texto = ""
40 }) {
41 Text(LocalContext.current.getString(R.string.cancel))
42 }
43 }
44 )
45 }
46 }
47}
Como puedes observar en este código, se ha optado por una estrategia diferente, el propio método se encarga de gestionar el estado de visibilidad del cuadro de diálogo y de mostrar el botón que desencadena la acción. Se propaga el dato recogido mediante el callback onSave
.
1CustomDialog(
2 onSave = { newItem ->
3 println("Nuevo elemento guardado: $newItem")
4 }
5)
Pero… según la documentación oficial “si quieres crear un diálogo más complejo, quizás con formularios y varios botones, debes usar Dialog con contenido personalizado” aquí
Si se adapta al uso de Dialog
, el método CustomDialog
podría quedar así:
1@Composable
2fun CustomDialog(onSave: (String) -> Unit) {
3 val abierto: MutableState<Boolean> = remember { mutableStateOf(false) }
4 var texto by remember { mutableStateOf("") }
5
6 Column(horizontalAlignment = Alignment.CenterHorizontally) {
7 Button(onClick = { abierto.value = true }) {
8 Text("Nuevo elemento")
9 }
10
11 if (abierto.value) {
12 Dialog(onDismissRequest = { abierto.value = false }) {
13 Column(
14 modifier = Modifier.padding(16.dp),
15 horizontalAlignment = Alignment.CenterHorizontally
16 ) {
17 Card(
18 modifier = Modifier.padding(16.dp),
19 shape = RoundedCornerShape(16.dp),
20 ) {
21 Column(
22 modifier = Modifier.wrapContentSize(),
23 verticalArrangement = Arrangement.Center,
24 horizontalAlignment = Alignment.CenterHorizontally,
25 ) {
26 Spacer(Modifier.height(16.dp))
27 Text("Crear nuevo elemento")
28 OutlinedTextField(
29 label = { Text("Introduce un nombre") },
30 value = texto,
31 onValueChange = { texto = it },
32 singleLine = true,
33 modifier = Modifier.fillMaxWidth(0.9f) // Ajusta el ancho del campo de texto (%)
34 )
35 Row(
36 modifier = Modifier.fillMaxWidth(),
37 horizontalArrangement = Arrangement.Center,
38 ) {
39 TextButton(
40 onClick = { abierto.value = false },
41 modifier = Modifier.padding(8.dp),
42 ) {
43 Text(LocalContext.current.getString(R.string.cancel))
44 }
45 TextButton(
46 onClick = {
47 if (texto.isNotBlank()) {
48 onSave(texto)
49 abierto.value = false
50 texto = ""
51 }
52 },
53 modifier = Modifier.padding(8.dp)
54 ) {
55 Text("Guardar")
56 }
57 }
58 }
59 }
60 }
61 }
62 }
63 }
64}
2.12.4. Selección de hora: TimePicker
El siguiente código muestra una forma sencilla de crear un cuadro de diálgo para la selección de una hora.
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun DialogoSeleccionHora() {
4 var mostrarDialogo by remember { mutableStateOf(false) }
5 val timePickerState = rememberTimePickerState() // Hora actual del sistema.
6 var horaSeleccionada by remember { mutableStateOf("") }
7
8 Column(horizontalAlignment = Alignment.CenterHorizontally) {
9 Button(onClick = { mostrarDialogo = true }) {
10 Text("Seleccionar hora")
11 }
12
13 Spacer(Modifier.height(8.dp))
14 Text(text = "Hora seleccionada: $horaSeleccionada")
15
16 if (mostrarDialogo) {
17 AlertDialog(
18 onDismissRequest = { mostrarDialogo = false },
19 confirmButton = {
20 TextButton(onClick = {
21 val h = timePickerState.hour.toString().padStart(2, '0')
22 val m = timePickerState.minute.toString().padStart(2, '0')
23 horaSeleccionada = "$h:$m"
24 mostrarDialogo = false
25 }) { Text("Aceptar") }
26 },
27 dismissButton = {
28 TextButton(onClick = { mostrarDialogo = false }) {
29 Text("Cancelar")
30 }
31 },
32 title = { Text("Selecciona la hora") },
33 text = { TimePicker(state = timePickerState) }
34 )
35 }
36 }
37}
2.12.5. Selección de fecha: DatePicker
Este caso es similar al anterior pero, en esta ocasión, para seleccionar una fecha.
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun DialogoSeleccionFecha() {
4 var mostrarDialogo by remember { mutableStateOf(false) }
5 val datePickerState = rememberDatePickerState() // Fecha actual del sistema.
6 var fechaSeleccionada by remember { mutableStateOf("") }
7
8 Column(horizontalAlignment = Alignment.CenterHorizontally) {
9 Button(onClick = { mostrarDialogo = true }) {
10 Text("Seleccionar fecha")
11 }
12
13 Spacer(Modifier.height(8.dp))
14 Text("Fecha seleccionada: $fechaSeleccionada")
15
16 if (mostrarDialogo) {
17 AlertDialog(
18 onDismissRequest = { mostrarDialogo = false },
19 confirmButton = {
20 TextButton(onClick = {
21 datePickerState.selectedDateMillis?.let { millis ->
22 val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
23 val fecha = Instant.ofEpochMilli(millis) // Convertir milisegundos a Instant.
24 .atZone(ZoneId.systemDefault()) // Convertir a zona horaria local.
25 .toLocalDate() // Obtener la fecha local.
26 fechaSeleccionada = formatter.format(fecha)
27 }
28 mostrarDialogo = false
29 }) { Text("Aceptar") }
30 },
31 dismissButton = {
32 TextButton(onClick = { mostrarDialogo = false }) {
33 Text("Cancelar")
34 }
35 },
36 title = { Text("Selecciona la fecha") },
37 text = { DatePicker(state = datePickerState) }
38 )
39 }
40 }
41}
2.12.6. Buenas prácicas
Recomendación |
Explicación |
Control con remember |
Utiliza variables de estado para controlar la visibilidad del diálogo |
Confirmación explícita |
Utiliza botones confirm/dismiss con acciones claras |
Diálogo no bloqueante |
Usa onDismissRequest para permitir al usuario cerrar tocando fuera |
Evita usarlo en recomposiciones frecuentes |
Solo muestra el diálogo cuando el estado lo indique |
Ejemplos prácticos
Fuentes
Subsecciones de Tema 2: Interfaz de usuario
Ejemplo práctico 1: Centrar un Text usando Modifier.layout
Objetivo
Aprende a crear un layout personalizado que centre un composable hijo (en este caso, un Text
) manualmente, sin usar Box
o Arrangement.Center
.
Estructura del Modifier.layout
1Modifier.layout { measurable, constraints ->
2 // Se mide el hijo con las restricciones.
3 val placeable = measurable.measure(constraints)
4
5 // Se calcula el tamaño total del padre.
6 val width = constraints.maxWidth
7 val height = constraints.maxHeight
8
9 // se calcula la posición centrada.
10 val x = (width - placeable.width) / 2
11 val y = (height - placeable.height) / 2
12
13 // Se devuelve el layout y se coloca el hijo.
14 layout(width, height) {
15 placeable.place(x, y)
16 }
17}
Ejemplo completo
Se crea el método composable:
1@Composable
2fun LayoutPersonalizadoDemo() {
3 Box(
4 modifier = Modifier
5 .wrapContentHeight() // Se ajusta al contenido
6 .background(Color(0xFFEFEFEF)) // Fondo gris claro
7 .centroManual() // Modificador personalizado
8 ) {
9 Text(
10 text = "Texto centrado con layout personalizado",
11 fontSize = 18.sp,
12 color = Color.Black
13 )
14 }
15}
A continuación, se crea el modificador personalizado centroManual()
:
1fun Modifier.centroManual(): Modifier = this.then(
2 Modifier.layout { measurable, constraints ->
3 val placeable = measurable.measure(constraints)
4
5 val width = constraints.maxWidth
6 val height = constraints.maxHeight
7
8 val x = (width - placeable.width) / 2
9 val y = (height - placeable.height) / 2
10
11 layout(width, height) {
12 placeable.place(x, y)
13 }
14 }
15)
Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:
1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaLayoutPersonalizado() {
4 LayoutPersonalizadoDemo()
5}
Ejemplo práctico 2: Limitar al 50% del espacio disponible usando Modifier.layout
Objetivo
Se creará un modificador personalizado llamado limitarAnchoAl50Porciento()
que limite el ancho del hijo al 50% del ancho máximo disponible. Además, colocará el hijo centrado horizontalmente dentro del espacio total y mantendrá la altura original del hijo.
Modificador personalizado
1fun Modifier.limitarAnchoAl50Porciento(): Modifier = this.then(
2 Modifier.layout { measurable, constraints ->
3 // Se calcula el 50% del ancho disponible.
4 val anchoDisponible = constraints.maxWidth
5 val anchoLimitado = anchoDisponible / 2
6
7 // Se crean nuevas restricciones con ancho máximo reducido.
8 val newConstraints = constraints.copy(maxWidth = anchoLimitado)
9
10 // Se mide el hijo con esas restricciones.
11 val placeable = measurable.measure(newConstraints)
12
13 // La altura del padre será la del hijo, ancho será el original.
14 layout(anchoDisponible, placeable.height) {
15 // Se centra horizontalmente
16 val x = (anchoDisponible - placeable.width) / 2
17 placeable.place(x, 0)
18 }
19 }
20)
A continuación, se creará el método composable:
1@Composable
2fun LayoutAnchoLimitadoDemo() {
3 Box(
4 modifier = Modifier
5 .fillMaxHeight()
6 .background(Color(0xFFEFEFEF))
7 .limitarAnchoAl50Porciento()
8 ) {
9 Text(
10 text = "Ancho limitado al 50%",
11 fontSize = 16.sp,
12 color = Color.Black,
13 modifier = Modifier
14 .background(Color.Yellow)
15 .padding(8.dp)
16 )
17 }
18}
Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:
1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaAnchoLimitado() {
4 LayoutAnchoLimitadoDemo()
5}
Ejemplo práctico 3: BoxWithConstraints adaptativo
Objetivo
Este ejemplo muestra un texto diferente según si el ancho del contenedor es mayor o menor a 300.dp
. Además, el fondo cambia de color para mayor visibilidad.
Composable EjemploBoxWithConstraints
1@SuppressLint("UnusedBoxWithConstraintsScope")
2@Composable
3fun EjemploBoxWithConstraints() {
4 BoxWithConstraints(
5 modifier = Modifier
6 .fillMaxWidth()
7 .height(200.dp)
8 .background(Color.LightGray)
9 ) {
10 val isPantallaGrande = maxWidth > 300.dp
11
12 Box(
13 modifier = Modifier
14 .fillMaxSize()
15 .background(if (isPantallaGrande) Color.Cyan else Color.Magenta),
16 contentAlignment = Alignment.Center
17 ) {
18 Text(
19 text = if (isPantallaGrande) "Pantalla ancha" else "Pantalla estrecha",
20 fontSize = 20.sp,
21 color = Color.White
22 )
23 }
24 }
25}
Para visualizar el ejemplo en la vista previa de Android Studio añade los siguientes métodos, ajustando los anchos de pantalla:
1@Preview(showBackground = true, widthDp = 400)
2@Composable
3fun VistaPreviaPantallaAncha() {
4 EjemploBoxWithConstraints()
5}
6
7@Preview(showBackground = true, widthDp = 250)
8@Composable
9fun VistaPreviaPantallaEstrecha() {
10 EjemploBoxWithConstraints()
11}
Ejemplo práctico 4: Barra de progreso circular personalizada
Objetivo
Este ejemplo muestra cómo crear una barra de progreso circular usando Canvas
.
Composable BarraProgresoCircular
1@Composable
2fun BarraProgresoCircular(progreso: Float) {
3 Canvas(modifier = Modifier.size(150.dp)) {
4 // Fondo del círculo (gris)
5 drawCircle(
6 color = Color.LightGray,
7 radius = size.minDimension / 2,
8 center = center,
9 style = Stroke(width = 20f)
10 )
11
12 // Progreso (azul)
13 drawArc(
14 color = Color.Blue,
15 startAngle = -90f,
16 sweepAngle = 360 * progreso,
17 useCenter = false,
18 style = Stroke(width = 20f, cap = StrokeCap.Round),
19 size = size
20 )
21 }
22}
Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente métodos:
1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaBarraProgreso() {
4 BarraProgresoCircular(progreso = 0.65f) // 65% de progreso
5}
Para dotar de movimiento a la barra de progreso, se añadirá el siguiente código en el método onCreate()
. Además, se podrá ver una breve introducción al uso de Scaffold
, Toolbar
y FloatingActionButton
.
1override fun onCreate(savedInstanceState: Bundle?) {
2 super.onCreate(savedInstanceState)
3 enableEdgeToEdge()
4 setContent {
5 Examplet02Theme {
6 var progreso by remember { mutableStateOf(0.0f) }
7
8 Scaffold(
9 modifier = Modifier.fillMaxSize(),
10 topBar = {
11 TopAppBar(
12 title = { Text("Scaffold con Canvas") }
13 )
14 },
15 floatingActionButton = {
16 FloatingActionButton(
17 onClick = {
18 progreso += 0.1f
19 if (progreso > 1.01f) progreso = 0f
20 }
21 ) {
22 Icon(Icons.Default.Refresh, contentDescription = "Incrementar Progreso")
23 }
24 }
25 ) { padding ->
26 Box(
27 modifier = Modifier
28 .fillMaxSize()
29 .padding(padding),
30 contentAlignment = Alignment.Center
31 ) {
32 BarraProgresoCircular(progreso = progreso)
33 }
34 }
35 }
36 }
37}
Para verla en acción deberás lanzar la aplicación contra un emulador o un dispositivo físico.

Objetivo
Este ejemplo crea un fondo decorativo con círculos y líneas, ideal para personalizar pantallas.
Composable FondoDecorativo
1@Composable
2fun FondoDecorativo(modifier: Modifier = Modifier) {
3 Canvas(modifier = modifier.fillMaxSize()) {
4 val ancho = size.width
5 val alto = size.height
6
7 // Fondo general
8 drawRect(Color(0xFFEFEFEF))
9
10 // Círculo azul en esquina superior izquierda
11 drawCircle(
12 color = Color.Blue,
13 radius = ancho / 4,
14 center = Offset(ancho / 4, alto / 4)
15 )
16
17 // Línea diagonal decorativa
18 drawLine(
19 color = Color.Magenta,
20 start = Offset(0f, alto),
21 end = Offset(ancho, 0f),
22 strokeWidth = 10f
23 )
24
25 // Pequeños círculos decorativos
26 for (i in 1..5) {
27 drawCircle(
28 color = Color.Green,
29 radius = 20f,
30 center = Offset(ancho * i / 6, alto * i / 6)
31 )
32 }
33 }
34}
Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:
1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaFondoDecorativo() {
4 Box(modifier = Modifier.size(300.dp, 300.dp)) {
5 FondoDecorativo()
6 Text(
7 text = "Contenido",
8 modifier = Modifier.align(Alignment.Center),
9 color = Color.Black,
10 fontSize = 18.sp
11 )
12 }
13}
Ejemplo práctico 6: Animación con graphicsLayer y Scaffold
Objetivo
Este ejemplo se creará un Scaffold
con TopAppBar
y FloatingActionButton
. Se creará un composable que anime su rotación y escala usando graphicsLayer
y un botón flotante que inicie o detenga la animación.
Composable EjemploAvanzadoGraphicsLayer
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun EjemploAvanzadoGraphicsLayer() {
4 var animar by remember { mutableStateOf(false) }
5 val rotacion by animateFloatAsState(
6 targetValue = if (animar) 360f else 0f,
7 animationSpec = tween(durationMillis = 2000, easing = LinearEasing)
8 )
9 val escala by animateFloatAsState(
10 targetValue = if (animar) 1.5f else 1f,
11 animationSpec = tween(durationMillis = 2000, easing = LinearEasing)
12 )
13
14 Scaffold(
15 topBar = {
16 TopAppBar(title = { Text("GraphicsLayer Avanzado") })
17 },
18 floatingActionButton = {
19 FloatingActionButton(onClick = { animar = !animar }) {
20 Icon(Icons.Default.PlayArrow, contentDescription = "Animar")
21 }
22 }
23 ) { padding ->
24 Box(
25 modifier = Modifier
26 .fillMaxSize()
27 .padding(padding),
28 contentAlignment = Alignment.Center
29 ) {
30 Box(
31 modifier = Modifier
32 .size(150.dp)
33 .graphicsLayer(
34 rotationZ = rotacion,
35 scaleX = escala,
36 scaleY = escala,
37 alpha = 0.8f,
38 shadowElevation = 16f
39 )
40 .background(Color(0xFF6200EE)),
41 contentAlignment = Alignment.Center
42 ) {
43 Text("Animado", color = Color.White, fontSize = 18.sp)
44 }
45 }
46 }
47}
Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:
1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaEjemploAvanzado() {
4 EjemploAvanzadoGraphicsLayer()
5}
En resumen, se coloca un Scaffold
con barra superior y un FAB, se crea un Box
central ocupando toda la pantalla que rota 360 grados y se escala a 1.5x cuando se pulsa el FAB. El estado animar
controla si se inicia o detiene la animación. Por último, se utiliza animateFloatAsState
para interpolar suavemente.
Ejemplo práctico 7: Uso básico de Scaffold
Objetivo
En este ejemplo se utiliza Material 3 para añadir un Scaffold
con TopAppBar
, BottomAppBar
, y FloatingActionButton
. Además, se utiliza un Snackbar
mediante SnackbarHostState
y cambio de contenido al pulsar el FAB, mostrando Snackbar
cuando el contador alcanza el máximo (5).
Composable ScaffoldMaterial3ConSnackbar
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun ScaffoldMaterial3ConSnackbar() {
4 val snackbarHostState = remember { SnackbarHostState() }
5 val scope = rememberCoroutineScope()
6 var contador by remember { mutableStateOf(0) }
7
8 Scaffold(
9 snackbarHost = { SnackbarHost(snackbarHostState) },
10 topBar = {
11 TopAppBar(
12 title = { Text("Mi App Simple M3") },
13 colors = topAppBarColors(
14 containerColor = MaterialTheme.colorScheme.primaryContainer,
15 titleContentColor = MaterialTheme.colorScheme.primary,
16 )
17 )
18 },
19 bottomBar = {
20 BottomAppBar {
21 IconButton(onClick = { /* Acción 1 */ }) {
22 Icon(Icons.Default.Home, contentDescription = "Home")
23 }
24 Spacer(Modifier.weight(1f))
25 IconButton(onClick = { /* Acción 2 */ }) {
26 Icon(Icons.Default.Favorite, contentDescription = "Favoritos")
27 }
28 }
29 },
30 floatingActionButton = {
31 ExtendedFloatingActionButton(
32 onClick = {
33 if (contador < 5) {
34 contador++
35 Log.d("ScaffoldM3", "Contador incrementado: $contador")
36 } else {
37 scope.launch {
38 snackbarHostState.showSnackbar(
39 "Conteo máximo alcanzado",
40 actionLabel = "Reiniciar",
41 duration = SnackbarDuration.Short
42 ).let { result ->
43 if (result == SnackbarResult.ActionPerformed) {
44 contador = 0
45 }
46 }
47 }
48 }
49 }
50 ) {
51 Text("Sumar")
52 }
53 },
54 floatingActionButtonPosition = FabPosition.End
55 ) { innerPadding ->
56 Box(
57 modifier = Modifier
58 .fillMaxSize()
59 .padding(innerPadding),
60 contentAlignment = Alignment.Center
61 ) {
62 Text(
63 text = "Conteo: $contador",
64 fontSize = 24.sp,
65 fontWeight = FontWeight.Bold
66 )
67 }
68 }
69}
Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:
1@Preview(showBackground = true)
2@Composable
3fun VistaPrevia() {
4 ScaffoldMaterial3ConSnackbar()
5}
En resumen, se utiliza remember { SnackbarHostState() }
para evitar que se cree una nueva en cada recomposición. El Scaffold
recibe el snackbarHost
, enlazado con el SnackbarHostState
creado.
El FAB incrementa contador
que, al llegar a 5, mostrará un Snackbar
con opción “Reiniciar” y resetea el contador si se pulsa la acción.
innerPadding
hace que el contenido central respete las barras del Scaffold
.
Ejemplo práctico 8: Menú básico en TopAppBar con devolución de selección vía callback
Objetivo
Este ejemplo trata de plantear una posible solución a problemas que pueden plantearse durante el desarrollo de aplicaciones móviles. La idea es crear un componente para montar una TopAppBar
con un menú, evaluando la selección del usuario mediante un único callback, comprobando la respuesta producida y actuando en consecuencia. Debes tener en cuenta que en Compose los métodos no devuelven valores, de ahí el uso de callbacks.
Recursos en string.xml
Tratará de evitarse lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.
1<resources>
2 <string name="app_name">ExampleT2_8</string>
3
4 <string name="txt_welcome">Selecciona una opción del menú</string>
5
6 <string name="txt_option_title">Más opciones</string>
7
8 <string name="txt_option_share">Compartir</string>
9 <string name="txt_option_save">Guardar</string>
10 <string name="txt_option_logout">Cerrar sesión</string>
11
12 <string name="txt_share">Has seleccionado la opción <b>Compartir</b>.</string>
13 <string name="txt_save">Has seleccionado la opción <b>Guardar</b>.</string>
14 <string name="txt_logout">Has seleccionado la opción <b>Cerrar sesión</b>.</string>
15</resources>
Sealed Class
Para simplificar el código, se creará la siguiente sealed class
en un fichero a parte, lo que permite reducir las evaluaciones para este caso.
1sealed class OpcionMenu {
2 object Compartir : OpcionMenu()
3 object Guardar : OpcionMenu()
4 object Logout : OpcionMenu()
5
6 override fun toString(): String {
7 return when (this) {
8 Compartir -> "Compartir"
9 Guardar -> "Guardar"
10 Logout -> "Cerrar sesión"
11 }
12 }
13}
Supón que quieres reutilizar este componente en más de una vista, para eso se creará este componente en un fichero separado, por ejemplo, Utils.kt
.
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun TopBarConMenu(
4 onOpcionSeleccionada: (OpcionMenu) -> Unit
5) {
6 var expanded by remember { mutableStateOf(false) }
7 val context = LocalContext.current
8
9 TopAppBar(
10 title = { Text("TopAppBar con Menú") },
11 colors = topAppBarColors(
12 containerColor = MaterialTheme.colorScheme.primaryContainer,
13 titleContentColor = MaterialTheme.colorScheme.primary,
14 ),
15 actions = {
16 IconButton(onClick = { expanded = true }) {
17 Icon(Icons.Default.MoreVert, contentDescription = context.getString(R.string.txt_option_title))
18 }
19 DropdownMenu(
20 expanded = expanded,
21 onDismissRequest = { expanded = false }
22 ) {
23 DropdownMenuItem(
24 text = { Text(context.getString(R.string.txt_option_share)) },
25 onClick = {
26 expanded = false
27 onOpcionSeleccionada(OpcionMenu.Compartir)
28 }
29 )
30 DropdownMenuItem(
31 text = { Text(context.getString(R.string.txt_option_save)) },
32 onClick = {
33 expanded = false
34 onOpcionSeleccionada(OpcionMenu.Guardar)
35 }
36 )
37 DropdownMenuItem(
38 text = { Text(context.getString(R.string.txt_option_logout)) },
39 onClick = {
40 expanded = false
41 onOpcionSeleccionada(OpcionMenu.Logout)
42 }
43 )
44 }
45 }
46 )
47}
Resultado de la MainActivity
Ahora, la actividad principal tendrá un aspecto más limpio al hacer uso de la sealed class y el componente creado en un fichero a parte.
1class MainActivity : ComponentActivity() {
2 override fun onCreate(savedInstanceState: Bundle?) {
3 super.onCreate(savedInstanceState)
4 enableEdgeToEdge()
5
6 setContent {
7 ExampleT2_8Theme {
8 PantallaPrincipal()
9 }
10 }
11 }
12}
13
14@Preview(showBackground = true)
15@Composable
16fun PantallaPrincipal() {
17 val context = LocalContext.current
18 var mensaje by remember { mutableStateOf(context.getString(R.string.txt_welcome)) }
19
20 Scaffold(
21 topBar = {
22 TopBarConMenu { opcion ->
23 mensaje = when (opcion) {
24 is OpcionMenu.Compartir -> context.getString(R.string.txt_share)
25 is OpcionMenu.Guardar -> context.getString(R.string.txt_save)
26 is OpcionMenu.Logout -> context.getString(R.string.txt_logout)
27 }
28 }
29 },
30 modifier = Modifier.fillMaxSize()
31 ) { innerPadding ->
32 Box(
33 modifier = Modifier
34 .padding(innerPadding)
35 .fillMaxSize(),
36 contentAlignment = Alignment.Center
37 ) {
38 Text(
39 text = mensaje,
40 fontSize = 18.sp
41 )
42 }
43 }
44}
El uso de este esquema permite el tipado seguro, evitando así errores de escritura en las cadenas, es escalable, está integrado con when
lo que permite una evaluación exhaustiva y permite la reutilización, ya que la acción se realizará en el when
, y no en el método encargado de montar el menú.
Puede ser más óptimo, por ejemplo, añadiendo propiedades como label
o icon
dentro de la sealed class.
Objetivo
Este ejemplo es una variante del anterior. Se sustituirá la TopAppBar
con un menú por una BottomAppBar
, evaluando la selección del usuario mediante un único callback, comprobando la respuesta recibida y actuando en consecuencia. Debes tener en cuenta que en Compose los métodos no devuelven valores, de ahí el uso de callbacks. Se mantendrá la misma estructura de sealed class
, añadiendo label
e icon
.
Recursos en string.xml
Como en la versión anterior, se tratará de evitar lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.
1<resources>
2 <string name="app_name">ExampleT2_8</string>
3
4 <string name="txt_welcome">Selecciona una opción del menú</string>
5
6 <string name="txt_option_title">Más opciones</string>
7
8 <string name="txt_option_share">Compartir</string>
9 <string name="txt_option_save">Guardar</string>
10 <string name="txt_option_logout">Cerrar sesión</string>
11
12 <string name="txt_share">Has seleccionado la opción <b>Compartir</b>.</string>
13 <string name="txt_save">Has seleccionado la opción <b>Guardar</b>.</string>
14 <string name="txt_logout">Has seleccionado la opción <b>Cerrar sesión</b>.</string>
15</resources>
Sealed Class
Para simplificar el código, se creará la siguiente sealed class
en un fichero a parte, lo que permite reducir las evaluaciones para este caso. Como se ha comentado, se añadirán dos nuevas propiedades a la clase label
e icon
.
1sealed class OpcionMenu(val label: String, val icon: ImageVector) {
2 object Compartir : OpcionMenu("Compartir", Icons.Default.Share)
3 object Guardar : OpcionMenu("Guardar", Icons.Default.Add)
4 object Logout : OpcionMenu("Cerrar sesión", Icons.AutoMirrored.Filled.ExitToApp)
5
6 companion object {
7 val todas = listOf(Compartir, Guardar, Logout)
8 }
9}
Supón que quieres reutilizar este componente en más de una vista, para eso se creará este componente en un fichero separado, por ejemplo, Utils.kt
. Esta versión está mejorada con respecto al ejemplo anterior, se crea un bucle para mostrar las opciones que se vayan añadiendo en la sealed class
.
1@Composable
2fun BottomAppBarConMenu(
3 onOpcionSeleccionada: (OpcionMenu) -> Unit
4) {
5 var expanded by remember { mutableStateOf(false) }
6
7 BottomAppBar(
8 actions = {
9 IconButton(onClick = { expanded = !expanded }) {
10 Icon(Icons.Default.MoreVert, contentDescription = "Menú inferior")
11 }
12
13 DropdownMenu(
14 expanded = expanded,
15 onDismissRequest = { expanded = false }
16 ) {
17 OpcionMenu.todas.forEach { opcion ->
18 DropdownMenuItem(
19 text = { Text(opcion.label) },
20 leadingIcon = { Icon(opcion.icon, contentDescription = null) },
21 onClick = {
22 expanded = false
23 onOpcionSeleccionada(opcion)
24 }
25 )
26 }
27 }
28 }
29 )
30}
Resultado de la MainActivity
Ahora, la actividad principal tendrá un aspecto más limpio al hacer uso de la sealed class y el componente creado en un fichero a parte.
1class MainActivity : ComponentActivity() {
2 override fun onCreate(savedInstanceState: Bundle?) {
3 super.onCreate(savedInstanceState)
4 enableEdgeToEdge()
5 setContent {
6 ExampleT2_9Theme {
7 PantallaPrincipal()
8 }
9 }
10 }
11}
12
13@Preview(showBackground = true)
14@Composable
15fun PantallaPrincipal() {
16 val context = LocalContext.current
17 var mensaje by remember { mutableStateOf(context.getString(R.string.txt_welcome)) }
18
19 Scaffold(
20 bottomBar = {
21 BottomAppBarConMenu { opcion ->
22 mensaje = when (opcion) {
23 is OpcionMenu.Compartir -> context.getString(R.string.txt_share)
24 is OpcionMenu.Guardar -> context.getString(R.string.txt_save)
25 is OpcionMenu.Logout -> context.getString(R.string.txt_logout)
26 }
27 }
28 },
29 modifier = Modifier.fillMaxSize()
30 ) { innerPadding ->
31 Box(
32 modifier = Modifier
33 .fillMaxSize()
34 .padding(innerPadding),
35 contentAlignment = Alignment.Center
36 ) {
37 Text(mensaje)
38 }
39 }
40}
Como puedes ver, BottomAppBar
permite el uso de actions
igual que la TopAppBar
. También es posible implementar este menú utilizando la sección floatingActionButton
y utilizando FloatingActionButton
, y se puede combinar ambas barras (topBar
y bottomBar
) en el mismo Scaffold
.
Ejemplo práctico 10: Login en un cuadro de diálgo
Objetivo
Este ejemplo permite crear un AlertDialog
personalizado para solicitar usuario y contraseña.
Recursos en string.xml
Como en la versión anterior, se tratará de evitar lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.
1<resources>
2 <string name="app_name">ExampleT2_10</string>
3
4 <string name="txt_title">Login</string>
5 <string name="txt_user">Usuario</string>
6 <string name="txt_password">Contraseña</string>
7
8 <string name="txt_login_error">Credenciales incorrectas</string>
9 <string name="txt_login_ok">Credenciales correctas</string>
10</resources>
Compose LoginDialog
Se creará el siguiente compose para mostrar el cuadro de diálogo personalizado, en esta ocasión se utiliza AlertDialog
ya que la personalización es mínima y simplifica el código, pero para los cuadros de diálogo personalizados se recomienda el uso de Dialog
.
1@Composable
2fun LoginDialog(onLogin: (String, String) -> Unit = { _, _ -> }) {
3 val ctxt = LocalContext.current
4 val openDialog = remember { mutableStateOf(false) }
5 var user by remember { mutableStateOf("") }
6 var pass by remember { mutableStateOf("") }
7
8 Box(
9 modifier = Modifier.fillMaxSize(),
10 contentAlignment = Alignment.Center
11 ) {
12 Button(onClick = { openDialog.value = true }) {
13 Text(text = ctxt.getString(R.string.txt_title))
14 }
15
16 if (openDialog.value) {
17 AlertDialog(
18 onDismissRequest = { openDialog.value = true }, // Se mantiene el diálogo abierto.
19 title = { Text(text = ctxt.getString(R.string.txt_title)) },
20 text = {
21 Column {
22 OutlinedTextField(
23 value = user,
24 onValueChange = { user = it },
25 singleLine = true,
26 label = { Text(ctxt.getString(R.string.txt_user)) }
27 )
28 OutlinedTextField(
29 value = pass,
30 onValueChange = { pass = it },
31 singleLine = true,
32 label = { Text(ctxt.getString(R.string.txt_password)) },
33 visualTransformation = PasswordVisualTransformation()
34 )
35 }
36 },
37 confirmButton = {
38 TextButton(onClick = {
39 if (user.isNotBlank() && pass.isNotBlank()) {
40 onLogin(user, pass)
41 openDialog.value = false
42 // Limpiar los campos después del inicio de sesión.
43 user = ""
44 pass = ""
45 }
46 }) {
47 Text(ctxt.getString(android.R.string.ok))
48 }
49 },
50 dismissButton = {
51 TextButton(onClick = {
52 openDialog.value = false
53 user = ""
54 pass = ""
55 }) {
56 Text(ctxt.getString(android.R.string.cancel))
57 }
58 }
59 )
60 }
61 }
62}
Observa el uso de visualTransformation
en el OutlinedTextField
, este permite la ocultación del password.
Compose MainScreen
Siguiendo con la reutilización y actualización del estado, se creará el siguiente composable para mostrar el botón de login o el mensaje de login correcto.
1@Composable
2fun MainScreen() {
3 var isLoggedIn by remember { mutableStateOf(false) }
4 val ctxt = LocalContext.current
5
6 if (!isLoggedIn) {
7 // Se muestra el diálogo de inicio de sesión
8 LoginDialog(
9 onLogin = { user, pass ->
10 // Aquí se maneja la lógica de inicio de sesión
11 // Por ejemplo, verificar las credenciales
12 if (user == "admin" && pass == "1234") {
13 isLoggedIn = true // Simulación de inicio de sesión exitoso
14 println("Inicio de sesión correcto. Usuario: $user, Contraseña: $pass")
15 } else {
16 // Se muestra un mensaje de error o manejar el fallo de inicio de sesión
17 Toast.makeText(
18 ctxt,
19 ctxt.getString(R.string.txt_login_error),
20 Toast.LENGTH_SHORT
21 ).show()
22 }
23 }
24 )
25 } else {
26 // Contenido principal de la aplicación
27 Text(text = ctxt.getString(R.string.txt_login_ok))
28 }
29}
Resultado de la MainActivity
Ahora, la actividad principal tendrá el siguiente aspecto.
1class MainActivity : ComponentActivity() {
2 @OptIn(ExperimentalMaterial3Api::class)
3 override fun onCreate(savedInstanceState: Bundle?) {
4 super.onCreate(savedInstanceState)
5 enableEdgeToEdge()
6 setContent {
7 ExampleT2_10Theme {
8 Scaffold(
9 topBar = { TopAppBar(title = { Text(getString(R.string.app_name)) }) }
10 ) { innerPadding ->
11 Box(
12 modifier = Modifier
13 .fillMaxSize()
14 .padding(innerPadding),
15 contentAlignment = Alignment.Center
16 ) {
17 MainScreen()
18 }
19 }
20 }
21 }
22 }
23}
Tema 3: Intents y permisos
Objetivos de este tema
- Comprender las diferentes estrategias de navegación en aplicaciones Android.
- Dominar el uso de la clase
Intent
para la comunicación entre componentes.
- Aplicar técnicas modernas para el envío de datos entre actividades y la recepción de resultados.
- Gestionar adecuadamente los permisos peligrosos en Android.
- Gestionar la navegación con
ViewModel
en el contexto de MVVM y Clean Architecture.
- Integrar el patrón
ViewModel
en la gestión de la navegación y el estado de los permisos.
- Comparar cuándo elegir múltiples actividades o una sola actividad con varias pantallas.
3.1. Creación y navegación entre actividades
Para crear una Activity
nueva en el modelo de vistas se utiliza una clase conocida como Intent
, esto pueden ser de dos formas:
- Explícitos, indicarán que deben lanzar exactamente, su uso típico es ejecutar diferentes componentes internos de una aplicación. Por ejemplo, una actividad (ventana nueva).
- Implícitos, se utilizan para lanzar tareas abstractas, del tipo “quiero hacer una llamada” o “quiero hacer una foto”. Estas peticiones se resuelven en tiempo de ejecución, por lo que el sistema buscará los componentes registrados para la tarea pedida, si encontrase varias, el sistema preguntará al usuario que componente prefiere.
3.1.1. Crear nuevas Activity
Para crear una Activity
es necesrio extender ComponentActivity
y usar startActivity(Intent(this, OtraActivity::class.java))
. Este sistema se utiliza para lanzar nuevas actividades en el sistema de vistas, y sería un Intent explícito_.
3.1.2. Enviar datos con Intent
Para añadir datos a la llamada se usará intent.putExtra("clave", valor)
, y se recibirá en OtraActivity
utilizando intent.getXXXExtra("clave")
. Como en la creación, se utiliza para lanzar nuevas actividades en el sistema de vistas.
3.1..3. Recibir resultados
Para recuperar datos de OtraActivity
se recomienda utilizar la API moderna, creando el siguiente callback, sustituyendo la versión anterior que utilizaba onActivityResult()
. A este método también se le conoce como Intent por contrato.
1val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
2 if (result.resultCode == Activity.RESULT_OK) { /* … */ }
3}
4launcher.launch(Intent(...))
3.2. Gestión de permisos
Cuando se quiere lanzar una tarea que no es propia de la app se tendrá que tratar con otro tema, los permisos. El tratamiento de los permisos en Android cambió a partir de la API 23, hasta entonces, se concedían durante la instalación. Ahora, debe concederse de manera explícita aquellos considerados peligrosos, ya no se pide permiso durante el proceso de instalación sino en tiempo de ejecución.
A raíz de este cambio se produce una clasificación de los permisos, básicamente se distinguirán tres tipos de permisos
según sea su nivel de peligrosidad.
- Permisos normales: estos se utilizan cuando la aplicación necesita acceder a recursos o servicios fuera del ámbito de la app, donde no existe riesgo para la privacidad del usuario o para el funcionamiento de otras aplicaciones, por ejemplo, cambiar el uso horario.
Si se declara en el manifest de la aplicación uno de estos permisos, el sistema otorgará automáticamente permiso para su uso durante la instalación. Además de no preguntarse al usuario por ellos, estos no podrán revocarlos.
- Permisos de firma: estos son concedidos durante la instalación de la app, pero sólo cuando la aplicación que intenta utilizar el permiso está firmada por el mismo certificado que la aplicación que define el permiso. Estos permisos no se suelen utilizar con aplicaciones de terceros, es decir, se utilizan entre aplicaciones del mismo desarrollador.
- Permisos peligrosos: estos permisos involucran áreas potencialmente peligrosas, como son la privacidad del usuario, la información almacenada por los usuarios o la interacción con otras aplicaciones. Si se declara la necesidad de uso de uno de estos permisos, se necesitará el consentimiento explícito del usuario, y se hará en tiempo de ejecución. Hasta que no se conceda el permiso, la app no podrá hacer uso de esa funcionalidad. Por
ejemplo, acceder a los contactos.
Puedes encontrar todos los permisos que se pueden utilizar en la documentación de Google para Android. El valor que necesites añadir al manifest lo encontrarás en Constant Value, y Protection level indica el tipo de permiso que es.
INTERNET
public static final String INTERNET
Allows applications to open network sockets.
Protection level: normal
Constant Value: "android.permission.INTERNET"
Los permisos normales no requieren de una solicitud al usuario para poder funcionar, es por eso que se comenzarán por los permisos peligrosos, concretamente, uno de los más habituales, el uso de la cámara de fotos del dispositivo. Según la documentación de Google, cuando uno se plantea la gestión de permisos debe plantearse el siguiente flujo de trabajo para una correcta gestión.

Como estamos introduciendo el uso de Jetpack Compose, una de las cosas que cambia con respecto al sistema de vistas es la gestión de permisos, para ello se hará una primera aproximación a ViewModel
.
3.2.1. ¿Qué es un ViewModel?
Un ViewModel
es un componente de Architecture Components de Android Jetpack que permite almacenar y gestionar datos relacionados con la UI, de forma que sobreviven a cambios de configuración (como rotaciones de pantalla). Sus principales características son:
- Separar la lógica de negocio de la UI, manteniendo los Composables atómicos y centrados en la presentación.
- Mantener la consistencia del estado tras representaciones de activities o fragments.
- Diseñado para integrarse fácilmente con librerías como
Hilt
, Navigation Compose
y funciones de flujo de datos como StateFlow
o LiveData
.
En Jetpack Compose, los ViewModel
se crearán e inyectarán en Composables utilizando las funciones viewModel()
o hiltViewModel()
.
3.2.2. Integrar permisos con ViewModel
Se creará una nueva clase (PermissionHandlerViewModel
) que extenderá (heredará) de ViewModel
y centralizará la lógica para solicitar y comprobar el estado del permiso.
1class PermissionHandlerViewModel : ViewModel() {
2 data class PermissionUiState(
3 val granted: Boolean = false,
4 val showRationale: Boolean = false,
5 val permanentlyDenied: Boolean = false
6 )
7
8 // MutableStateFlow to hold the UI state, we use backing property.
9 private val _uiState = MutableStateFlow(PermissionUiState())
10 val uiState: StateFlow<PermissionUiState> = _uiState.asStateFlow()
11
12 // Function to update the UI state based on permission results.
13 fun onPermissionResult(granted: Boolean, shouldShowRationale: Boolean) {
14 _uiState.update {
15 it.copy(
16 granted = granted,
17 showRationale = !granted && shouldShowRationale,
18 permanentlyDenied = !granted && !shouldShowRationale
19 )
20 }
21 }
22}
El método onPermissionResult
actualizará el estado según la respuesta del usuario:
- granted: permiso concedido.
- showRationale: denegado con posible explicación.
- permanentlyDenied: denegación del permiso sin posibilidad de volver a preguntar.
El uso de MutableStateFlow
en el ViewModel
es para representar un estado que pueda sobrevivir a cambios de configuración, integrarse con flujos de datos y mantenerse testable y encapsulado. Se expone como StateFlow
para su consumo externo, esta técnica se conoce como backing. En Compose, se usa collectAsState()
para convertirlo en un estado observable y disparar la recomposición.
Ahora se añadirá al Manifest
el permiso para poder utilizar la cámara de fotos del dispositivo.
1<uses-permission android:name="android.permission.CAMERA" />
2<uses-feature android:name="android.hardware.camera" android:required="true" />
Para este tipo de acciones, es necesario establecer en el Manifest
lo que se conoce como queries
, estas permiten indicar al sistema operativo que la aplicación va ha necesitar una aplicación de terceros. La siguiente querie se utiliza para indicar que la aplicación va a necesitar el uso de la cámara de fotos.
1<queries>
2 <intent>
3 <action android:name="android.media.action.IMAGE_CAPTURE" />
4 </intent>
5</queries>
A continuación, se creará el siguiente Composable para crear la pantalla principal, esto es meramente estético.
1@OptIn(ExperimentalMaterial3Api::class)
2@Preview(showBackground = true)
3@Composable
4fun MainScreen() {
5 val ctxt = LocalContext.current
6
7 Scaffold(
8 topBar = {
9 TopAppBar(
10 title = { Text(ctxt.getString(R.string.app_name)) },
11 colors = topAppBarColors(
12 containerColor = MaterialTheme.colorScheme.primaryContainer,
13 titleContentColor = MaterialTheme.colorScheme.primary
14 )
15 )
16 }
17 ) { innerPadding ->
18 Column(
19 modifier = Modifier
20 .padding(innerPadding)
21 .fillMaxWidth()
22 ) {
23 OpenCamera()
24 }
25 }
26}
Ahora se creará el método OpenCamera()
que será el encargado de gestionar los permisos y mostrar la UI según la respuesta del usuario.
1@Composable
2fun OpenCamera(viewModel: PermissionHandlerViewModel = viewModel()) {
3 val permissionState = viewModel.uiState.collectAsState() // Obtiene el estado del permiso desde el ViewModel.
4 val ctxt = LocalContext.current
5 // Este callback se usa para solicitar el permiso de cámara.
6 val requestPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
7 viewModel.onPermissionResult(
8 granted, ActivityCompat.shouldShowRequestPermissionRationale(
9 ctxt as Activity, Manifest.permission.CAMERA
10 )
11 )
12 }
13
14 // Observamos el estado del permiso y actuamos en consecuencia.
15 LaunchedEffect(permissionState) {
16 when {
17 permissionState.value.granted -> {
18 // Aquí abrimos la cámara; por simplicidad indicamos con un log
19 Log.d("CameraPermission", "Acceso a cámara concedido")
20 // Podrías lanzar una navegación o mostrar vista de cámara
21 }
22
23 permissionState.value.showRationale -> {
24 // Mostrar diálogo explicativo
25 }
26
27 permissionState.value.permanentlyDenied -> {
28 // Mostrar diálogo con opción a abrir ajustes
29 }
30
31 else -> {
32 // Primer lanzamiento: solicitamos el permiso
33 requestPermission.launch(Manifest.permission.CAMERA)
34 }
35 }
36 }
37
38 // Aquí se muestra la UI dependiendo del estado del permiso.
39 when {
40 permissionState.value.granted -> {
41 Text("Pulsa el botón para abrir un intent")
42 Button(
43 onClick = {
44 Log.d("DEBUG", "Botón pulsado")
45
46 val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
47 if (cameraIntent.resolveActivity(ctxt.packageManager) != null)
48 ctxt.startActivity(cameraIntent)
49 else Log.e("DEBUG", "No hay aplicación que pueda manejar la cámara")
50 },
51 modifier = Modifier.padding(8.dp).fillMaxWidth()
52 ) {
53 Text(text = "Abrir la cámara")
54 }
55 }
56
57 permissionState.value.showRationale -> {
58 Text("Se necesita acceso a la cámara de fotos")
59 Toast.makeText(
60 ctxt,
61 "Es necesario tener acceso a la cámara de fotos",
62 Toast.LENGTH_LONG
63 ).show()
64 }
65
66 permissionState.value.permanentlyDenied -> {
67 Text("Permiso denegado permanentemente")
68 Button(
69 onClick = { // Se abren los ajustes de la aplicación para que el usuario pueda conceder el permiso manualmente.
70 ctxt.startActivity(
71 Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
72 data = Uri.fromParts("package", ctxt.packageName, null)
73 }
74 )
75 }, modifier = Modifier.padding(8.dp).fillMaxWidth()
76 ) {
77 Text("Abrir ajustes")
78 }
79 }
80
81 else -> {
82 Text("Solicitando permiso para acceder a la cámara")
83 // Aquí podrías mostrar un diálogo o una UI que explique por qué se necesita el permiso
84 }
85 }
86}
Deberás añadir la siguiente dependencia para simplificar la creación y uso de ViewModel
al build.gradle.kts (Module :app)
. Recuerda sincronizar el proyecto para que surta efecto.
1implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1")
- Como habrás observado,
ViewModel
centraliza todo el estado de los permisos (concedido, con justificación, denegado permanentemente).
- El Composable observa ese estado con
collectAsState()
y lanza acciones según el estado:
- Solicitar permiso.
- Mostrar explicaciones.
- Abrir ajustes del sistema si hay denegación permanente.
- Ejecutar acceso a la cámara cuando esté concedido.
viewModel()
es la forma recomendada en Compose para instanciar ViewModels
vinculados al ciclo de vida (debes incluir la biblioteca).
- No necesitas hacer nada en el
Activity
ni pasar parámetros adicionales.
- Asegura la consistencia del estado y facilitando además la integración con el patrón MVVM y clean architecture.
3.3. Intents implícitos
Pero, para abrir los Intents implícitos, se sigue utilizando la clase Intent
, a continuación, se muestran algunos ejemplos sencillos, empezando por aquellos que no requieren permiso del usuario.
3.3.1. Abrir una URL en el navegador del dispositivo
En este es necesario establecer en el Manifest
la queries
para indicar que la aplicación va a necesitar un navegador web.
1<?xml version="1.0" encoding="utf-8"?>
2<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
4 <!-- Comprueba que existe un navegador en el sistema -->
5 <queries>
6 <intent>
7 <action android:name="android.intent.action.VIEW" />
8 <category android:name="android.intent.category.BROWSABLE" />
9 <data android:scheme="https" />
10 </intent>
11 </queries>
12
13 <application...></application>
14</manifest>
El código para lanzar el Intent podría ser como el que se muestra a continuación.
1Button(
2 onClick = {
3 Log.d("DEBUG", "Botón pulsado")
4
5 // Intent para abrir un navegador web
6 Intent(Intent.ACTION_VIEW, "https://www.javiercarrasco.es".toUri()).apply {
7 if (this.resolveActivity(ctxt.packageManager) != null)
8 ctxt.startActivity(this)
9 else Log.d("DEBUG", "Hay un problema para encontrar un navegador.")
10 }
11 },
12 modifier = Modifier
13 .padding(8f.dp)
14 .fillMaxWidth()
15) {
16 Text("Abrir navegador")
17}
Puedes refactorizar el código y llevarte el código para crear el Intent
en un método a parte, al que se le pase el contexto y la URL que quieras abrir.
1fun openWebPage(ctxt: Context, url: String) {
2 // Intent para abrir un navegador web
3 Intent(Intent.ACTION_VIEW, url.toUri()).apply {
4 addCategory(Intent.CATEGORY_BROWSABLE) // Añade categoría para navegadores.
5 flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea.
6 if (this.resolveActivity(ctxt.packageManager) != null)
7 ctxt.startActivity(this)
8 else Log.d("DEBUG", "Hay un problema para encontrar un navegador.")
9 }
10}
3.3.2. Marcar un número de teléfono
Este no necesita crear una query
, se entiende que el dispositivo está preparado, y no necesita solicitar al usuario permiso explícito.
1fun openDialer(ctxt: Context, phoneNumber: String) {
2 // Intent para abrir la aplicación de teléfono
3 Intent(Intent.ACTION_DIAL, "tel:$phoneNumber".toUri()).apply {
4 addCategory(Intent.CATEGORY_DEFAULT) // Añade categoría por defecto
5 flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea
6 ctxt.startActivity(this)
7 }
8}
3.3.3. Abrir una aplicación de mapas
Tampoco requiere query
ni permiso específico ya que no se está utilizando geolocalización, para lo que sí sería necesario.
1fun openMap(ctxt: Context, geo: String) { // geo: "geo:0,0?q=Alicante"
2 // Intent para abrir la aplicación de teléfono
3 Intent(Intent.ACTION_VIEW, geo.toUri()).apply {
4 addCategory(Intent.CATEGORY_DEFAULT) // Añade categoría por defecto
5 flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea
6 ctxt.startActivity(this)
7 }
8}
3.3.4. Escribir un correo electrónico
En primer lugar se creará la siguiente query
en el Manifest
.
1<!-- Comprueba que existe una aplicación de correo electrónico -->
2<queries>
3 <intent>
4 <action android:name="android.intent.action.SENDTO" />
5 <category android:name="android.intent.category.DEFAULT" />
6 <data android:scheme="mailto" />
7 </intent>
8</queries>
Un posible método para componer un correo podría ser como el siguiente.
1fun composeMail(ctxt: Context, email: String, subject: String, body: String) {
2 // Intent para enviar un correo electrónico
3 Intent(Intent.ACTION_SENDTO).apply {
4 data = "mailto:".toUri() // Asegura que solo se manejen aplicaciones de correo
5 putExtra(Intent.EXTRA_EMAIL, arrayOf(email)) // Destinatario del correo
6 // putExtra(Intent.EXTRA_CC, arrayOf(emailsCC)) // Destinatarios en copia (opcional)
7 putExtra(Intent.EXTRA_SUBJECT, subject)
8 putExtra(Intent.EXTRA_TEXT, body)
9
10 addCategory(Intent.CATEGORY_DEFAULT) // Añade categoría por defecto
11 flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea
12
13 if (this.resolveActivity(ctxt.packageManager) != null)
14 ctxt.startActivity(Intent.createChooser(this, "Enviar correo..."))
15 else Log.d("DEBUG", "Hay un problema para enviar el correo electrónico.")
16 }
17}
3.3.5. Crear una alarma
El siguiente ejemplo necesita establecer el permiso correspondiente para poder crear una alarma en el despertador, en el manifest deberás añadir la siguiente línea. Este permiso está catalogado como normal, por tanto no se necesita pedir permiso al usuario.
1<uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
También habrá que añadir la query
para buscar el tipo de aplicación necesaria.
1<!-- Comprueba que existe una aplicación para establecer alarmas -->
2<queries>
3 <intent>
4 <action android:name="android.intent.action.SET_ALARM" />
5 <category android:name="android.intent.category.DEFAULT" />
6 </intent>
7</queries>
Un posible método para establecer una alarma en la aplicación de reloj podría ser el siguiente.
1fun setAlarm(ctxt: Context, mensaje: String, hora: Int, minuto: Int) {
2 Log.d("SetAlarm", "Estableciendo alarma: $mensaje a las $hora:$minuto")
3
4 Intent(AlarmClock.ACTION_SET_ALARM).apply {
5 putExtra(AlarmClock.EXTRA_MESSAGE, mensaje)
6 putExtra(AlarmClock.EXTRA_HOUR, hora)
7 putExtra(AlarmClock.EXTRA_MINUTES, minuto)
8
9 if (this.resolveActivity(ctxt.packageManager) != null) {
10 ctxt.startActivity(this)
11 } else {
12 Log.d("DEBUG", "Hay un problema para establecer la alarma.")
13 Toast.makeText(
14 ctxt,
15 "No se pudo establecer la alarma, comprueba que tienes una aplicación de reloj instalada.",
16 Toast.LENGTH_LONG
17 ).show()
18 }
19 }
20}
A continuación, se muestra el uso de otro Intent
que sí requieren permiso del usuario, haciendo uso de la clase vista en el punto anterior. Recuerda añadir la dependencia "androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1"
al Gradle.
3.3.6. Realizar una llamada telefónica
El siguiente Intent
, a diferencia del anterior, sí requiere permiso expreso por parte del usuario, ya que se va a producir una acción considerada peligrosa.
En primer lugar habrá que indicar en el Manifest
el uso del permiso en cuestión, y la necesidad del componente hardware necesario para realizar la acción.
1<uses-permission android:name="android.permission.CALL_PHONE" />
2<uses-feature android:name="android.hardware.telephony" android:required="false" />
La adaptación del método para realizar la llamada, controlando el estado de los permisos podría quedar como se muestra a continuación.
1@Composable
2fun CallPhone(phoneNumber: String, viewModel: PermissionHandlerViewModel = viewModel()) {
3 val permissionState =
4 viewModel.uiState.collectAsState() // Obtiene el estado del permiso desde el ViewModel.
5 val ctxt = LocalContext.current
6 // Este callback se usa para solicitar el permiso de cámara.
7 val requestPermission =
8 rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
9 viewModel.onPermissionResult(
10 granted, ActivityCompat.shouldShowRequestPermissionRationale(
11 ctxt as Activity, Manifest.permission.CALL_PHONE
12 )
13 )
14 }
15
16 // Se observa el estado del permiso y actuamos en consecuencia.
17 LaunchedEffect(permissionState) {
18 when {
19 permissionState.value.granted -> {
20 // Aquí abrimos la cámara; por simplicidad indicamos con un log
21 Log.d("CallPermission", "Acceso a llamar concedido")
22 }
23
24 else -> {
25 // Primer lanzamiento: solicitamos el permiso
26 requestPermission.launch(Manifest.permission.CALL_PHONE)
27 }
28 }
29 }
30
31 // Aquí se muestra la UI dependiendo del estado del permiso.
32 when {
33 permissionState.value.granted -> {
34 Text("Pulsa el botón para abrir un intent")
35 Button(
36 onClick = {
37 Log.d("DEBUG", "Botón pulsado")
38
39 // Intent para realizar una llamada telefónica
40 Intent(Intent.ACTION_CALL, "tel:$phoneNumber".toUri()).apply {
41
42 addCategory(Intent.CATEGORY_DEFAULT) // Añade categoría por defecto
43 flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea
44
45 // Nota: ACTION_CALL requiere el permiso CALL_PHONE en el manifiesto
46 if (this.resolveActivity(ctxt.packageManager) != null)
47 ctxt.startActivity(this)
48 else Log.d("DEBUG", "Hay un problema para realizar la llamada.")
49 }
50 },
51 modifier = Modifier.padding(8.dp).fillMaxWidth()
52 ) {
53 Text(text = "Realizar llamada telefónica")
54 }
55 }
56
57 permissionState.value.showRationale -> {
58 Text("Se necesita acceso para realizar llamadas telefónicas")
59 // Solicitar nuevamente el permiso.
60 Button(
61 onClick = { // Se solicita el permiso de llamada telefónica.
62 requestPermission.launch(Manifest.permission.CALL_PHONE)
63 },
64 modifier = Modifier.padding(8.dp).fillMaxWidth(),
65 colors = ButtonDefaults.buttonColors(containerColor = Color.Red)
66 ) {
67 Text("Solicitar permiso")
68 }
69
70 Toast.makeText(
71 ctxt,
72 "Es necesario tener acceso para realizar llamadas telefónicas",
73 Toast.LENGTH_LONG
74 ).show()
75 }
76
77 permissionState.value.permanentlyDenied -> {
78 Text("Permiso denegado permanentemente")
79 Button(
80 onClick = { // Se abren los ajustes de la aplicación para que el usuario pueda conceder el permiso manualmente.
81 ctxt.startActivity(
82 Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
83 data = Uri.fromParts("package", ctxt.packageName, null)
84 }
85 )
86 }, modifier = Modifier.padding(8.dp).fillMaxWidth()
87 ) {
88 Text("Abrir ajustes")
89 }
90 }
91
92 else -> {
93 Text("Solicitando permiso para realizar llamadas telefónicas")
94 // Aquí podrías mostrar un diálogo o una UI que explique por qué se necesita el permiso
95 }
96 }
97}
Ejemplos prácticos
Ejemplo práctico 11 Recuperar la imagen capturada desde la cámara de fotos
3.4. Creación y navegación entre actividades
Jetpack Compose apuesta por el uso de una sola Activity
con múltiples pantallas (Composable), pero, en algunas situaciones es útil o necesario utilizar varias actividades: interoperabilidad con vistas heredadas, flujos aislados o necesidades de integración específicas.
3.4.1. Crear nuevas Activity
En Android, una actividad representa una pantalla completa. Crear una nueva Activity
en un proyecto con Jetpack Compose es algo más sencillo que hacerlo en el sistema basado en vistas, ya que no tienes que crear el XML que la represente. El primer paso será crear una nueva clase. Puedes utilizar la opción File > New > Compose > Empty Activity. Tras eliminar algo de boilerplate code, podría quedar así.
1class SecondActivity : ComponentActivity() {
2 override fun onCreate(savedInstanceState: Bundle?) {
3 super.onCreate(savedInstanceState)
4 enableEdgeToEdge()
5 setContent {
6 Text(
7 "Second Activity",
8 modifier = Modifier.padding(32.dp)
9 )
10 }
11 }
12}
Esta nueva actividad quedará registrada en el Manifest.
1<activity
2 android:name=".SecondActivity"
3 android:exported="false"
4 android:label="@string/title_activity_second"
5 android:theme="@style/Theme.DocumentationT3_4" />
3.4.2. Enviar datos con Intent
Una vez preparada una actividad principal sencilla con un botón que traslade al usuario a la segunda actividad (SecondActivity.kt
)…
1class MainActivity : ComponentActivity() {
2 override fun onCreate(savedInstanceState: Bundle?) {
3 super.onCreate(savedInstanceState)
4 enableEdgeToEdge()
5 setContent {
6 DocumentationT3_4Theme {
7 MainScreen()
8 }
9 }
10 }
11}
12
13@OptIn(ExperimentalMaterial3Api::class)
14@Preview(showBackground = true)
15@Composable
16fun MainScreen() {
17 val ctxt = LocalContext.current
18 Scaffold(
19 topBar = {
20 TopAppBar(
21 title = { Text(ctxt.getString(R.string.app_name)) },
22 colors = topAppBarColors(
23 containerColor = MaterialTheme.colorScheme.primaryContainer,
24 titleContentColor = MaterialTheme.colorScheme.primary,
25 )
26 )
27 }
28 ) { innerPadding ->
29 Column(modifier = Modifier.padding(innerPadding)) {
30 Text(
31 modifier = Modifier.padding(5.dp).fillMaxWidth(),
32 text = "Main Activity"
33 )
34 Button(
35 modifier = Modifier.padding(5.dp).fillMaxWidth(),
36 onClick = {
37 /* Navigate to SecondActivity */
38 }) {
39 Text(text = "Ir a la segunda pantalla")
40 }
41 }
42 }
43}
…pueden pasarse datos de una Activity
a otra usando el sistema de Intent
. El onClick
del botón podría ser como se muestra a continuación.
1/* Navigate to SecondActivity */
2Intent(ctxt, SecondActivity::class.java).apply {
3 putExtra("nombre", "Javier")
4 putExtra("edad", 48)
5
6 ctxt.startActivity(this)
7}
Ahora, en la segunda actividad (SecondActivity.kt
) se pueden recoger los datos y mostrarlos en un Text
por ejemplo.
1class SecondActivity : ComponentActivity() {
2 override fun onCreate(savedInstanceState: Bundle?) {
3 super.onCreate(savedInstanceState)
4 enableEdgeToEdge()
5 setContent {
6 // Se recuperan los datos enviados desde MainActivity.
7 val nombre = intent.getStringExtra("nombre") ?: ""
8 val edad = intent.getIntExtra("edad", 0)
9
10 Column(modifier = Modifier.padding(32.dp)) {
11 Text("Second Activity")
12 Spacer(modifier = Modifier.height(8.dp))
13 if (nombre.isNotEmpty())
14 Text("Nombre: $nombre")
15 if (edad > 0)
16 Text("Edad: $edad")
17 }
18 }
19 }
20}
Consejo
Utiliza constantes o companion object
para definir las claves (“nombre”, “edad”), esto te evitará posibles errores de escritura.
3.4.3. Recibir resultados con ActivityResultLauncher
Desde la API 30 de Android (Jetpack Activity 1.2.0), se utiliza la nueva API para recibir resultados, evitando el uso obsoleto de onActivityResult
.
El primer paso será crear en un Composable el launcher encargado de lanzar la nueva actividad y recoger el resultado devuelto.
1val launcher = rememberLauncherForActivityResult(
2 contract = ActivityResultContracts.StartActivityForResult()
3) { result ->
4 if (result.resultCode == Activity.RESULT_OK) {
5 println("Resultado recibido de SecondActivity")
6 val data = result.data?.getStringExtra("resultado")
7
8 if (data != null) {
9 // Aquí puedes manejar el resultado que viene de SecondActivity
10 // Por ejemplo, mostrar un Toast o actualizar la UI
11 println("Resultado recibido: $data")
12 }
13 }
14}
El segundo paso será lanzar el Intent
, el método completo quedaría como se muestra a continuación.
1@Composable
2fun NavigateToSecondActivity(nombre: String, edad: Int) {
3 val ctxt = LocalContext.current
4 val launcher = rememberLauncherForActivityResult(
5 contract = ActivityResultContracts.StartActivityForResult()
6 ) { result ->
7 if (result.resultCode == Activity.RESULT_OK) {
8 println("Resultado recibido de SecondActivity")
9 val data = result.data?.getStringExtra("resultado")
10
11 if (data != null) {
12 // Aquí puedes manejar el resultado que viene de SecondActivity
13 // Por ejemplo, mostrar un Toast o actualizar la UI
14 println("Resultado recibido: $data")
15 }
16 }
17 }
18
19 Button(
20 modifier = Modifier.padding(5.dp).fillMaxWidth(),
21 onClick = {
22 /* Navigate to SecondActivity */
23 val intent = Intent(ctxt, SecondActivity::class.java)
24 intent.putExtra("nombre", nombre)
25 intent.putExtra("edad", edad)
26
27 launcher.launch(intent)
28 }) {
29 Text(text = "Ir a la segunda pantalla")
30 }
31}
En SecondActivity.kt
se añadirá un botón, por ejemplo, que se encargue de realizar el retorno con el paso de información.
1Button(
2 onClick = {
3 // Se crea un Intent para devolver el resultado a MainActivity.
4 val resultIntent = Intent().apply {
5 putExtra("resultado", "Hola $nombre, tienes $edad años")
6 }
7 setResult(Activity.RESULT_OK, resultIntent)
8 finish() // Finaliza SecondActivity y devuelve el resultado.
9 }
10) {
11 Text("Devolver resultado")
12}
El uso de esta API es compatible con Compose y es segura para el ciclo de vida.
3.4.4. ¿Cuándo usar varias Activity?
En Jetpack Compose se recomiendoa el uso múltiples actividades solo si:
- Se esta integrando módulos legacy con Compose.
- Se necesita un fuerte aislamiento entre pantallas (como flujos separados de autenticación o ajustes).
- Se necesitas interoperar con componentes que requieran una Activity concreta (por ejemplo, bibliotecas de terceros).
En todos los demás casos, se recomienda usar una sola actividad y Navigation Compose
para gestionar pantallas.
Fuentes
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, 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
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 del NavHost
o en un scope compartido, por lo que todas las pantallas tendrán acceso al mismo ViewModel
.
-
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á 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}
-
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}
-
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
Fuentes
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
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
Tema 5: Arquitectura MVVM y Clean Architecture
Objetivos de este tema
- Entender la separación de responsabilidades en aplicaciones Android.
- Implementar el patrón MVVM en proyectos Android con Jetpack Compose.
- Integrar Clean Architecture con componentes como
Room
, Retrofit2
y ViewModel
.
- Aprender a usar corutinas de Kotlin para realizar operaciones asíncronas.
- Aplicar inyección de dependencias con Koin/Hilt.
- Realizar pruebas unitarias básicas en capas lógicas.
5.1. ¿Qué es MVVM?
MVVM (Model-View-ViewModel) es un patrón de arquitectura que separa la lógica de la interfaz de usuario (UI).
Componentes principales:
- Modelo (
Model
): representará la capa de datos o lógica de negocio. Únicamente contendrá la información, no habrán métodos o acciones que manipulen los datos y, no tendrá ninguna dependencia de la vista.
- Vista (
View
): será la parte encargada de representar la información al usuario. En el patrón MVVM, las vistas son activas, reaccionando a eventos o cambios de los datos (Jetpack Compose en este caso).
- Modelo de vista (
ViewModel
): es el intermediario entre el modelo y la vista, mantiene el estado de la UI y contiene la lógica de negocio y abstracción de la interfaz. El enlace con la vista se realizará mediante el enlace de datos.
Ejemplo básico con Jetpack Compose:
Como se hace uso de viewModel()
será necesaria añadir la siguiente dependencia:
1// ViewModel dependencies for Compose
2implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
Contador básico:
1class CounterViewModel : ViewModel() {
2 // Técnica de backing con StateFlow para manejar el estado del contador.
3 private val _count = MutableStateFlow(0) // MutableStateFlow para el estado del contador.
4 val count: StateFlow<Int> = _count // Exponer el estado como StateFlow para que pueda ser observado por la UI.
5
6 fun increment() {
7 _count.value++
8 }
9}
10
11@Composable
12fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
13 val count by viewModel.count.collectAsState()
14 Column {
15 Text("Contador: $count")
16 Button(onClick = viewModel::increment) {
17 Text("Incrementar")
18 }
19 }
20}
5.2. Separación de responsabilidades
View
(Compose): Dibuja la UI, no contiene lógica.
ViewModel
: Gestiona el estado y eventos de la UI.
Repository
: Interactúa con fuentes de datos (API, BD).
Model
/Domain
: Reglas de negocio independientes del contexto.
5.3. ViewModel, Flow y LiveData
ViewModel
Se encarga de almacenar el estado de la UI y sobrevive a cambios de configuración (rotación de pantalla por ejemplo).
LiveData vs. Flow
-
LiveData: Observa los cambios en la UI (solo emite en contexto de Android).
1class LiveDataViewModel : ViewModel() {
2 private val _text = MutableLiveData("nombre")
3 val text: LiveData<String> = _text
4
5 fun updateText() {
6 _text.value = "Javier" // Actualizar el valor de LiveData.
7 }
8}
9
10@Composable
11fun Greeting(viewModel: LiveDataViewModel = viewModel()) {
12 // Usar observeAsState para observar cambios en LiveData.
13 val currentText by viewModel.text.observeAsState()
14
15 Column {
16 Text(text = "Hola $currentText!")
17 Button(onClick = { viewModel.updateText() }) {
18 Text("Actualizar nombre")
19 }
20 }
21}
Para utilizar observeAsState()
deberás añadir la dependencia androidx.compose.runtime:runtime-livedata:1.9.0
que permite observar LiveData
.
-
Flow: Colección asíncrona reactiva (ideal para Jetpack Compose).
1class FlowViewModel : ViewModel() {
2 private val _uiState = MutableStateFlow(0)
3 val uiState: StateFlow<Int> = _uiState
4
5 // Ejemplo con corutina
6 fun fetchData() {
7 viewModelScope.launch {
8 delay(1000)
9 _uiState.emit(_uiState.value + 10)
10 }
11 }
12}
13
14@Composable
15fun FlowScreen(viewModel: FlowViewModel = viewModel()) {
16 // Usar collectAsState para observar cambios en StateFlow.
17 val state by viewModel.uiState.collectAsState()
18
19 Column {
20 Text(text = "Estado actual: $state")
21 Button(onClick = { viewModel.fetchData() }) {
22 Text("Obtener datos")
23 }
24 }
25}
5.4. Introducción a Clean Architecture
Si además de la aplicación del patrón MVVM, se aplican conceptos de Clean Architecture se conseguirá mayor independencia entre módulos y proyectos más compactos. El uso de Clean Architecture se basa en la estructuración del código por capas, donde cada una de estas capas se comunicará con sus capas más cercanas. Además, cada una de estas capas tendrá un único objetivo, separando responsabilidades. Esta combinación permitirá soportar el crecimiento de la aplicación de manera más fiable.
Las capas comunes de Clean Architecture son:
- Presentación, esta es la capa que interactúa directamente con el usuario.
- Casos de uso, capa que suele contener las acciones que el usuario puede activar.
- Dominio, contiene la lógica de negocio, suele contener los modelos, por ejemplo las clases
SuperHero
o Editorial
.
- Datos, esta capa contiene las definiciones de la fuente de datos y cómo se utilizará. Puede no limitarse a una única fuente de datos. Se suele utilizar el patrón repositorio para decidir que fuente de datos (DataSource) utilizar.
- Framework, esta capa define las distintas fuentes de datos, por ejemplo, Room en modo local o una API de forma remota.
El diagrama clásico que representa la Clean Architecture creado por Robert C. Martin es posible que ya lo hayas visto.

5.4.1. Capas: Presentation, Domain, Data
Evidentemente, puede hacerse una libre interpretación de la arquitectura, eliminando capas, unificando, etc. Esto es así porque no es realmente una arquitectura como tal, sino una guía con recomendaciones a seguir. En Android es muy común unificar las capas, lo que además permitirá simplificar el modelo.
- Capa presentación, donde se aplicará MVVM.
- Capa dominio, contendrá el modelo de negocio y los casos de uso.
- Capa datos, se utilizará el modelo repositorio y el acceso a datos.
La comunicación entre todos los componentes de las capas será la siguiente.

5.5. Uso de corutinas
Ya se ha hecho un uso básico de ellas en puntos anteriores, a grandes rasgos, manejan tareas asíncronas sin bloquear el hilo principal, engargado de gestionar la UI.
1viewModelScope.launch {
2 val data = withContext(Dispatchers.IO) {
3 apiService.fetchData()
4 }
5 _uiState.emit(data)
6}
Más adelante se hará un uso más detallado de ellas.
5.6. Pruebas unitarias básicas
En este punto se tratará de mostrar una prueba “básica” para evaluar el método fetchData()
de la clase FlowViewModel()
. En concreto se crearán pruebas utilizando JUnit
, kotlinx-coroutines-test
y Turbine
(para probar Flow
).
JUnit
ya se encuentra añadida por defecto en el Gradle de los proyectos de Android Studio, por lo que habrá que añadir las dos que faltan al build.gradle.kts (Module: app)
.
1// Coroutines y para pruebas
2androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
3
4// Para utilizar InstantTaskExecutorRule
5androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
6
7// Para Turbine (test de Flows, test, awaitItem, cancelAndConsumeRemainingEvents)
8androidTestImplementation("app.cash.turbine:turbine:1.2.1")
Información
Turbine es clave para probar Flow
de forma sencilla. Permite recolectar valores emitidos y hacer aserciones.
Se creará una clase en el directorio destinado a los androidTest.
1import androidx.arch.core.executor.testing.InstantTaskExecutorRule
2import app.cash.turbine.test
3import kotlinx.coroutines.Dispatchers
4import kotlinx.coroutines.ExperimentalCoroutinesApi
5import kotlinx.coroutines.test.*
6import org.junit.*
7import org.junit.Assert.assertEquals
8
9// FlowViewModelTest.kt
10
11@OptIn(ExperimentalCoroutinesApi::class)
12class FlowViewModelTest {
13 // Regla para ejecutar tareas en el hilo inmediato (sincroniza LiveData)
14 @get:Rule
15 val instantTaskExecutorRule = InstantTaskExecutorRule()
16
17 // Disponemos de un TestDispatcher para controlar el tiempo
18 private lateinit var testDispatcher: TestDispatcher
19 private lateinit var viewModel: FlowViewModel
20
21 @Before
22 fun setUp() {
23 testDispatcher = UnconfinedTestDispatcher() // Permite controlar corutinas
24 Dispatchers.setMain(testDispatcher)
25 viewModel = FlowViewModel()
26 }
27
28 @After
29 fun tearDown() {
30 Dispatchers.resetMain()
31 }
32
33 @Test // Cuando se llama a fetchData, emite nuevo valor tras 1 segundo.
34 fun testCallfetchData() = runTest {
35 // GIVEN: Estado inicial es 0
36 assertEquals(0, viewModel.uiState.value)
37
38 // WHEN: Se llama a fetchData
39 viewModel.fetchData()
40
41 // THEN: Aún no se ha emitido nada (por el delay de 1 segundo)
42 assertEquals(0, viewModel.uiState.value)
43
44 // Avanzamos el tiempo virtual
45 advanceTimeBy(1100) // Simula algo más de 1 segundo
46
47 // Verificamos que el valor cambió a 10
48 assertEquals(10, viewModel.uiState.value)
49 }
50
51 @Test // uiState emite valores correctamente con Turbine.
52 fun testUiStateTurbine() = runTest {
53 // GIVEN: Recolectamos el Flow con Turbine
54 viewModel.uiState.test {
55 // THEN: Primer valor emitido debe ser 0
56 assertEquals(0, awaitItem())
57
58 // WHEN: Llamamos a fetchData
59 viewModel.fetchData()
60
61 // Y avanzamos el tiempo
62 advanceTimeBy(1000)
63
64 // THEN: Debe emitir 10
65 assertEquals(10, awaitItem())
66
67 // Finalizamos la recolección
68 cancelAndConsumeRemainingEvents()
69 }
70 }
71
72 @Test // fetchData puede llamarse múltiples veces y suma correctamente.
73 fun testCallfetchDataMultipleTurbine() = runTest {
74 viewModel.uiState.test {
75 assertEquals(0, awaitItem())
76
77 // Primera llamada
78 viewModel.fetchData()
79 advanceTimeBy(1000)
80 assertEquals(10, awaitItem())
81
82 // Segunda llamada
83 viewModel.fetchData()
84 advanceTimeBy(1000)
85 assertEquals(20, awaitItem())
86 }
87 }
88}
Resumen del test
runTest
: Reemplaza a runBlocking
. Permite controlar el tiempo con advanceTimeBy()
.
testDispatcher
: Simula el lanzamiento de corutinas sin depender del tiempo real.
viewModel.uiState.test { ... }
: Con Turbine
, se puede recolectar los valores emitidos por el Flow
.
awaitItem()
: Espera a que se emita un valor (ideal para pruebas asíncronas).
advanceTimeBy(1000)
: Simula que han pasado 1000 ms, haciendo que delay(1000)
termine.
Fuentes
Tema 6: Persistencia con ROOM
Objetivos de este tema
- Entender el propósito de
ROOM
como solución de persistencia local en Android.
- Crear entidades , DAOs y una base de datos usando
ROOM
.
- Integrar
ROOM
con el patrón MVVM y el componente ViewModel
.
- Realizar consultas básicas y avanzadas en
ROOM
.
- Gestionar migraciones simples al evolucionar la base de datos.
6.1. Introducción a ROOM
ROOM
es una biblioteca de persistencia oficial de Android (parte de Jetpack) que facilita el acceso a la base de datos SQLite
. Proporciona una capa de abstracción sobre SQLite
, permitiendo escribir consultas de forma más segura y con menos código que usando SQLiteDatabase
directamente.
Ventajas de Room:
- Tipado seguro: Detecta errores en tiempo de compilación.
- Integración con
LiveData
y Coroutines: Perfecto para aplicar el patrón MVVM.
- Sin boilerplate: No se necesita escribir manualmente
ContentValues
, Cursor
, etc y otro código innecesario o redundante.
- Migraciones controladas: Facilita el cambio de versiones de la base de datos.
Información
ROOM
no se ejecuta en el hilo principal de la aplicación (UI). Siempre deberán utilizarse Coroutines, LiveData
y/o Flow
para evitar ANR (Application Not Responding).
6.2. Configuración de ROOM en el proyecto
En primer lugar, se añadirá el complemento KSP
en el archivo build.gradle.kts (Project:)
, alineando la versión de KSP con la versión de Kotlin del proyecto. Puedes encontrar una lista de las actualizaciones en la página de GitHub de KSP.
1plugins {
2 ...
3 id("com.google.devtools.ksp") version "2.2.0-2.0.2" apply false
4}
A continuación, habilita KSP
en el archivo build.gradle.kts (Module :app)
a nivel del módulo:
1plugins {
2 ...
3 id("com.google.devtools.ksp")
4}
Para terminar, seguiendo con el archivo build.gradle.kts (Module :app)
, se añadirán las siguientes dependencias para poder hacer uso de ROOM
.
1// ROOM dependencies
2implementation("androidx.room:room-runtime:2.7.2")
3implementation("androidx.room:room-ktx:2.7.2") // Soporte para Coroutines y Kotlin Extensions.
4ksp("androidx.room:room-compiler:2.7.2") // KSP para procesamiento de anotaciones.
5
6// ViewModel y LiveData
7implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.2")
8implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
9implementation("androidx.compose.runtime:runtime-livedata:1.9.0")
10
11// Navigation Compose
12implementation("androidx.navigation:navigation-compose:2.9.3")
Recuerda sincronicar el Gradle con cada paso.
6.3. Modelo
Para simplificar, se omite el dominio y directamente se creará un package model para alojar las data classes que representarán las tablas.
1import androidx.room.Entity
2import androidx.room.PrimaryKey
3
4// Editorial.kt
5@Entity(tableName = "editorial")
6data class Editorial(
7 @PrimaryKey(autoGenerate = true) val idEd: Int = 0,
8 val name: String
9)
tableName
te permite cambiar el nombre de la tabla si no quieres que coincida con el nombre de la data class.
autoGenerate = true
genera IDs automáticamente.
1import androidx.room.Entity
2import androidx.room.PrimaryKey
3
4// SuperHero.kt
5@Entity(tableName = "superhero")
6data class SuperHero(
7 @PrimaryKey(autoGenerate = true)
8 var idSuper: Int = 0,
9 var superName: String,
10 var realName: String,
11 var favorite: Boolean = false,
12 var idEditorial: Int = 0
13)
La siguiente clase representa la relación entre SuperHero
y Editorial
con cardinalidad 1:1. Observa que las clases que representan una relación no llevan la etiqueta @Entity
.
1import androidx.room.Embedded
2import androidx.room.Relation
3
4// SuperWithEditorial.kt
5data class SuperWithEditorial(
6 @Embedded val supers: SuperHero,
7 @Relation(
8 parentColumn = "idEditorial",
9 entityColumn = "idEd"
10 )
11 val editorial: Editorial
12)
@Embedded
se utiliza para incluir los campos de la entidad SuperHero en el objeto SuperWithEditorial.
@Relation
se utiliza para definir la relación entre SuperHero y Editorial, especificando las columnas que se utilizan para enlazarlas.
6.4. Creación del DAO
El DAO(Data Access Object) define las operaciones que pueden realizarse sobre la base de datos (CRUD). Siguiendo con la ordenación según la Clean Architecture se creará el package data, dentro se creará la interface para el DAO.
1import androidx.lifecycle.LiveData
2import androidx.room.Dao
3import androidx.room.Delete
4import androidx.room.Insert
5import androidx.room.OnConflictStrategy
6import androidx.room.Query
7import androidx.room.Transaction
8import kotlinx.coroutines.flow.Flow
9
10// SupersDAO.kt
11@Dao
12interface SupersDAO {
13 // Versión de consultas que devuelven un FLOW.
14 @Transaction // Permite obtener datos de varias tablas relacionadas con una sola consulta.
15 @Query("SELECT * FROM SuperHero ORDER BY superName")
16 fun getSuperHerosWithEditorials(): Flow<List<SuperWithEditorial>>
17
18 @Query("SELECT * FROM Editorial")
19 fun getAllEditorials(): Flow<List<Editorial>>
20
21 // Versión de consultas que devuelven un LIVEDATA (...LD).
22 @Transaction
23 @Query("SELECT * FROM SuperHero ORDER BY superName")
24 fun getSuperHerosWithEditorialsLD(): LiveData<List<SuperWithEditorial>>
25
26 @Query("SELECT * FROM Editorial")
27 fun getAllEditorialsLD(): LiveData<List<Editorial>>
28
29 // Resto de consultas.
30
31 @Query("SELECT * FROM SuperHero WHERE idSuper = :idSuper")
32 suspend fun getSuperById(idSuper: Int): SuperHero?
33
34 @Query("SELECT * FROM Editorial WHERE idEd = :editorialId")
35 suspend fun getEditorialById(editorialId: Int): Editorial?
36
37 @Insert(onConflict = OnConflictStrategy.REPLACE)
38 suspend fun insertEditorial(editorial: Editorial)
39
40 @Insert(onConflict = OnConflictStrategy.REPLACE)
41 suspend fun insertSuperHero(superHero: SuperHero)
42
43 @Delete
44 suspend fun deleteSuperHero(superHero: SuperHero): Int
45}
@Transaction
permite obtener datos de varias tablas relacionadas con una sola consulta.
@Query
permite realizar consultas SQL directamente sobre la base de datos.
@Insert
permite insertar un nuevo registro en la base de datos. Devuelve como Long
el ID del registro insertado, si se ha insertado correctamente.
- El uso de
onConflictStrategy.REPLACE
permite reemplazar un registro existente si hay un conflicto de clave primaria. Si se intenta insertar un SuperHero
o Editorial
con un id que ya existe, se actualizará el registro existente, puede utilizarse para ahorrarse un método para actualizar (@Update
).
@Delete
permite eliminar un registro de la base de datos, devolvilendo como Int
el número de filas afectadas.
Observa que se han duplicado dos métodos, esto se hace a modo didáctico para ilustrar el uso de Flow
y LiveData
.
6.5. Definición de la base de datos
Se creará a continuación una clase abstracta para definir la base de datos y conecta las entidades con los DAOs.
1import android.content.Context
2import androidx.room.Database
3import androidx.room.Room
4import androidx.room.RoomDatabase
5
6// AppDatabase.kt
7@Database(
8 entities = [SuperHero::class, Editorial::class],
9 version = 1,
10 exportSchema = true // Importante para migraciones
11)
12abstract class AppDatabase : RoomDatabase() {
13 abstract fun supersDAO(): SupersDAO // Conexión con DAO de SuperHéroes.
14
15 companion object {
16 @Volatile
17 private var INSTANCE: AppDatabase? = null
18
19 fun getInstance(context: Context): AppDatabase {
20 return INSTANCE ?: synchronized(this) {
21 val instance = Room.databaseBuilder(
22 context.applicationContext,
23 AppDatabase::class.java,
24 "SuperHeros.db"
25 ).fallbackToDestructiveMigration(true) // Solo en desarrollo.
26 .build()
27
28 INSTANCE = instance // Asigna la instancia a la variable volátil.
29 instance // Devuelve la instancia de la base de datos.
30 }
31 }
32 }
33}
@Volatile
asegura visibilidad del hilo.
synchronized
evita creación múltiple de la instancia de la base de datos.
exportSchema = true
genera un JSON con el esquema (necesario para migraciones).
fallbackToDestructiveMigration(boolen)
se utiliza durante la configuración de la base de datos permitiendo controlar cómo se manejarán las migraciones cuando no se ha definido una estrategia.
false
desactiva la migración destructiva, si no hay una migración definida entre dos versiones del esquema, ROOM
lanzará una excepción (IllegalStateException
) en lugar de borrar y recrear la base de datos. Por defecto.
true
facilita los cambios rápidos del esquema sin tener que escribir migraciones cada vez, borra y crea la base de datos. Solo en desarrollo.
6.6. Uso de ROOM con ViewModel
Para respetar el patrón MVVM, nunca se accederá a Room desde la UI. Se utilizará un ViewModel
para iniciar la interacción Repository <-> Datasource <-> Framework. Al aplicar la capa intermedia (Repository - Datasource), inicialmente puede verse como una repetición o redundancia de código, pero tiene una lógica, y es separar la lógica de acceso al Framework del Repositoy, separando y facilitando así el acceso a distintas fuentes de datos.
Información
UI (Compose) <-> ViewModel <-> Repository <-> Datasource <-> [API (Retrofit) o DB (ROOM)]
Comenzando por el Datasource se creará en el package data la siguiente clase:
1import androidx.lifecycle.LiveData
2import kotlinx.coroutines.flow.Flow
3
4// LocalDatasource.kt
5class LocalDatasource(private val dao: SupersDAO) {
6 // Version FLOW.
7 val currentSupers: Flow<List<SuperWithEditorial>> = dao.getSuperHerosWithEditorials()
8 val currentEditorials: Flow<List<Editorial>> = dao.getAllEditorials()
9
10 // Version LIVEDATA.
11 val currentSupersLD: LiveData<List<SuperWithEditorial>> = dao.getSuperHerosWithEditorialsLD()
12 val currentEditorialsLD: LiveData<List<Editorial>> = dao.getAllEditorialsLD()
13
14 suspend fun deleteSuper(superHero: SuperHero): Int { // Returns the number of rows deleted.
15 return dao.deleteSuperHero(superHero)
16 }
17
18 suspend fun saveSuper(superHero: SuperHero) {
19 dao.insertSuperHero(superHero)
20 }
21
22 suspend fun getSuperById(superId: Int): SuperHero? = dao.getSuperById(superId)
23
24 suspend fun saveEditorial(editorial: Editorial) {
25 dao.insertEditorial(editorial)
26 }
27
28 suspend fun getEdById(editorialId: Int): Editorial? = dao.getEditorialById(editorialId)
29}
Se opta por el nombre LocalDatasource.kt
porque es la clase que da acceso al almacenamiento local. Ahora se creará Repository.kt
, que será en este caso muy similar, pero ya se verá su utilidad real.
1import androidx.lifecycle.LiveData
2import kotlinx.coroutines.flow.Flow
3
4// Reposity.kt
5class Repository(private val localDatasource: LocalDatasource) {
6 // Versión FLOW.
7 val currentSupers: Flow<List<SuperWithEditorial>> = localDatasource.currentSupers
8 val currentEditorials: Flow<List<Editorial>> = localDatasource.currentEditorials
9
10 // Versión LIVEDATA.
11 val currentSupersLD: LiveData<List<SuperWithEditorial>> = localDatasource.currentSupersLD
12 val currentEditorialsLD: LiveData<List<Editorial>> = localDatasource.currentEditorialsLD
13
14 suspend fun deleteSuper(superHero: SuperHero): Int {
15 return localDatasource.deleteSuper(superHero)
16 }
17
18 suspend fun saveSuper(superHero: SuperHero) {
19 localDatasource.saveSuper(superHero)
20 }
21
22 suspend fun getSuperById(superId: Int): SuperHero? = localDatasource.getSuperById(superId)
23
24 suspend fun saveEditorial(editorial: Editorial) {
25 localDatasource.saveEditorial(editorial)
26 }
27
28 suspend fun getEdById(editorialId: Int): Editorial? = localDatasource.getEdById(editorialId)
29}
A continuación, se creará el ViewModel compartido entre pantallas dónde se establecerá la conexión a la base de datos y la interacción con el repositorio. La clase SupersViewModel.kt
estará a la misma altural que la clase MainActivity.kt
en la estructura de árbol del proyecto.
1import android.app.Application
2import androidx.lifecycle.AndroidViewModel
3import androidx.lifecycle.LiveData
4import androidx.lifecycle.viewModelScope
5import kotlinx.coroutines.Deferred
6import kotlinx.coroutines.async
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.catch
10import kotlinx.coroutines.launch
11
12// SupersViewModel.kt
13class SupersViewModel(application: Application) : AndroidViewModel(application) {
14 // Se inicializa el repositorio y el datasource.
15 private val repository: Repository
16 private val localDatasource: LocalDatasource
17
18 // Se exponen los StateFlow para que la UI observe los cambios.
19 private val _currentSupers = MutableStateFlow<List<SuperWithEditorial>>(emptyList())
20 val currentSupers: StateFlow<List<SuperWithEditorial>> = _currentSupers
21
22 private val _currentEditorials = MutableStateFlow<List<Editorial>>(emptyList())
23 val currentEditorials: StateFlow<List<Editorial>> = _currentEditorials
24
25 // Se exponen los LiveData según sea necesario.
26 val currentSupersLD: LiveData<List<SuperWithEditorial>>
27 val currentEditorialLD: LiveData<List<Editorial>>
28
29 init {
30 // Inicialización del repositorio y el datasource.
31 val database = AppDatabase.getInstance(application)
32 val dao = database.supersDAO()
33 localDatasource = LocalDatasource(dao)
34 repository = Repository(localDatasource)
35
36 // Carga inicial de superhéroes y editoriales, versión Flow.
37 loadSupers()
38 loadEditorials()
39
40 // Inicialización del LiveData para los superhéroes.
41 currentSupersLD = repository.currentSupersLD
42 currentEditorialLD = repository.currentEditorialsLD
43 }
44
45 // Se observan los StateFlow para que la UI pueda reaccionar a los cambios con Flow una vez
46 // que se hayan cargado los datos iniciales.
47 fun loadEditorials() {
48 viewModelScope.launch {
49 repository.currentEditorials
50 .catch { e -> e.printStackTrace() } // Manejo de errores.
51 .collect { editorials ->
52 _currentEditorials.value = editorials // Actualiza el StateFlow con las editoriales.
53 }
54 }
55 }
56
57 fun loadSupers() {
58 viewModelScope.launch {
59 repository.currentSupers
60 .catch { e -> e.printStackTrace() } // Manejo de errores.
61 .collect { supers ->
62 _currentSupers.value = supers // Actualiza el StateFlow con los superhéroes.
63 }
64 }
65 }
66
67 fun saveEditorial(editorial: Editorial) {
68 viewModelScope.launch {
69 repository.saveEditorial(editorial)
70 }
71 }
72
73 fun saveSuper(superHero: SuperHero) {
74 viewModelScope.launch {
75 repository.saveSuper(superHero)
76 }
77 }
78
79 suspend fun delSuper(superHero: SuperHero) : Int{
80 return deleteSuper(superHero).await()
81 }
82
83 // Esta función devuelve un Deferred para que se pueda esperar su resultado de forma asíncrona.
84 private fun deleteSuper(superHero: SuperHero): Deferred<Int> {
85 return viewModelScope.async {
86 repository.deleteSuper(superHero)
87 }
88 }
89
90 fun getSuperById(superId: Int): Deferred<SuperHero?> {
91 return viewModelScope.async { repository.getSuperById(superId) }
92 }
93
94 fun getEdById(editorialId: Int): Deferred<Editorial?> {
95 return viewModelScope.async { repository.getEdById(editorialId) }
96 }
97}
Este ViewModel
muestra dos formas de recuperar la información de la BD, una mediante MutableStateFlow
para la versión con Flows y otra utilizando LiveData
, aquí por motivos didácticos se tienen las dos a la vez, no es lo habitual, siempre se elegirá una única forma de trabajar, se recomienda el uso de Flows para Jetpack Compose.
6.6.1. Comsumir los datos desde la UI
Versión para Flow
En la versión para Flows, puede obtenerse el flugo de datos de la siguiente manera:
1@Composable
2fun MainScreen(navController: NavController, viewModel: SupersViewModel) {
3 val snackbarHostState = remember { SnackbarHostState() }
4 val scope = rememberCoroutineScope()
5
6 // Se recolecta el StateFlow del ViewModel para observar el flujo de datos
7 // de los superhéroes y las editoriales. Se puede usar collectAsState() o
8 // collectAsStateWithLifecycle() para obtener el estado actual.
9 val currentSupers by viewModel.currentSupers.collectAsStateWithLifecycle()
10 val currentEditorials by viewModel.currentEditorials.collectAsStateWithLifecycle()
11
12 ...
13}
Cuando se observa StateFlow
o Flow
se utilizan los método collectAsState()
o collectAsStateWithLifecycle()
, aquí tienes una comparativa entre ambos.
Característica |
collectAsState() |
collectAsStateWithLifecycle() |
Ciclo de vida |
Siempre ecolecta, incluso estando en segundo plano |
Solo recolectará cuando el estado de vida sea STARTED (pantalla visible) |
Consumo de recursos |
Puede consumir batería innecesariamente |
Más eficiente, pausa la recolección en segundo plano |
Uso recomendado |
En apps simples o prototipos |
Recomendado para producción |
Dependencia extra |
No necesita dependencias adicionales |
Necesita la librería androidx.lifecycle:lifecycle-viewmodel-compose |
collectAsStateWithLifecycle()
es la opción recomendada para aplicaciones reales y proyectos con ViewModel + Compose, permitiendo así una gestión eficiente del ciclo de vida.
Nota
Desde Compose BOM 2023.10.01 y Lifecycle 2.6.2+, Google añadió una serie de mejoras importantes, ahora, collectAsState()
dentro de un @Composable
respeta el ciclo de vida si se usa junto con ViewModel
y StateFlow
/MutableStateFlow
.
Esto quiere decir que collectAsState()
pausa la recolección cuando la pantalla no está en primer plano, igual que hacía collectAsStateWithLifecycle()
.
Una vez se obtienen los datos, ya se puede trabajar con ellos, por ejemplo, comprobar la existencia previa de editoriales para permitir añadir superhéroes.
1LaunchedEffect(currentEditorials.isEmpty()) {
2 delay(1_000) // Se espera un segundo para dar tiempo a que se carguen los datos.
3 if (currentEditorials.isEmpty()) {
4
5 snackbarHostState.showSnackbar(
6 message = "No hay editoriales disponibles, debe existir al menos una para poder añadir superhéroes.",
7 duration = SnackbarDuration.Short
8 )
9 }
10}
En este ejemplo se utiliza LaunchedEffect(key)
, esta es una función de Compose que permite lanzar una Coroutine cuando un componente se muestra (o vuelve a componerse bajo ciertas condiciones).
Se usa para ejecutar tareas asíncronas desde la UI, como:
- Llamar a una función suspendida del
ViewModel
.
- Mostrar un
Snackbar
.
- Ejecutar una tarea o acción tras un evento (ej: después de guardar).
El parámetro key del método se utilizará de la siguiente manera:
- Si el objeto pasado como key cambia, la acción se vuelve a ejecutar.
- Si la clave es
Unit
, se ejecutará solo una vez al entrar en composición.
En el caso de currentSupers
, que es una lista, se utilizará como tal:
1items(currentSupers) { oneSuper ->
2 ...
3}
Versión para LiveData
En el caso de utilizar LiveData
los datos se observan.
1// Se recolecta el LiveData del ViewModel
2val currentSupersLD by viewModel.currentSupersLD.observeAsState()
3val currentEditorialsLD by viewModel.currentEditorialLD.observeAsState()
En este caso deberán realizar más comprobaciónes, controlando los posibles nulos.
1if (currentEditorialsLD != null) {
2 LaunchedEffect(currentEditorialsLD!!.isEmpty()) {
3 delay(1_000) // Se espera un segundo para dar tiempo a que se carguen los datos.
4 if (currentEditorialsLD!!.isEmpty()) {
5
6 snackbarHostState.showSnackbar(
7 message = "No hay editoriales disponibles, debe existir al menos una para poder añadir superhéroes.",
8 duration = SnackbarDuration.Short
9 )
10 }
11 }
12}
También para la lista de superhéroes.
1if (currentSupersLD != null) {
2 items(currentSupersLD!!, key = { it.supers.idSuper }) { oneSuper ->
3 ...
4 }
5}
Código completo
Fuentes
Tema 7: Comunicación con APIs REST con Retrofit2
Objetivos de este tema
- Entender qué es una API REST y cómo comunicarse desde app Android.
- Configurar y usar
Retrofit2
para realizar peticiones HTTPS desde una app Android.
- Convertir respuestas JSON en objetos Kotlin usando Gson.
- Gestionar errores en las llamadas a la API.
- Integrar
Retrofit2
con ViewModel
, ROOM (para caché offline) y el patrón MVVM.
- Usar corutinas para gestionar llamadas asíncronas sin bloquear la interfaz de usuario.
7.1. Introducción a APIs REST
Una API REST es un servicio que facilita una serie de mecanismos para obtener información de un cliente externo, generalmente una base de datos que nutra la aplicación. Las peticiones que pueden hacerse a una API REST son los siguientes:
- GET, devuelven información, puede pasársele parámetros a través de la URL, pero es poco segura.
- POST, similar a GET, pero más segura, los parámetros no se pasan en la URL.
- PUT, se utilizará para crear registros en la base de datos.
- DELETE, permite eliminar registros de la base de datos.
La información devuelva por una API REST estará por lo general en formato JSON. Como norma general, para poder modificar el contenido mediante una API REST, será necesario algún tipo de autenticación, aunque muchas son utilizadas como consulta (GET) y no requieren de este sistema de seguridad.
Ejemplo de una URL de API REST: https://jsonplaceholder.typicode.com/posts. Esta URL devuelve una lista de publicaciones en formato JSON.
7.2. Retrofit2
Retrofit2
es una librería de Square que convierte una API REST en una interfaz de Java o Kotlin, facilitando mucho las llamadas HTTP en Android de una manera relativamente sencilla. Esta biblioteca permite el consumo de APIs REST, además se combinará con corrutinas y con el uso de Flows.
7.2.1. Dependencias necesarias
1// ViewModel
2implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.3")
3implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.3")
4
5// Retrofit2
6implementation("com.squareup.retrofit2:retrofit:3.0.0")
7
8// Conversor para JSON (Gson)
9implementation("com.squareup.retrofit2:converter-gson:3.0.0")
10
11// Corutinas (para llamadas asíncronas)
12implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
Recuerda también añadir el permiso de uso de Internet al Manifest, importante para el consumo de API REST.
1<uses-permission android:name="android.permission.INTERNET" />
7.3. Modelo
Para obtener un listado de los posts mostrados por jsonplaceholder
se creará el siguiente modelo. Se creará dentro del package model.
1// Post.kt
2data class Post(
3 val id: Int,
4 val userId: Int,
5 val title: String,
6 val body: String
7)
En este caso se trata de una data class
sencilla, pero puedes encontrarte con JSONs más complejos. Existen en Android Studio un plugin que puede ayudarte para estas situaciones, JSON To Kotlin Class.
7.4. Configuración de Retrofit2
Para ordenar el código, la configuración de Retrofit2
se hará dentro del package data, concretamente se creará el fichero RetrofitClient.kt
en el que se ubicará un object
para tener una única instancia de Retrofit2 (patrón Singleton) y la interfaz para utilizar las anotaciones que definirán las peticiones a la API.
1import retrofit2.Response
2import retrofit2.Retrofit
3import retrofit2.converter.gson.GsonConverterFactory
4import retrofit2.http.GET
5import retrofit2.http.Path
6
7// RetrofitClient.kt
8object RetrofitClient {
9 private const val BASE_URL = "https://jsonplaceholder.typicode.com/"
10
11 val apiService: ApiService by lazy {
12 Retrofit.Builder()
13 .baseUrl(BASE_URL)
14 .addConverterFactory(GsonConverterFactory.create())
15 .build()
16 .create(ApiService::class.java)
17 }
18}
19
20interface ApiService {
21 @GET("posts")
22 suspend fun getPosts(): Response<List<Post>>
23
24 @GET("posts/{id}")
25 suspend fun getPostById(@Path("id") id: Int): Response<Post>
26}
Información
suspend
: permite usar corutinas (asíncrono).
Response<T>
: incluye código de estado, mensaje y cuerpo. Ideal para manejar errores.
addConverterFactory(GsonConverterFactory.create())
: convierte automáticamente el JSON a objetos Kotlin y viceversa. Retrofit no procesa JSON por sí solo, necesita un convertidor. El más usado es GsonConverter
, importado al inicio del tema.
7.4.1. Manejo de errores y excepciones
Cuando se hacen llamadas a una API pueden producirse errores que hay que controlar:
- No hay conexión a Internet.
- Servidor caído (500).
- Recurso no encontrado (404).
- Respuesta vacía.
Se utilizará try-catch
y se analizará el Response
obtenido para gestionarlos.
7.5. Creación del flujo e integración con ViewModel
En aplicaciones profesionales, se recomienda guardar los datos de manera local con ROOM y utilizar la API solo si no hay datos en caché o si el usuario refresca.
Flujo recomendado (Clean Architecture + MVVM)
Información
UI (Compose) <-> ViewModel <-> Repository <-> Datasource <-> [API (Retrofit) o DB (ROOM)]
Se creará en primer lugar la obtención de la información de la API, como ya se dispone del interface para la API, se pasará a crear el Datasource en el package data.
1// RemoteDatasource.kt
2class RemoteDatasource {
3 // Servicio API utilizando Retrofit.
4 private val apiService = RetrofitClient.apiService
5
6 // Funciones para obtener datos desde la API.
7 suspend fun getPosts() = apiService.getPosts()
8
9 // Obtener un post por su ID.
10 suspend fun getPostById(id: Int) = apiService.getPostById(id)
11}
Observa como quedaría ahora el repositorio, controlando posibles errores y haciendo uso del Datasource remoto.
1// Repository.kt
2class Repository(private val remoteDatasource: RemoteDatasource) {
3 // Manejo de errores básico con try-catch.
4 // En caso de error, se devuelve una lista vacía.
5 suspend fun getPosts(): List<Post>? {
6 return try {
7 val response = remoteDatasource.getPosts()
8 if (response.isSuccessful) {
9 val posts = response.body() ?: emptyList()
10 posts
11 } else {
12 Log.e("Repository", "Error response: ${response.code()} - ${response.message()}")
13 emptyList()
14 }
15 } catch (e: Exception) {
16 Log.e("Repository", e.message, e)
17 throw e // Lanzar la excepción para que el ViewModel pueda manejarla.
18 }
19 }
20
21 // Obtener un post por su ID con manejo de errores.
22 // En caso de error, se devuelve null.
23 suspend fun getPostById(id: Int): Post? {
24 return try {
25 val response = remoteDatasource.getPostById(id)
26 if (response.isSuccessful) {
27 val post = response.body()
28 post
29 } else {
30 Log.e("Repository", "Error response: ${response.code()} - ${response.message()}")
31 null
32 }
33 } catch (e: Exception) {
34 Log.e("Repository", "Error fetching post by ID", e)
35 throw e
36 }
37 }
38}
El siguiente paso será crear el ViewModel
que se encargará de facilitar la información a la UI.
1import androidx.lifecycle.ViewModel
2import androidx.lifecycle.viewModelScope
3import kotlinx.coroutines.flow.MutableStateFlow
4import kotlinx.coroutines.flow.StateFlow
5import kotlinx.coroutines.launch
6
7class MainViewModel : ViewModel() {
8 // Se inicializa el repositorio y el datasource.
9 private val repository: Repository
10 private val remoteDatasource: RemoteDatasource
11
12 // Estado para la lista de posts, estado de carga y errores.
13 private val _posts = MutableStateFlow<List<Post>>(emptyList())
14 val posts: StateFlow<List<Post>> = _posts
15
16 // Estado de carga y errores.
17 private val _loading = MutableStateFlow(false)
18 val loading: StateFlow<Boolean> = _loading
19
20 // Estado de error.
21 private val _error = MutableStateFlow<String?>(null)
22 val error: StateFlow<String?> = _error
23
24 init {
25 remoteDatasource = RemoteDatasource()
26 repository = Repository(remoteDatasource)
27 }
28
29 fun fetchPosts() {
30 viewModelScope.launch {
31 _loading.value = true
32 _error.value = null
33
34 try {
35 val posts = repository.getPosts()
36 _posts.value = posts ?: emptyList()
37 } catch (e: Exception) {
38 _error.value = "ERROR: ${e.message}"
39 } finally {
40 _loading.value = false
41 }
42 }
43 }
44}
Información
- Se utiliza
StateFlow
para exponer los datos a la UI.
viewModelScope.launch
ejecuta la corutina en el contexto del ViewModel
.
Para cargar los datos desde la UI, puede utilizarse un Composable como el siguiente:
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun PostScreen(viewModel: MainViewModel = viewModel()) {
4 val posts: List<Post> by viewModel.posts.collectAsState()
5 val loading by viewModel.loading.collectAsState()
6 val error by viewModel.error.collectAsState()
7
8 Scaffold(
9 topBar = { TopAppBar({ Text("Documentation T7.1") }) },
10 modifier = Modifier.fillMaxSize()
11 ) { paddingValues ->
12 Column(modifier = Modifier.padding(paddingValues)) {
13 if (loading) {
14 CircularProgressIndicator(
15 modifier = Modifier
16 .padding(16.dp)
17 .align(Alignment.CenterHorizontally)
18 )
19 } else if (error != null) {
20 Text(text = "Error: $error", color = Color.Red, modifier = Modifier.padding(16.dp))
21 } else {
22 LazyColumn {
23 items(posts) { post ->
24 Card(modifier = Modifier.padding(8.dp)) {
25 Text("Título: ${post.title}", Modifier.padding(8.dp))
26 Text("Cuerpo: ${post.body}", modifier = Modifier.padding(8.dp))
27 }
28 }
29 }
30 }
31
32 Button(
33 modifier = Modifier.fillMaxWidth().padding(8.dp),
34 onClick = { viewModel.fetchPosts() }) {
35 Text("Actualizar")
36 }
37 }
38 }
39}
Este Composable muestra en pantalla un botón que el usuario debe pulsar para cargar la información, si prefieres que se cargen automáticamente, basta con añadir un LaunchedEffect
.
1LaunchedEffect(posts) {
2 if (posts.isEmpty() && !loading && error == null) {
3 viewModel.fetchPosts()
4 }
5}
7.5.1. PullToRefreshBox
en Jetpack Compose
En una aplicación móvil es muy común deslizar hacia abajo (pull down) para que se actualice el contenido (por ejemplo, nuevos correos, noticias o publicaciones). En Android con vistas tradicionales (XML) se usaba SwipeRefreshLayout
, pero en Jetpack Compose, desde 2024, se utiliza PullToRefreshBox
, que es parte de Material 3 y ofrece una experiencia más fluida y moderna. En versiones anteriores, debía añadirse pullRefresh
como un modificador de un contenedor Box
.
Para aplicarlo, se modificará el Composable PostScreen
creado en el punto anterior. Se reutilizará el estado loading
y se simplificará el código, ya no hará falta utilizar CircularProgressIndicator
.
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun PostScreen(viewModel: MainViewModel = viewModel()) {
4 val posts: List<Post> by viewModel.posts.collectAsState()
5 val loading by viewModel.loading.collectAsState() // Estado de carga.
6 val error by viewModel.error.collectAsState()
7
8 // Estado del pull-to-refresh.
9 val refreshState = rememberPullToRefreshState()
10
11 LaunchedEffect(posts) {
12 if (posts.isEmpty() && !loading && error == null) {
13 viewModel.fetchPosts()
14 }
15 }
16
17 Scaffold(
18 topBar = { TopAppBar({ Text("Documentation T7.1") }) },
19 modifier = Modifier.fillMaxSize()
20 ) { paddingValues ->
21 Column(modifier = Modifier.padding(paddingValues)) {
22 if (error != null) {
23 Text(text = "Error: $error", color = Color.Red, modifier = Modifier.padding(16.dp))
24
25 Button(
26 modifier = Modifier.fillMaxWidth().padding(8.dp),
27 onClick = { viewModel.fetchPosts() }) {
28 Text("Actualizar")
29 }
30 } else {
31 // Implementación de Pull to Refresh.
32 PullToRefreshBox(
33 isRefreshing = loading, // Usa el estado de carga del ViewModel.
34 state = refreshState, // Estado del pull-to-refresh.
35 modifier = Modifier.fillMaxSize(),
36 onRefresh = { viewModel.fetchPosts() } // Acción al refrescar.
37 ) {
38 // Contenido que se puede refrescar
39 LazyColumn {
40 items(posts){ post ->
41 Card(modifier = Modifier.padding(8.dp).fillMaxWidth()) {
42 Text("Título: ${post.title}", Modifier.padding(8.dp))
43 Text("Cuerpo: ${post.body}", modifier = Modifier.padding(8.dp))
44 }
45 }
46 }
47 }
48 }
49 }
50 }
51}
En este caso se deja el botón para forzar la actualización en caso de producirse algún error.
7.6. Integración con ROOM
Para realizar la integración con ROOM, se seguirán los pasos vistos en el tema anterior para la configuración del proyecto (ver aquí).
7.6.1. Modelo
Se modificará la data class
que representa el modelo para que pueda utilizarse con ROOM.
1// Post.kt
2@Entity(tableName = "posts")
3data class Post(
4 @PrimaryKey(autoGenerate = true) val id: Int,
5 val userId: Int,
6 val title: String,
7 val body: String
8)
Información
@Entity
: indica que esta clase será una tabla en la base de datos.
@PrimaryKey
: el campo id
es la clave primaria (obligatorio en Room).
7.6.2. Configuración de la BD y DAO
También será necesario definir la base de datos y crear el DAO.
1// AppDatabase.kt
2@Database(
3 entities = [Post::class],
4 version = 1,
5 exportSchema = true // Importante para migraciones
6)
7abstract class AppDatabase : RoomDatabase() {
8 abstract fun postsDAO(): PostsDAO // Conexión con DAO de Posts.
9
10 companion object {
11 @Volatile
12 private var INSTANCE: AppDatabase? = null
13
14 fun getInstance(context: Context): AppDatabase {
15 return INSTANCE ?: synchronized(this) {
16 val instance = Room.databaseBuilder(
17 context.applicationContext,
18 AppDatabase::class.java,
19 "Posts.db"
20 ).fallbackToDestructiveMigration(true) // Solo en desarrollo.
21 .build()
22
23 INSTANCE = instance // Asigna la instancia a la variable volátil.
24 instance // Devuelve la instancia de la base de datos.
25 }
26 }
27 }
28}
1// PostsDAO.kt
2@Dao
3interface PostsDAO {
4 // Obtiene todos los posts como un Flow para observar cambios en tiempo real.
5 @Query("SELECT * FROM posts")
6 fun getPosts(): Flow<List<Post>>
7
8 // Inserta una lista de posts. Si ya existen, los reemplaza.
9 @Insert(onConflict = OnConflictStrategy.REPLACE)
10 suspend fun insertAllPosts(posts: List<Post>)
11}
Información
Flow<List<Post>>
: devuelve un flujo de datos que se actualizará automáticamente cuando los datos cambien en la base de datos (ideal para Jetpack Compose).
7.6.3. Datasource local
1// LocalDatasource.kt
2class LocalDatasource(private val dao: PostsDAO) {
3
4 // Obtiene todos los posts desde la base de datos local.
5 fun getPosts(): Flow<List<Post>> = dao.getPosts()
6
7 // Inserta una lista de posts en la base de datos local.
8 suspend fun insertAllPosts(posts: List<Post>) {
9 dao.insertAllPosts(posts)
10 }
11}
Como se puede observar, la clase LocalDatasource.kt
es bastante sencilla y separa la lógica del Datasource local del remoto.
7.6.4. Repository
Ahora el Repository deberá inyectar ambos Datasources.
1class Repository(
2 private val remoteDatasource: RemoteDatasource,
3 private val localDatasource: LocalDatasource
4) ...
Y el método getPosts()
se modificará de la siguiente forma para almacenar los posts en la BD y devolver de esta cuando se produzca algún error de la API.
1suspend fun getPosts(): List<Post>? {
2 return try {
3 val response = remoteDatasource.getPosts()
4 if (response.isSuccessful) {
5 val posts = response.body() ?: emptyList()
6 // Almacenar los posts obtenidos en la base de datos local.
7 localDatasource.insertAllPosts(posts)
8 posts
9 } else {
10 Log.e("Repository", "Error response: ${response.code()} - ${response.message()}")
11 localDatasource.getPosts().first() // Se obtienen los posts almacenados localmente.
12 }
13 } catch (e: Exception) {
14 Log.e("Repository", e.message, e)
15 val dbdata = localDatasource.getPosts().first()
16 if (dbdata.isNotEmpty())
17 dbdata
18 else throw e // Lanzar la excepción para que el ViewModel pueda manejarla.
19 }
20}
Información
localDatasource.insertAllPosts(posts)
guarda los datos obtenidos de la API en la BD, de esta manera, aunque el usuario esté sin internet, podrá ver los datos almacenados.
first()
se utiliza para obtener el primer valor emitido por el flujo y luego cancelar la suscripción. Se utiliza cuando no se necesita observar cambios continuamente.
Observa que se controla en el bloque del catch
si se debe lanzar la excepción o no, si hay datos en la BD no se lanzará.
7.6.5. ViewModel
Por último, habrá que modificar el ViewModel
de la siguiente forma.
1class MainViewModel(application: Application) : AndroidViewModel(application) {
2 // Se inicializa el repositorio y el datasource.
3 private val repository: Repository
4 private val remoteDatasource: RemoteDatasource
5 private val localDatasource: LocalDatasource
6
7 ...
8
9 init {
10 // Se inicializa la base de datos local y el DAO.
11 val database = AppDatabase.getInstance(application)
12 val dao = database.postsDAO()
13
14 remoteDatasource = RemoteDatasource()
15 localDatasource = LocalDatasource(dao)
16 repository = Repository(remoteDatasource, localDatasource)
17 }
18
19 ...
20}
Básicamente se añade el LocalDatasource
y se establece la conexión a la BD en el contructor init
. También se modifica la declaración de la clase.
Código completo
Fuentes
Objetivos de este tema
- Reproducir audio y video en una app Android usando
MediaPlayer
y ExoPlayer
.
- Capturar fotos y vídeos mediante la cámara del dispositivo.
- Acceder a la galería de imágenes del dispositivo.
- Usar del micrófono del disposivo.
- Utilizar sensores básicos del dispositivo como el acelerómetro y el sensor de luz.
- Integrar funcionalidades en una arquitectura moderna (MVVM + Clean Architecture) con Jetpack Compose.
8.1. Reproducción de audio y video
En Android hay diferentes maneras de reproducir contenido multimedia. Las más comunes son MediaPlayer
(nativa) y ExoPlayer
, esta última más moderna y recomendada para Compose.
Esta clase está integrada en Android, por lo que no necesita dependencias externas, es sencilla de utilizar pero algo limitada y poco personalizable.
1val mediaPlayer = MediaPlayer.create(ctxt, R.raw.epic_cinematic)
2mediaPlayer.start() // Reproduce.
3mediaPlayer.pause() // Pausa la reproducción.
4mediaPlayer.stop() // Detiene.
5mediaPlayer.prepare() // Prepara el MediaPlayer para poder reproducirlo de nuevo.
6mediaPlayer.release() // // Libera recursos del MediaPlayer.
Código completo
Nota
MediaPlayer
no está recomendado para reproducción en streaming o formatos complejos.
8.1.2. ExoPlayer
ExoPlayer
es una biblioteca de código abierto desarrollada por Google, ahora ya forma parte de Jetpack Media3. Es más potente y flexible que MediaPlayer
y se encuentra actualizada. Además de ser la recomendación actual.
Dependencias necesarias para incluir ExoPlayer
en el proyecto.
1// ExoPlayer
2implementation("androidx.media3:media3-exoplayer:1.8.0")
3implementation("androidx.media3:media3-ui:1.8.0")
4implementation("androidx.media3:media3-common:1.8.0")
El siguiente Composable utiliza un AndroidView
para incrustar el PlayerView
de ExoPlayer
.
1import androidx.compose.foundation.layout.fillMaxWidth
2import androidx.compose.runtime.Composable
3import androidx.compose.runtime.remember
4import androidx.compose.ui.Modifier
5import androidx.compose.ui.platform.LocalContext
6import androidx.compose.ui.viewinterop.AndroidView
7import androidx.media3.common.MediaItem
8import androidx.media3.exoplayer.ExoPlayer
9import androidx.media3.ui.PlayerView
10
11// VideoPlayer.kt
12@Composable
13fun VideoPlayer(videoUrl: String) {
14 val ctxt = LocalContext.current
15
16 // Se crea la instancia de ExoPlayer.
17 val exoPlayer = remember {
18 ExoPlayer.Builder(ctxt).build().apply {
19 val mediaItem = MediaItem.fromUri(videoUrl)
20 setMediaItem(mediaItem)
21 prepare()
22 playWhenReady = true
23 }
24 }
25
26 // Integración de la vista nativa de Android (PlayerView) en Compose.
27 AndroidView(
28 factory = { ctx ->
29 PlayerView(ctx).apply {
30 player = exoPlayer
31 useController = true // muestra controles
32 }
33 },
34 modifier = Modifier.fillMaxWidth(),
35 onRelease = { playerView ->
36 playerView.player?.release()
37 }
38 )
39}
La llamada desde la pantalla que muestre el vídeo será simplemente pasándole una URL.
1@Composable
2fun VideoScreen() {
3 VideoPlayer(videoUrl = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")
4}
Código completo
Puedes obtener más vídeos de muestra en este repositorio.
8.2. Captura de imágenes y vídeo
8.3. Galería de imágenes
8.4. Uso del micrófono
8.5. Sensores básicos
Fuentes
Versión anterior
Aquí puedes consultar la versión anterior de la documentación utilizada en el módulo PMDM de CFGS.