Interfaces modernas con Jetpack Compose en Kotlin

Capítulo 4

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

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 remember para 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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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.
  • Spacer para separar elementos sin “views” extra.
  • weight para repartir espacio en Row/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ónEjemploBeneficio
Datos + callbackquery y onQueryChangeControl externo del estado
modifier opcionalmodifier: Modifier = ModifierComposición flexible
Estilos desde el temaMaterialTheme.typographyConsistencia 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 remember para valores que no deben recalcularse en cada recomposición.
  • Consume colores y tipografías desde MaterialTheme para mantener consistencia.
  • Crea @Preview para componentes clave y estados representativos.

Ahora responde el ejercicio sobre el contenido:

¿Cuál es el propósito principal de aplicar state hoisting en un componente de Jetpack Compose?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Elevar el estado permite que el componente reciba el valor y un callback para reportar cambios, haciendo la UI más reutilizable, controlable y fácil de probar al no depender de estado interno.

Siguiente capítulo

Recursos y diseño adaptable en Android

Arrow Right Icon
Portada de libro electrónico gratuitaAndroid desde Cero con Kotlin
29%

Android desde Cero con Kotlin

Nuevo curso

14 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.