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.

17 capítulos Kotlin 2.x Material 3 Android Studio Modo claro / oscuro
17
Capítulos
90+
Ejemplos de código
5
Partes progresivas
1
Aplicación integradora
Cómo usar este manual: los capítulos son secuenciales: cada concepto se apoya en el anterior y se aplica de inmediato a la aplicación guía «Notas». Requiere el manual del lenguaje Kotlin; el manual tres (conceptos Android) es recomendable pero no obligatorio.

1 · De vistas a composición

Básico ~12 min

Durante 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.

ConceptoSistema de vistas (XML)Compose
InterfazArchivos XML + clases ViewFunciones Kotlin
ActualizaciónImperativa: textView.text = ...Automática ante cambios de estado
EstadoDisperso en vistas y variablesFuente única que «baja» a la interfaz
ReutilizaciónIncludes, fragments, custom viewsLlamar funciones con parámetros
Herramienta oficialLegado (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.
Ojo: el sistema de vistas no está obsoleto y convivirá años en proyectos reales (con 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 min

Instala 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!")
}
¡Hola, Percy!

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.
  • setContent reemplaza por completo a setContentView(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.

Acelera tu ciclo: activa Live Edit (Settings → Editor → Live Edit) para ver cambios de composables en el emulador mientras guardas, sin recompilar toda la aplicación.

Puntos clave

  • Plantilla Empty Activity = Compose listo; nada de XML obligatorio.
  • setContent abre 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 min

Un 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.
Dos reglas de oro: un composable debe ser idempotente (mismos datos → mismo resultado, sin importar cuántas veces lo llamen) y sin efectos secundarios: nada de escribir archivos, lanzar corrutinas «a pelo» o tocar variables globales en su cuerpo. Para eso existen las APIs de efectos del capítulo 11.

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 min

Con 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.

ContenedorEje principalParámetros clave
ColumnVertical ↓verticalArrangement, horizontalAlignment
RowHorizontal →horizontalArrangement, verticalAlignment
BoxNinguno (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.
  • weight reparte 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 min

Casi 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)))
ModifierQué 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)
    )
}
  • padding antes de background = margen exterior; después = relleno interior.
  • clickable tras clip+background limita el toque al área dibujada.
  • Extrae modificadores repetidos en variables (val chipMod = Modifier...) o en composables propios.
Densidades: siempre usa 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 min

Material 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.
  • Snackbar para avisos desechables («Nota guardada» con acción Deshacer).
  • Usa la jerarquía Button/Outlined/Text en lugar de inventar estilos propios.
Accesibilidad gratis: los componentes Material ya anuncian estado y acciones a TalkBack. Si creas controles propios, añade siempre 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 isError y supportingText: validación visible sin esfuerzo.

7 · Temas y modo oscuro

Intermedio ~15 min

Un 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 colorUso típico
primary / onPrimaryAcciones principales; «on» es el texto encima
secondaryAcciones de apoyo, chips
surface / onSurfaceTarjetas y fondos de componentes
error / onErrorErrores y destructivos
*Container / on*ContainerFondos tenues con su texto contrastado
Regla: si escribes 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 min

El 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 estadoDónde ponerlo
Lógica de interfaz (pestaña activa)Composable padre cercano
Borrador de formulario compartidoViewModel
Datos de negocio (notas)Repositorio + ViewModel
Animación pasajeraLocal con remember

Puntos clave

  • mutableStateOf convierte 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 min

Tres 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».

HerramientaSobrevive a recomposiciónA rotaciónA muerte del proceso
nadaNoNoNo
rememberNoNo
rememberSaveableSí (Bundle)
ViewModelNo
Room / DataStore
@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/mapSaver extienden Saveable a tus data classes.
  • Prueba SIEMPRE rotando en medio de cada flujo.

10 · Flujo unidireccional de datos

Avanzado ~15 min

El 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.
Síntoma de UDF roto: un composable que escribe en una variable de otro componente o que «sabe» de dónde vienen los datos. Si lo ves, eleva ese estado un nivel.

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 min

El 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
rememberCoroutineScopeLanzar desde callbacks de usuario
derivedStateOfDerivados que cambian menos que sus entradas
snapshotFlowObservar 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 min

Las 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 usa animateScrollToItem.
@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ípicoSolución
Cargar 10 000 notas en memoriaPaginación en SQL (LIMIT) o Paging 3
Ítem recomputa todo por cada scrollParámetros estables + remember(campo) en cálculos caros
LazyColumn anidado en LazyColumn verticalAplana la jerarquía o usa item { } agrupando
Índice como claveIdentificadores reales y únicos

Puntos clave

  • Claves + contentType = reciclaje y animaciones correctos.
  • derivedStateOf para 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 min

Compose 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()) { /* ... */ }
APICuándo usarla
animateFloatAsState y familiaUn valor que cambia (color, tamaño, progreso)
AnimatedVisibilityAparecer/desaparecer elementos
CrossfadeIntercambiar 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); tween para transiciones de interfaz.
  • Respeta la accesibilidad: las preferencias de «quitar animaciones» del sistema reducen duraciones automáticamente en componentes Material.
Etiqueta siempre: los parámetros 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 min

Cuando 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 min

Los 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

SelectorEncuentra…
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.
  • mainClock permite avanzar animaciones determinísticamente en pruebas.
  • Las pruebas corren en el emulador (instrumentadas): ./gradlew connectedAndroidTest.
Bonus de la arquitectura: si seguiste la elevación de estado del capítulo 8, tus pruebas no necesitan base de datos ni red: solo estados y lambdas. El diseño desacoplado se paga solo.

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 min

Llegaste 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… reutilizables

Fíjate en la correspondencia exacta con lo aprendido:

CapítuloPieza en «Notas»
2–4 FundamentosPantallas compuestas de Column/Row/Box
5–6 DiseñoModifiers consistentes + Scaffold y Material
7 TemasModo oscuro y color dinámico funcionando
8–10 EstadoViewModel + UiState + eventos hacia arriba
11 EfectosCarga de nota por id, snackbars seguros
12 ListasListado con claves, animado y paginable
13 NavegaciónListado ↔ Editor con rutas tipadas
14–15 ExtrasAnimaciones de borrado, anillo de progreso
16 PruebasTarjetas 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).
Curso completado. Dominas el modelo declarativo, el estado unidireccional y las herramientas de una aplicación Compose real. El mejor siguiente paso: extiende «Notas» con una función propia — etiquetas, recordatorios con notificaciones o búsqueda por voz — y siente cómo cada concepto encaja sin fricción.