Jetpack Compose desde cero
El toolkit declarativo oficial para interfaces Android, explicado sin saltos: composables, estado, Modifier, Material 3, navegación, animaciones y pruebas. Todo construyendo una aplicación real de notas que crece capítulo a capítulo hasta quedar lista para producción.
1 · De vistas a composición
Básico ~12 minDurante una década las interfaces Android se escribieron en XML (archivos de layout) y se manipulaban desde Kotlin con código imperativo: busca la vista, cámbiale el texto, muévela, ocúltala. Ese estilo genera dos problemas clásicos: estado duplicado (el XML dice una cosa, tus variables otra) y actualizaciones olvidadas (cada camino de ejecución debe acordarse de refrescar cada vista).
Jetpack Compose invierte el modelo: describes la interfaz como una función del estado actual — igual que un componente de React o Flutter. No mutas vistas: generas el árbol completo a partir de los datos y el framework decide qué redibujar.
| Concepto | Sistema de vistas (XML) | Compose |
|---|---|---|
| Interfaz | Archivos XML + clases View | Funciones Kotlin |
| Actualización | Imperativa: textView.text = ... | Automática ante cambios de estado |
| Estado | Disperso en vistas y variables | Fuente única que «baja» a la interfaz |
| Reutilización | Includes, fragments, custom views | Llamar funciones con parámetros |
| Herramienta oficial | Legado (sigue existiendo) | Recomendada para proyectos nuevos |
El mismo resultado, dos mundos
// Imperativo: TÚ gestionas cada cambio
fun mostrarBienvenida(usuario: String) {
titulo.text = "Hola, $usuario"
titulo.visibility = View.VISIBLE
boton.isEnabled = usuario.isNotEmpty()
}
// Declarativo: la interfaz es una FUNCIÓN del estado
@Composable
fun Bienvenida(usuario: String) {
Text("Hola, $usuario")
Button(
onClick = { /* ... */ },
enabled = usuario.isNotEmpty()
) { Text("Continuar") }
}- En Compose no existe «actualizar la vista»: cambia el estado y la interfaz se regenera.
- Las funciones de interfaz se llaman composables y se anidan como componentes.
- Tu experiencia en servidores ayuda aquí: pensar en «render(state)» puro es pensar funcional.
ComposeView puedes mezclar). Pero todo
proyecto nuevo merece empezar con Compose.Puntos clave
- Composable = función que recibe estado y devuelve interfaz.
- Nunca mutas vistas: cambias datos y el framework redibuja.
- Declarativo = menos errores de sincronización entre datos y pantalla.
2 · Tu primer proyecto
Básico ~15 minInstala Android Studio (incluye emulador y SDK) y crea un proyecto nuevo con la plantilla Empty Activity: ya viene configurada para Compose. Nuestra aplicación guía se llamará Notas.
Anatomía mínima
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { // aquí empieza TODO tu mundo Compose
NotasTheme { // tema Material 3 del proyecto
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Saludo("Percy")
}
}
}
}
}
@Composable
fun Saludo(nombre: String) {
Text(text = "¡Hola, $nombre!")
}Qué trae el build.gradle.kts
android {
buildFeatures { compose = true } // activa el compilador Compose
}
dependencies {
// El BOM fija versiones compatibles entre bibliotecas Compose
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.3")
}- BOM (bill of materials): declara versiones sin números; todas las piezas quedan alineadas.
- El compilador de Compose es un plugin de Kotlin: usa versiones compatibles.
setContentreemplaza por completo asetContentView(R.layout...).
Ejecutar
Crea un dispositivo virtual (Device Manager → Create Virtual Device), elige un teléfono reciente y pulsa Run. La primera compilación tarda; después, los cambios son casi instantáneos.
Puntos clave
- Plantilla Empty Activity = Compose listo; nada de XML obligatorio.
setContentabre el mundo declarativo dentro de la Activity.- El BOM evita el infierno de versiones entre módulos de Compose.
3 · Composables y recomposición
Básico ~15 minUn composable es una función anotada con @Composable que
describe una parte de la interfaz. Se componen entre sí como funciones
puras: reciben datos y emiten eventos hacia arriba.
@Composable
fun TarjetaNota(nota: Nota, alAbrir: () -> Unit) {
Card(onClick = alAbrir) {
Column(Modifier.padding(16.dp)) {
Text(nota.titulo, style = MaterialTheme.typography.titleMedium)
Text(nota.contenido,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall)
}
}
}La recomposición es inteligente… y caprichosa
Cuando un estado cambia, Compose vuelve a ejecutar los composables que dependen de él — solo esos. A este redibujado se le llama recomposición. Pero el framework se reserva libertades importantes:
- Puede ejecutar tus composables muchas veces, incluso dentro de un mismo fotograma.
- Puede ejecutarlos en cualquier orden o saltarse los que no cambiaron.
- Puede abandonar una recomposición si llega otro cambio antes de terminar.
Estabilidad: por qué a veces no se salta nada
Compose omite recomponer si los parámetros son iguales… pero solo puede saberlo con tipos «estables» (inmutables u observables): primitivos, funciones, data classes inmutables. Las interfaces tipo List son inestables por defecto; usa colecciones inmutables o anótalas:
@Immutable
data class Nota(
val id: Long,
val titulo: String,
val contenido: String
)Puntos clave
- Composables = funciones pequeñas, puras y reutilizables.
- Recomposición redibuja solo lo que cambió; asume que puede repetirse en cualquier orden.
- Sin efectos secundarios en el cuerpo del composable.
- Tipos estables activan las optimizaciones automáticas.
4 · Layouts: Column, Row y Box
Básico ~15 minCon tres contenedores construyes el 90 % de las pantallas:
Column apila en vertical, Row en horizontal y
Box superpone. La separación uniforme vive en
Arrangement.spacedBy; el alineado, en los parámetros
alignment.
| Contenedor | Eje principal | Parámetros clave |
|---|---|---|
Column | Vertical ↓ | verticalArrangement, horizontalAlignment |
Row | Horizontal → | horizontalArrangement, verticalAlignment |
Box | Ninguno (superpone) | contentAlignment; hijos con Modifier.align |
El editor de notas, pieza a pieza
@Composable
fun EditorNota() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Cabecera: título a la izquierda, acción a la derecha
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Nueva nota", style = MaterialTheme.typography.headlineSmall)
IconButton(onClick = { /* guardar */ }) {
Icon(Icons.Default.Check, contentDescription = "Guardar")
}
}
OutlinedTextField(
value = "", onValueChange = {},
label = { Text("Título") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = "", onValueChange = {},
label = { Text("Contenido") },
modifier = Modifier
.fillMaxWidth()
.weight(1f) // ocupa TODO el espacio restante
)
}
}weight(1f): reparte el espacio sobrante dentro de Column/Row — el flexbox de Compose.Arrangement.spacedBy(12.dp): separación uniforme sin Spacer sueltos.- Anida libremente: un Row dentro de un Column dentro de un Box…
Superponer con Box
Box {
Image(foto, null, Modifier.fillMaxSize())
Text(
nota.titulo,
Modifier
.align(Alignment.BottomStart) // anclado abajo-izquierda
.padding(12.dp),
color = Color.White
)
}Puntos clave
- Column/Row/Box cubren casi todo; se combinan anidados.
weightreparte espacio libre; sin weight, cada elemento mide lo suyo.- Box es para capas: texto sobre imágenes, insignias, fondos decorativos.
5 · Modifier: el ADN del diseño
Intermedio ~15 minCasi todo lo visual —tamaño, relleno, fondo, borde, clics— se aplica con Modifier: una cadena de ajustes que envuelve al composable. Cada eslabón decora o restringe al anterior, así que el orden importa.
// Fondo GRANDE y contenido con margen interior:
Text("Notas",
Modifier
.background(Color(0xFFEDE7F6))
.padding(16.dp))
// Margen exterior GRANDE y fondo pequeño (¡diferente!):
Text("Notas",
Modifier
.padding(16.dp)
.background(Color(0xFFEDE7F6)))| Modifier | Qué hace |
|---|---|
fillMaxWidth() / fillMaxSize() | Ocupa todo el ancho / todo el espacio del padre |
width(120.dp) / heightIn(min=) | Tamaño fijo o restricciones |
padding() | Espacio alrededor del contenido (después de sí mismo = margen) |
background() / border() | Fondo y borde |
clip(RoundedCornerShape(12.dp)) | Recorta con esquinas redondeadas |
clickable { } | Hace clicable (incluye onda de toque accesible) |
weight(1f) | Solo en Column/Row: reparte espacio sobrante |
Encadenar con criterio
@Composable
fun ChipNota(texto: String, alTocar: () -> Unit) {
Text(
text = texto,
modifier = Modifier
.clip(RoundedCornerShape(50)) // 1. recorta a píldora
.background(MaterialTheme.colorScheme.secondaryContainer) // 2. fondo dentro del recorte
.clickable(onClick = alTocar) // 3. zona táctil = área visible
.padding(horizontal = 12.dp, vertical = 6.dp)
)
}paddingantes debackground= margen exterior; después = relleno interior.clickabletrasclip+backgroundlimita el toque al área dibujada.- Extrae modificadores repetidos en variables (
val chipMod = Modifier...) o en composables propios.
dp para tamaños y
sp para texto. El sistema escala ambos según la pantalla; sp además
respeta el tamaño de fuente que el usuario eligió en ajustes.Puntos clave
- Modifier se lee de afuera hacia adentro; el orden cambia el resultado.
- Cada modificador crea una capa: piensa en cajas anidadas.
- dp para medidas, sp para tipografía, siempre.
6 · Material 3 esencial
Intermedio ~18 minMaterial 3 trae los componentes listos: botones, campos, tarjetas,
diálogos… y el andamiaje de pantalla (Scaffold) que coordina
barra superior, botón flotante y avisos emergentes.
Scaffold: el esqueleto de pantalla
@Composable
fun PantallaNotas(alNuevaNota: () -> Unit) {
val snackbar = remember { SnackbarHostState() }
val alcance = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(title = { Text("Mis notas") })
},
floatingActionButton = {
FloatingActionButton(onClick = alNuevaNota) {
Icon(Icons.Default.Add, contentDescription = "Nueva nota")
}
},
snackbarHost = { SnackbarHost(snackbar) }
) { relleno -> // insets: respeta barras del sistema
Column(Modifier.padding(relleno)) {
Text("Aquí va tu listado")
}
}
}Componentes del día a día
// Botones por jerarquía visual
Button(onClick = { }) { Text("Guardar") } // acción principal
OutlinedButton(onClick = { }) { Text("Cancelar") } // secundaria
TextButton(onClick = { }) { Text("Más tarde") } // terciaria
// Campo con etiqueta y error
OutlinedTextField(
value = titulo,
onValueChange = { titulo = it },
label = { Text("Título de la nota") },
isError = titulo.isBlank(),
supportingText = { if (titulo.isBlank()) Text("Requerido") },
singleLine = true
)
// Selección
Checkbox(checked = archivada, onCheckedChange = { archivada = it })
Switch(checked = fijada, onCheckedChange = { fijada = it })
// Diálogo de confirmación
if (mostrarBorrar) {
AlertDialog(
onDismissRequest = { mostrarBorrar = false },
title = { Text("¿Borrar nota?") },
confirmButton = {
TextButton(onClick = { borrar(); mostrarBorrar = false }) {
Text("Borrar", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { mostrarBorrar = false }) { Text("Cancelar") }
}
)
}- El parámetro
relleno(content padding) del Scaffold evita que tus contenidos queden bajo las barras. Snackbarpara avisos desechables («Nota guardada» con acción Deshacer).- Usa la jerarquía Button/Outlined/Text en lugar de inventar estilos propios.
contentDescription en iconos y semántica explícita.Puntos clave
- Scaffold coordina topBar + FAB + Snackbar + insets del sistema.
- Tres niveles de botón comunican jerarquía de acciones.
- Campo con
isErrorysupportingText: validación visible sin esfuerzo.
7 · Temas y modo oscuro
Intermedio ~15 minUn tema define colores, tipografías y formas que todos los
componentes heredan. En Compose vives dentro de
MaterialTheme: en lugar de hardcodear colores, pides los del
tema y el modo oscuro sale gratis.
// ui/theme/Theme.kt
private val Claro = lightColorScheme(
primary = Color(0xFF6D28D9),
onPrimary = Color.White,
secondary = Color(0xFF4F46E5),
background = Color(0xFFFDF7FF)
)
private val Oscuro = darkColorScheme(
primary = Color(0xFFCFBCFF),
onPrimary = Color(0xFF381E72),
secondary = Color(0xFFC5CAFF),
background = Color(0xFF1C1B1F)
)
@Composable
fun NotasTheme(
oscuro: Boolean = isSystemInDarkTheme(),
// Color dinámico: paleta generada desde el fondo de pantalla (Android 12+)
dinamico: Boolean = Build.VERSION.SDK_INT >= 31,
contenido: @Composable () -> Unit
) {
val esquema = when {
dinamico -> if (oscuro) dynamicDarkColorScheme(LocalContext.current)
else dynamicLightColorScheme(LocalContext.current)
oscuro -> Oscuro
else -> Claro
}
MaterialTheme(
colorScheme = esquema,
typography = Tipografia,
shapes = Formas,
content = contenido
)
}Consumir el tema
@Composable
fun EtiquetaPrioridad() {
Text(
"Urgente",
// Nunca colores fijos: siempre tokens del tema
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier
.clip(MaterialTheme.shapes.small) // forma del tema
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}| Rol del color | Uso típico |
|---|---|
primary / onPrimary | Acciones principales; «on» es el texto encima |
secondary | Acciones de apoyo, chips |
surface / onSurface | Tarjetas y fondos de componentes |
error / onError | Errores y destructivos |
*Container / on*Container | Fondos tenues con su texto contrastado |
Color(0xFF...) dentro de un
composable de pantalla, casi seguro estás rompiendo el modo oscuro.
Los colores fijos solo en la definición del tema.Puntos clave
- Pide tokens (
MaterialTheme.colorScheme.*), jamás colores crudos. - Dos esquemas (claro/oscuro) + color dinámico = toda la personalización.
- Prueba tu aplicación con el modo oscuro activo desde el primer día.
8 · Estado y elevación de estado
Intermedio ~18 minEl estado es cualquier valor que puede cambiar con el tiempo y
que afecta la interfaz: el texto de un campo, la lista de notas, una casilla.
En Compose se declara con mutableStateOf: al cambiar, todo
composable que lo lee se recompone.
@Composable
fun Contador() {
var toques by remember { mutableIntStateOf(0) } // estado local
Button(onClick = { toques++ }) {
Text("Toques: $toques") // se redibuja solo al cambiar
}
}Composables con y sin estado
- Sin estado (stateless): recibe valores y funciones; no guarda nada. Reutilizable y trivial de probar.
- Con estado (stateful): posee su propio
remember. Conveniente pero acoplado.
La técnica central es la elevación de estado (state hoisting): subir el estado al llamador dejando el componente «sin memoria». El patrón estándar en Compose:
// Componente SIN estado: valor + función para cambiarlo
@Composable
fun CampoTitulo(
titulo: String,
alCambiar: (String) -> Unit,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = titulo,
onValueChange = alCambiar,
label = { Text("Título") },
modifier = modifier.fillMaxWidth()
)
}
// El dueño del estado conecta todo
@Composable
fun EditorNotaViewModel() {
val vm: NotasViewModel = viewModel()
val borrador by vm.borrador.collectAsStateWithLifecycle()
Column {
CampoTitulo(
titulo = borrador.titulo,
alCambiar = vm::cambiarTitulo
)
// ... más campos con el mismo patrón
}
}Los parámetros siguen la convención:
valor: T, alCambiar: (T) -> Unit, modifier: Modifier = Modifier.
Así el mismo CampoTitulo sirve en el editor, en un diálogo y en
las pruebas.
¿Dónde vive cada estado?
| Tipo de estado | Dónde ponerlo |
|---|---|
| Lógica de interfaz (pestaña activa) | Composable padre cercano |
| Borrador de formulario compartido | ViewModel |
| Datos de negocio (notas) | Repositorio + ViewModel |
| Animación pasajera | Local con remember |
Puntos clave
mutableStateOfconvierte un valor en observable para la composición.- Prefiere componentes sin estado: valor + callback + modifier.
- Un solo dueño por estado: fuente única de verdad, cero duplicación.
9 · remember, Saveable y ViewModel
Intermedio ~15 minTres herramientas protegen el estado ante tres niveles de destrucción: recomposición, cambio de configuración y muerte del proceso. Elegir mal es la fuente número uno del bug «se borró todo al girar».
| Herramienta | Sobrevive a recomposición | A rotación | A muerte del proceso |
|---|---|---|---|
| nada | No | No | No |
remember | Sí | No | No |
rememberSaveable | Sí | Sí | Sí (Bundle) |
| ViewModel | Sí | Sí | No |
| Room / DataStore | Sí | Sí | Sí |
@Composable
fun BuscadorNotas(vm: NotasViewModel) {
// Texto mientras se teclea: si muere, nadie sufre
var texto by remember { mutableStateOf("") }
// Filtro aplicado que el usuario esperaría conservar al rotar:
var filtroGuardado by rememberSaveable { mutableStateOf("") }
// Resultados (lista completa desde la base): ViewModel
val resultados by vm.buscar(filtroGuardado)
.collectAsStateWithLifecycle(initialValue = emptyList())
}Guardar tipos propios con un Saver
rememberSaveable solo acepta tipos que caben en un Bundle.
Para una data class propia, define cómo convertirla:
data class Filtro(val categoria: String, val soloPendientes: Boolean)
val filtroSaver = listSaver<Filtro, Any>(
save = { listOf(it.categoria, it.soloPendientes) },
restore = { Filtro(it[0] as String, it[1] as Boolean) }
)
var filtro by rememberSaveable(stateSaver = filtroSaver) {
mutableStateOf(Filtro("todas", false))
}- El Bundle tiene límites estrictos: nada de imágenes ni listas enormes ahí.
- ViewModel + Room juntos cubren rotación Y muerte del proceso.
- Regla práctica: UI pasajera → remember; campos → Saveable; datos → ViewModel.
Puntos clave
- Cada nivel de protección responde a un tipo distinto de destrucción.
listSaver/mapSaverextienden Saveable a tus data classes.- Prueba SIEMPRE rotando en medio de cada flujo.
10 · Flujo unidireccional de datos
Avanzado ~15 minEl flujo unidireccional (UDF) es la arquitectura oficial de Compose: el estado baja (ViewModel → interfaz) y los eventos suben (interfaz → ViewModel). La interfaz nunca modifica el estado directamente: solo pide cambios.
// 1. Estado de pantalla inmutable: una foto de la realidad
data class NotasUiState(
val notas: List<Nota> = emptyList(),
val cargando: Boolean = true,
val mensaje: String? = null
)
class NotasViewModel(private val repo: NotasRepositorio) : ViewModel() {
private val _estado = MutableStateFlow(NotasUiState())
val estado: StateFlow<NotasUiState> = _estado.asStateFlow()
init { observarNotas() }
private fun observarNotas() = viewModelScope.launch {
repo.observar().collect { notas ->
_estado.update { it.copy(notas = notas, cargando = false) }
}
}
// 2. Eventos que suben: funciones públicas y explícitas
fun borrar(nota: Nota) = viewModelScope.launch { repo.borrar(nota) }
fun deshacerBorrado(id: Long) = viewModelScope.launch { repo.restaurar(id) }
}La pantalla solo renderiza y reporta
@Composable
fun NotasScreen(vm: NotasViewModel = viewModel()) {
val estado by vm.estado.collectAsStateWithLifecycle()
val snackbar = remember { SnackbarHostState() }
// Reacción a eventos de una sola vez (mensaje efímero)
LaunchedEffect(estado.mensaje) {
estado.mensaje?.let {
val resultado = snackbar.showSnackbar(it, actionLabel = "Deshacer")
if (resultado == SnackbarResult.ActionPerformed) vm.deshacerUltimo()
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { relleno ->
when {
estado.cargando -> Indicador(Modifier.padding(relleno))
else -> ListaNotas(
notas = estado.notas,
alBorrar = vm::borrar, // evento ↑
modifier = Modifier.padding(relleno)
)
}
}
}
@Composable
private fun ListaNotas(
notas: List<Nota>,
alBorrar: (Nota) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(modifier) {
items(notas, key = { it.id }) { nota ->
TarjetaNota(nota, alBorrar = { alBorrar(nota) })
}
}
}- Beneficios: la pantalla es trivial de probar (le das un estado, afirmas salida); los cambios de datos tienen un único punto.
- Los lambdas (
alBorrar) viajan hacia abajo como parte del estado de eventos. - Mensajes efímeros (snackbar) NO van en el estado permanente: usa canales de eventos o marca consumido.
Puntos clave
- Estado baja, eventos suben: la interfaz declara, nunca decide sola.
- Un UiState inmutable por pantalla evita estados imposibles.
- Funciones del ViewModel con nombre de intención:
borrar,deshacer.
11 · Efectos secundarios seguros
Avanzado ~18 minEl capítulo 3 prohibió efectos dentro de composables. Pero a veces hay que lanzarlos: cargar datos al abrir pantalla, registrar un receptor, reaccionar a un scroll. Compose ofrece APIs que ejecutan el efecto en el momento correcto y lo limpian solo.
LaunchedEffect: corrutina atada a la composición
@Composable
fun DetalleNota(notaId: Long, vm: NotasViewModel = viewModel()) {
LaunchedEffect(notaId) {
// Corre UNA VEZ por cada valor distinto de notaId.
// Si la nota cambia o la pantalla muere, se cancela sola.
vm.cargar(notaId)
}
}DisposableEffect: registrar y liberar
@Composable
fun ConectividadActiva(alCambiar: (Boolean) -> Unit): Boolean {
val contexto = LocalContext.current
var enLinea by remember { mutableStateOf(true) }
DisposableEffect(contexto) {
val gestor = contexto.getSystemService(ConnectivityManager::class.java)
val peticion = NetworkRequest.Builder().build()
val red = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(red: Network) { enLinea = true; alCambiar(true) }
override fun onLost(red: Network) { enLinea = false; alCambiar(false) }
}
gestor.registerNetworkCallback(peticion, red)
onDispose { gestor.unregisterNetworkCallback(red) } // limpieza garantizada
}
return enLinea
}rememberCoroutineScope: eventos fuera de la composición
val alcance = rememberCoroutineScope()
Button(onClick = {
// Dentro de un callback NO puedes usar LaunchedEffect:
// aquí es donde corresponde lanzar manualmente.
alcance.launch { snackbar.showSnackbar("Nota guardada") }
}) { Text("Guardar") }Valores derivados y observación fina
// derivedStateOf: calcula SOLO cuando el resultado real cambia
val primeraVisible by estadoLista.derivedStateOf {
lista.firstVisibleItemIndex > 0
}
BackHandler(enabled = primeraVisible) { desplazarArriba() }
// snapshotFlow: convierte valores leídos en composición en un Flow
LaunchedEffect(cajaTexto) {
snapshotFlow { cajaTexto.text.isNotEmpty() }
.distinctUntilChanged()
.collect { hayTexto -> vm.habilitarGuardado(hayTexto) }
}| API | Úsala para… |
|---|---|
LaunchedEffect(clave) | Efectos asíncronos ligados a parámetros |
DisposableEffect(clave) | Registros con limpieza obligatoria |
rememberCoroutineScope | Lanzar desde callbacks de usuario |
derivedStateOf | Derivados que cambian menos que sus entradas |
snapshotFlow | Observar valores de composición como Flow |
Puntos clave
- Cada efecto tiene dueño y ciclo: nace, se repite según claves y muere limpio.
- Las claves de reinicio (
notaId) son tu contrato: elígelas con precisión. - Nunca lances corrutinas «desnudas» en composición ni en callbacks sin scope.
12 · Listas perezosas en serio
Intermedio ~18 minLas listas son el corazón de «Notas». Los contenedores perezosos
(LazyColumn, LazyRow, LazyVerticalGrid)
componen solo lo visible, pero exigirán disciplina con las claves y la
estabilidad para mantener 60 fps.
@Composable
fun ListaNotas(
notas: List<Nota>,
alAbrir: (Long) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Cabecera fija dentro del scroll
item(key = "cabecera") {
Text("Tienes ${notas.size} notas",
style = MaterialTheme.typography.titleMedium)
}
items(
items = notas,
key = { it.id }, // identidad estable
contentType = { "nota" } // mismo tipo → reciclaje efectivo
) { nota ->
TarjetaNota(nota, alAbrir = { alAbrir(nota.id) })
}
}
}Detalles que marcan la diferencia
- Claves estables: el identificador de base de datos. Con claves, al borrar el elemento 3 de 500, Compose anima exactamente ese.
- Animación automática de cambios:
Modifier.animateItem()en el ítem anima inserciones, borrados y movimientos. - Scroll programático: guarda el estado con
rememberLazyListState()y usaanimateScrollToItem.
@Composable
fun ConBotonFlotanteInteligente(estadoLista: LazyListState) {
// Aparece solo cuando bajaste
val mostrar by remember {
derivedStateOf { estadoLista.firstVisibleItemIndex > 2 }
}
AnimatedVisibility(mostrar) {
ExtendedFloatingActionButton(onClick = {
alcance.launch { estadoLista.animateScrollToItem(0) }
}) { Text("Arriba") }
}
}Rendimiento: lo que duele
| Error típico | Solución |
|---|---|
| Cargar 10 000 notas en memoria | Paginación en SQL (LIMIT) o Paging 3 |
| Ítem recomputa todo por cada scroll | Parámetros estables + remember(campo) en cálculos caros |
| LazyColumn anidado en LazyColumn vertical | Aplana la jerarquía o usa item { } agrupando |
| Índice como clave | Identificadores reales y únicos |
Puntos clave
- Claves + contentType = reciclaje y animaciones correctos.
derivedStateOfpara estados derivados del scroll sin recomposiciones extra.- Rendimiento se gana en los DATOS (paginar), no solo en la interfaz.
13 · Navegación
Intermedio ~15 min«Notas» necesita dos destinos: el listado y el editor de una nota concreta. Navigation Compose los declara como rutas con argumentos.
implementation("androidx.navigation:navigation-compose:2.8.5")Grafo con rutas tipadas (Navigation 2.8+)
// Las rutas son objetos serializables: cero strings mágicos
@Serializable data object Listado
@Serializable data class Editor(val notaId: Long = -1L) // -1 = nueva
@Composable
fun AppNotas() {
val controlador = rememberNavController()
NavHost(controlador, startDestination = Listado) {
composable<Listado> {
PantallaNotas(
alAbrir = { id -> controlador.navigate(Editor(id)) },
alNueva = { controlador.navigate(Editor()) }
)
}
composable<Editor> { entrada ->
val destino = entrada.toRoute<Editor>()
PantallaEditor(
notaId = destino.notaId,
alGuardar = { controlador.popBackStack() } // vuelve y limpia
)
}
}
}toRoute<T>()reconstruye el objeto con sus argumentos: adiós a parsear strings.- Atrás hace pop automáticamente; para flujos terminados usa
popBackStack(), no navegues «hacia adelante» al listado. - Pasa identificadores (Long), nunca objetos completos: el editor recarga la nota desde la base.
Resultado del editor compartido
// El editor guarda y devuelve el resultado por la pila anterior:
controlador.previousBackStackEntry
?.savedStateHandle?.set("notaGuardada", true)
controlador.popBackStack()
// El listado lo observa al volver:
controlador.currentBackStackEntry
?.savedStateHandle?.getStateFlow("notaGuardada", false)
?.collectAsState()Puntos clave
- Rutas serializables +
toRoute: navegación sin strings frágiles. - Argumentos mínimos (ids); los datos vivos se leen de Room.
- La pila es la dueña del «volver»: no la pelees.
14 · Animaciones
Avanzado ~18 minCompose anima casi todo con APIs de una línea. La regla de oro: anima cambios de estado, no fotogramas a mano — tú cambias el dato, el interpolador hace el resto.
Animar un valor con animate*AsState
@Composable
fun TarjetaNota(nota: Nota, archivada: Boolean) {
val colorFondo by animateColorAsState(
targetValue = if (archivada)
MaterialTheme.colorScheme.surfaceVariant
else
MaterialTheme.colorScheme.surface,
animationSpec = tween(300),
label = "fondoNota"
)
Card(colors = CardDefaults.cardColors(containerColor = colorFondo)) { }
}Apariciones y desvanecimientos
// Entrada/salida con tamaño, fundido y deslizamiento
AnimatedVisibility(
visible = mostrarFormulario,
enter = fadeIn(tween(200)) + slideInVertically { it / 2 },
exit = fadeOut(tween(150)) + slideOutVertically { it / 2 }
) {
FormularioNuevaNota()
}
// Cambiar contenido con fundido (pestañas, estados de carga)
Crossfade(estadoActual, label = "pantalla") { estado ->
when (estado) {
Cargando -> Indicador()
Listo -> ListaNotas()
}
}
// El contenedor crece/decrece suave cuando cambia el contenido
Column(Modifier.animateContentSize()) { /* ... */ }| API | Cuándo usarla |
|---|---|
animateFloatAsState y familia | Un valor que cambia (color, tamaño, progreso) |
AnimatedVisibility | Aparecer/desaparecer elementos |
Crossfade | Intercambiar pantallas o contenidos |
animateContentSize() | Contenedores cuyo alto varía |
Modifier.animateItem() | Inserciones/borrados en listas perezosas |
- Duración estándar: 150–300 ms. Más lento se siente pesado; más rápido, invisible.
spring()para gestos directos (rebote natural);tweenpara transiciones de interfaz.- Respeta la accesibilidad: las preferencias de «quitar animaciones» del sistema reducen duraciones automáticamente en componentes Material.
label de las APIs de
animación aparecen en Android Studio al depurar — cuestan nada y ahorran horas.Puntos clave
- Anima estados, no dibujos: cambia el valor, Compose interpola.
- Una API por situación: Visibility para entrar/salir, Crossfade para cambiar, AsState para valores.
- Movimiento sutil y consistente = sensación de calidad.
15 · Canvas y gestos
Avanzado ~18 minCuando ningún componente encaja —un anillo de progreso de notas por
categoría, un gráfico— dibujas con Canvas. Y cuando la
interacción va más allá del clic, defines gestos con
pointerInput.
Anillo de progreso dibujado a mano
@Composable
fun AnilloProgreso(progreso: Float, modifier: Modifier = Modifier) {
val trazoAnimado by animateFloatAsState(progreso, label = "anillo")
Canvas(modifier.size(120.dp)) {
val borde = 14.dp.toPx()
val diametro = size.minDimension - borde
val topLeft = Offset((size.width - diametro) / 2,
(size.height - diametro) / 2)
// Pista completa
drawArc(
color = Color.LightGray.copy(alpha = 0.3f),
startAngle = -90f, sweepAngle = 360f, useCenter = false,
topLeft = topLeft, size = Size(diametro, diametro),
style = Stroke(borde, cap = StrokeCap.Round)
)
// Progreso animado
drawArc(
color = Color(0xFF6D28D9),
startAngle = -90f, sweepAngle = 360f * trazoAnimado,
useCenter = false,
topLeft = topLeft, size = Size(diametro, diametro),
style = Stroke(borde, cap = StrokeCap.Round)
)
}
}Gestos personalizados
Modifier.pointerInput(Unit) { // Unit: se crea una vez
detectDragGestures(
onDragStart = { inicio = it },
onDragEnd = { confirmarDeslizamiento() }
) { cambio, _ ->
desplazamiento += cambio // mueve tu tarjeta con el dedo
}
}
// Toques simples y dobles:
Modifier.pointerInput(Unit) {
detectTapGestures(
onTap = { abrir() },
onLongPress = { mostrarMenu() }
)
}Para el gesto estrella —deslizar una nota para archivarla— no lo escribas a mano: usa el componente oficial:
SwipeToDismissBox(
state = estadoDeslizamiento,
backgroundContent = { IconoArchivoFondo(estadoDeslizamiento) },
content = { TarjetaNota(nota) },
onDismiss = { vm.archivar(nota) }
)pointerInput(clave): cambia la clave si dependes de parámetros que alteren el detector.- Dibuja con tokens del tema cuando pueda cambiar de modo oscuro.
- Gestos propios necesitan semántica accesible (
semantics { }) para TalkBack.
Puntos clave
- Canvas = coordenadas locales del composable, sin estados ocultos.
- Combina animaciones con dibujo para resultados vivos.
- Gestos estándar primero (SwipeToDismissBox); personalizados solo si aportan.
16 · Pruebas de interfaz
Intermedio ~15 minLos composables sin estado son triviales de probar: les pasas datos, afirmas lo que aparece. Con la semántica de Compose buscas nodos por texto, etiqueta o rol — como un test de integración de tu API, pero visual.
testImplementation(platform("androidx.compose:compose-bom:2024.12.01"))
testImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("androidx.compose.ui:ui-test-manifest")Probar un componente aislado
class TarjetaNotaTest {
@get:Rule
val regla = createComposeRule()
@Test
fun muestraTituloYContenido() {
regla.setContent {
NotasTheme {
TarjetaNota(Nota(1, "Compras", "Leche, pan"), alAbrir = {})
}
}
regla.onNodeWithText("Compras").assertIsDisplayed()
regla.onNodeWithText("Leche, pan").assertExists()
}
@Test
fun notificaAlAbrir() {
var abierta = false
regla.setContent {
TarjetaNota(Nota(1, "Compras", "…"), alAbrir = { abierta = true })
}
regla.onNodeWithText("Compras").performClick()
assertTrue(abierta)
}
}Localizadores útiles
| Selector | Encuentra… |
|---|---|
onNodeWithText("Guardar") | Nodo con ese texto visible |
onNodeWithTag("lista") | Nodo etiquetado con Modifier.testTag("lista") |
onNodeWithContentDescription("Nueva nota") | Iconos y acciones accesibles |
- Prueba componentes sin estado directamente; prueba pantallas completas inyectando un ViewModel con repositorio falso.
mainClockpermite avanzar animaciones determinísticamente en pruebas.- Las pruebas corren en el emulador (instrumentadas):
./gradlew connectedAndroidTest.
Puntos clave
- Semántica = API de pruebas: textos, tags y contentDescription.
- Componentes sin estado → pruebas sin montaje.
- Pantallas completas con ViewModels falsificados.
17 · Proyecto integrador «Notas»
Meta final ~20 minLlegaste al final: «Notas» ya tiene todo lo que vimos. Este capítulo ordena el proyecto completo como lo escribiría un equipo profesional.
Estructura del proyecto
app/src/main/java/pe/tuempresa/notas/
├── MainActivity.kt // setContent + AppNotas()
├── AppNotas.kt // grafo de navegación
├── datos/
│ ├── NotaEntidad.kt // @Entity Room
│ ├── NotaDao.kt // consultas con Flow
│ └── NotasRepositorio.kt // única fuente de verdad
├── logica/
│ ├── NotasViewModel.kt // UiState + intenciones (UDF)
│ └── UiState.kt
└── ui/
├── theme/ // Color.kt, Type.kt, Theme.kt
├── listado/ // PantallaNotas, TarjetaNota, ListaNotas
├── editor/ // PantallaEditor, campos sin estado
└── componentes/ // AnilloProgreso, ChipNota… reutilizablesFíjate en la correspondencia exacta con lo aprendido:
| Capítulo | Pieza en «Notas» |
|---|---|
| 2–4 Fundamentos | Pantallas compuestas de Column/Row/Box |
| 5–6 Diseño | Modifiers consistentes + Scaffold y Material |
| 7 Temas | Modo oscuro y color dinámico funcionando |
| 8–10 Estado | ViewModel + UiState + eventos hacia arriba |
| 11 Efectos | Carga de nota por id, snackbars seguros |
| 12 Listas | Listado con claves, animado y paginable |
| 13 Navegación | Listado ↔ Editor con rutas tipadas |
| 14–15 Extras | Animaciones de borrado, anillo de progreso |
| 16 Pruebas | Tarjetas y pantallas bajo test |
Lista para el siguiente nivel
- Hilt: elimina las fábricas manuales de ViewModel y repositorio.
- Paging 3: si tu base crece a miles de notas.
- DataStore: recordar preferencias (ordenar por fecha, tema fijo).
- Sincronización: WorkManager + Retrofit para respaldos en la nube.
- Publicación: firma, AAB y Play Console (manual tres, capítulo 12).