Construir UI con Jetpack Compose: mentalidad declarativa
Jetpack Compose permite describir la interfaz como una función del estado: en lugar de “modificar vistas”, declaras cómo se ve la UI para un estado dado. Cuando el estado cambia, Compose ejecuta recomposición y vuelve a dibujar solo las partes necesarias.
¿Qué es un composable?
Un @Composable es una función que emite UI. No “devuelve” una vista; describe qué debe mostrarse. Puedes combinar composables como piezas pequeñas para formar pantallas completas.
@Composable fun Greeting(name: String) { Text(text = "Hola, $name") }Modificadores (Modifier): el pegamento de la UI
Modifier encadena comportamiento y estilo: tamaño, padding, clics, alineación, fondo, etc. Es habitual recibirlo como parámetro para hacer el composable flexible.
@Composable fun Title(text: String, modifier: Modifier = Modifier) { Text(text = text, modifier = modifier.padding(16.dp)) }Estados y recomposición: remember, mutableStateOf
Estado local con remember
Para que un valor sobreviva a recomposiciones dentro del mismo árbol de UI, se usa remember. Para que ese valor sea observable por Compose, se usa mutableStateOf. Cuando el valor cambia, Compose programa recomposición de los composables que lo leen.
@Composable fun Counter() { var count by remember { mutableStateOf(0) } Column(Modifier.padding(16.dp)) { Text("Contador: $count") Button(onClick = { count++ }) { Text("Incrementar") } } }Cómo pensar la recomposición
- Compose recompone cuando cambia un estado leído por un composable.
- La recomposición no significa “redibujar todo”; se recalculan solo nodos afectados.
- Evita crear objetos costosos en cada recomposición; usa
rememberpara cachear.
State hoisting (elevar el estado)
Para reutilizar componentes, conviene que el estado viva “arriba” y el componente reciba value y onValueChange. Así el componente es controlable y testeable.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
@Composable fun SearchField(query: String, onQueryChange: (String) -> Unit) { OutlinedTextField(value = query, onValueChange = onQueryChange, label = { Text("Buscar") }, modifier = Modifier.fillMaxWidth()) }Layouts esenciales: Column, Row, Box
Column y Row
Column apila elementos verticalmente y Row horizontalmente. Controlas distribución con verticalArrangement/horizontalArrangement y alineación con horizontalAlignment/verticalAlignment.
@Composable fun ProfileHeader(name: String, role: String) { Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Box(Modifier.size(48.dp).background(Color.Gray, CircleShape)) Spacer(Modifier.width(12.dp)) Column { Text(name, style = MaterialTheme.typography.titleMedium) Text(role, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } }Box
Box superpone elementos (como un “stack”). Útil para badges, overlays, imágenes con texto encima, etc.
@Composable fun ImageWithBadge() { Box(Modifier.size(120.dp).background(Color.LightGray)) { Text("Foto", Modifier.align(Alignment.Center)) Box(Modifier.align(Alignment.TopEnd).padding(8.dp).size(12.dp).background(Color.Red, CircleShape)) } }Espaciado y tamaño
padding,width,height,size,fillMaxWidth,fillMaxSize.Spacerpara separar elementos sin “views” extra.weightpara repartir espacio enRow/Column.
@Composable fun TwoColumns() { Row(Modifier.fillMaxWidth().padding(16.dp)) { Box(Modifier.weight(1f).height(60.dp).background(Color(0xFFE3F2FD))) Box(Modifier.width(12.dp)) Box(Modifier.weight(2f).height(60.dp).background(Color(0xFFFFF3E0))) } }Theming: colores, tipografía y estilos consistentes
El tema centraliza decisiones visuales. En Material 3, el tema expone MaterialTheme.colorScheme y MaterialTheme.typography. La idea es que tus composables consuman el tema en lugar de “hardcodear” colores y tamaños.
Colores
Usa colorScheme para fondos, texto y estados. Esto facilita modo oscuro y consistencia.
@Composable fun ThemedCard(title: String, subtitle: String) { Surface(color = MaterialTheme.colorScheme.surface, tonalElevation = 2.dp, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } }Tipografía
La tipografía define jerarquía visual. Recurre a estilos como titleLarge, titleMedium, bodyLarge, labelMedium.
@Composable fun Headline(text: String) { Text(text, style = MaterialTheme.typography.headlineSmall) }Formas y elevación
Para superficies (cards, contenedores), usa Surface y shapes como RoundedCornerShape. La elevación tonal ayuda a separar capas.
Bloque 1: Componentes básicos (building blocks)
Text, Button, IconButton, TextField
Estos componentes cubren la mayoría de interacciones. La clave es combinarlos con estado y modificadores.
@Composable fun LoginForm() { var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = password, onValueChange = { password = it }, label = { Text("Password") }, modifier = Modifier.fillMaxWidth()) Button(onClick = { /* validar */ }, modifier = Modifier.fillMaxWidth()) { Text("Entrar") } } }Listas con LazyColumn
Para listas eficientes, usa LazyColumn. Renderiza solo lo visible y permite items con claves estables.
@Composable fun SimpleList(items: List<String>) { LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { items(items, key = { it }) { Text(it, Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant).padding(12.dp)) } } }Bloque 2: Composición de pantallas (lista + detalle)
En esta práctica crearás dos pantallas: una lista de elementos y un detalle. Usaremos estado para seleccionar un elemento y previsualizaciones para validar UI rápidamente.
Modelo de datos para la práctica
data class Product( val id: Int, val name: String, val description: String, val price: Double )val sampleProducts = listOf( Product(1, "Auriculares", "Cancelación de ruido y estuche", 59.99), Product(2, "Teclado", "Mecánico compacto", 79.0), Product(3, "Mouse", "Ergonómico inalámbrico", 35.5) )Paso 1: Item reutilizable para la lista
Crearemos un componente de fila (card) que muestre nombre y precio, y notifique clics. Observa el patrón: datos + callback + modifier.
@Composable fun ProductRow( product: Product, onClick: () -> Unit, modifier: Modifier = Modifier ) { Surface( onClick = onClick, shape = RoundedCornerShape(12.dp), tonalElevation = 1.dp, modifier = modifier.fillMaxWidth() ) { Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { Text(product.name, style = MaterialTheme.typography.titleMedium) Text(product.description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) } Text( text = "$${String.format("%.2f", product.price)}", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary ) } } }Paso 2: Pantalla de lista con estado de búsqueda
Incluimos un campo de búsqueda con remember y filtramos la lista. Este es un ejemplo típico de UI = f(estado).
@Composable fun ProductListScreen( products: List<Product>, onProductSelected: (Product) -> Unit, modifier: Modifier = Modifier ) { var query by remember { mutableStateOf("") } val filtered = remember(query, products) { if (query.isBlank()) products else products.filter { it.name.contains(query, ignoreCase = true) } } Column(modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { SearchField(query = query, onQueryChange = { query = it }) LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxSize()) { items(filtered, key = { it.id }) { product -> ProductRow(product = product, onClick = { onProductSelected(product) }) } } } }Paso 3: Pantalla de detalle con estado local (favorito)
En el detalle, agregamos un estado local “favorito”. Este estado vive en la pantalla (no en el item) y cambia la UI al pulsar.
@Composable fun ProductDetailScreen( product: Product, onBack: () -> Unit, modifier: Modifier = Modifier ) { var isFavorite by remember { mutableStateOf(false) } Column(modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { TextButton(onClick = onBack) { Text("Volver") } Spacer(Modifier.weight(1f)) IconButton(onClick = { isFavorite = !isFavorite }) { val tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant Icon(imageVector = Icons.Filled.Favorite, contentDescription = null, tint = tint) } } Text(product.name, style = MaterialTheme.typography.headlineSmall) Text(product.description, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) Surface(shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceVariant) { Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) { Text("Precio", style = MaterialTheme.typography.labelLarge) Text("$${String.format("%.2f", product.price)}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) } } } }Paso 4: “Contenedor” simple para alternar entre lista y detalle (sin navegación)
Para practicar estado y recomposición sin entrar en navegación, haremos un contenedor que muestre lista o detalle según el producto seleccionado.
@Composable fun ProductAppContent(products: List<Product>) { var selected by remember { mutableStateOf<Product?>(null) } if (selected == null) { ProductListScreen(products = products, onProductSelected = { selected = it }) } else { ProductDetailScreen(product = selected!!, onBack = { selected = null }) } }Paso 5: Previsualizaciones (@Preview) para iterar rápido
Las previsualizaciones te permiten ver componentes y pantallas sin ejecutar en un dispositivo. Es útil crear previews para estados distintos (por ejemplo, lista con datos, detalle con un producto).
@Preview(showBackground = true) @Composable fun PreviewProductRow() { ProductRow(product = sampleProducts.first(), onClick = {}) }@Preview(showBackground = true, widthDp = 360, heightDp = 720) @Composable fun PreviewListScreen() { ProductListScreen(products = sampleProducts, onProductSelected = {}) }@Preview(showBackground = true, widthDp = 360, heightDp = 720) @Composable fun PreviewDetailScreen() { ProductDetailScreen(product = sampleProducts.first(), onBack = {}) }Bloque 3: Reutilización mediante componentes propios
Diseña componentes con una API clara
Un componente reutilizable suele seguir estas reglas: recibe datos (estado), expone eventos (callbacks), acepta modifier, y evita depender de variables globales.
| Patrón | Ejemplo | Beneficio |
|---|---|---|
| Datos + callback | query y onQueryChange | Control externo del estado |
| modifier opcional | modifier: Modifier = Modifier | Composición flexible |
| Estilos desde el tema | MaterialTheme.typography | Consistencia visual |
Componente “EmptyState” reutilizable
Cuando una lista está vacía (por filtro o falta de datos), conviene mostrar un estado vacío consistente.
@Composable fun EmptyState( title: String, message: String, actionText: String? = null, onAction: (() -> Unit)? = null, modifier: Modifier = Modifier ) { Column(modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(title, style = MaterialTheme.typography.titleMedium) Text(message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) if (actionText != null && onAction != null) { Button(onClick = onAction) { Text(actionText) } } } }Integrar EmptyState en la lista
Podemos mostrar EmptyState cuando el filtrado no devuelve resultados.
@Composable fun ProductListScreen( products: List<Product>, onProductSelected: (Product) -> Unit, modifier: Modifier = Modifier ) { var query by remember { mutableStateOf("") } val filtered = remember(query, products) { if (query.isBlank()) products else products.filter { it.name.contains(query, ignoreCase = true) } } Column(modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { SearchField(query = query, onQueryChange = { query = it }) if (filtered.isEmpty()) { EmptyState(title = "Sin resultados", message = "Prueba con otro término de búsqueda.") } else { LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxSize()) { items(filtered, key = { it.id }) { product -> ProductRow(product = product, onClick = { onProductSelected(product) }) } } } } }Checklist de buenas prácticas en Compose (aplicable a este capítulo)
- Prefiere composables pequeños y enfocados; compón pantallas a partir de piezas.
- Eleva el estado cuando el componente deba reutilizarse o compartirse.
- Usa
rememberpara valores que no deben recalcularse en cada recomposición. - Consume colores y tipografías desde
MaterialThemepara mantener consistencia. - Crea
@Previewpara componentes clave y estados representativos.