Tema 1C. Interfaz de usuario avanzada en Compose
- Bloque: B1 — Fundamentos: Kotlin, Compose y entorno Android
- Duración aproximada: 4 horas
- RA1 — Aplica tecnologías de desarrollo para dispositivos móviles evaluando sus características y capacidades.
| Código | Criterio |
|---|---|
| RA1-b | Se han identificado las tecnologías de desarrollo de aplicaciones para dispositivos móviles. |
| RA1-d | Se han identificado configuraciones que clasifican los dispositivos móviles en base a sus características. |
| RA1-g | Se han realizado modificaciones sobre aplicaciones existentes. |
| RA1-h | Se han utilizado emuladores para comprobar el funcionamiento de las aplicaciones. |
1. Ecosistema Android y limitaciones del dispositivo móvil#
Antes de profundizar en la UI, es importante entender el entorno en el que se ejecutan las aplicaciones Android y las restricciones que impone.
1.1 Arquitectura del sistema Android#
┌──────────────────────────────────────────────────────┐
│ Capa de Aplicaciones │
│ (AppFlix, Gmail, Maps, apps del usuario) │
├──────────────────────────────────────────────────────┤
│ Framework de Android (API Java/Kotlin) │
│ Activity Manager, Window Manager, Content Providers │
├──────────────────────────────────────────────────────┤
│ Librerías nativas + Android Runtime (ART) │
│ Compilación AOT y JIT, recolector de basura │
├──────────────────────────────────────────────────────┤
│ Capa de Abstracción de Hardware (HAL) │
├──────────────────────────────────────────────────────┤
│ Kernel Linux │
└──────────────────────────────────────────────────────┘1.2 Limitaciones a tener en cuenta en el diseño#
| Recurso | Limitación | Impacto en el desarrollo |
|---|---|---|
| Memoria RAM | Típicamente 4-12 GB, pero el sistema puede matar procesos | Liberar recursos al pausar, usar LazyColumn en listas |
| CPU | Arquitectura ARM, varios núcleos pero limitados | No bloquear el hilo principal (operaciones en corrutinas con Dispatchers.IO) |
| Batería | Recurso crítico para el usuario | Minimizar operaciones en background, usar sensores con moderación |
| Almacenamiento | Limitado y compartido | No guardar datos innecesarios, comprimir imágenes |
| Conectividad | Puede perderse en cualquier momento | Diseñar para funcionar offline (offline-first, Bloque 3) |
| Tamaño de pantalla | De 4’’ a 13’’ (tablets) con distintas densidades | Usar dp/sp en lugar de px, diseñar layouts adaptativos |
1.3 API Level y compatibilidad#
Cada versión de Android tiene un número de API Level. Cuanto más alto, más funciones disponibles pero menos dispositivos compatibles:
| API Level | Versión Android | Nombre | Cuota aprox. (2025) |
|---|---|---|---|
| 21 | 5.0 | Lollipop | < 1% |
| 26 | 8.0 | Oreo | ~3% |
| 30 | 11 | — | ~15% |
| 33 | 13 | Tiramisu | ~25% |
| 34 | 14 | Upside Down Cake | ~30% |
| 35 | 15 | Baklava | ~10% |
En este curso se usa minSdk = 30 (Android 11), que cubre más del 80 % de los dispositivos activos y permite usar Jetpack Compose sin restricciones.
2. Modifier avanzado#
En la sección anterior se vieron los modificadores más básicos. Esta sección explora modificadores menos evidentes pero muy utilizados en aplicaciones reales.
2.1 weight — distribución proporcional#
1@Composable
2fun DistribucionProporcional() {
3 Row(modifier = Modifier.fillMaxWidth().height(60.dp)) {
4 // Ocupa 2/3 del espacio disponible
5 Box(
6 modifier = Modifier
7 .weight(2f)
8 .fillMaxHeight()
9 .background(MaterialTheme.colorScheme.primary),
10 contentAlignment = Alignment.Center
11 ) { Text("Título", color = Color.White) }
12
13 // Ocupa 1/3 del espacio disponible
14 Box(
15 modifier = Modifier
16 .weight(1f)
17 .fillMaxHeight()
18 .background(MaterialTheme.colorScheme.secondary),
19 contentAlignment = Alignment.Center
20 ) { Text("★ 8.5", color = Color.White) }
21 }
22}2.2 Modificadores de interacción#
1@Composable
2fun EjemploInteraccion() {
3 var seleccionado by remember { mutableStateOf(false) }
4
5 Box(
6 modifier = Modifier
7 .size(120.dp)
8 .clip(RoundedCornerShape(12.dp))
9 .background(
10 if (seleccionado) MaterialTheme.colorScheme.primaryContainer
11 else MaterialTheme.colorScheme.surfaceVariant
12 )
13 // clickable con ripple effect (efecto visual al pulsar)
14 .clickable { seleccionado = !seleccionado }
15 .padding(16.dp),
16 contentAlignment = Alignment.Center
17 ) {
18 Text(if (seleccionado) "✓ Favorito" else "Añadir")
19 }
20}2.3 Diseño adaptativo básico con BoxWithConstraints#
1@Composable
2fun LayoutAdaptativo() {
3 // BoxWithConstraints da acceso al tamaño disponible en tiempo de composición
4 BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
5 if (maxWidth < 600.dp) {
6 // Móvil: lista vertical
7 Column { ContenidoMovil() }
8 } else {
9 // Tablet: vista maestra-detalle
10 Row {
11 Box(modifier = Modifier.weight(0.4f)) { Lista() }
12 Box(modifier = Modifier.weight(0.6f)) { Detalle() }
13 }
14 }
15 }
16}3. Temas y sistema de diseño Material 3#
Material 3 es el sistema de diseño de Google para Android, integrado nativamente en Compose. Define colores, tipografía, formas y componentes de forma coherente.
3.1 Estructura del tema#
1// Tema generado por Android Studio al crear el proyecto
2// ui/theme/Theme.kt
3@Composable
4fun AppFlixTheme(
5 darkTheme: Boolean = isSystemInDarkTheme(),
6 content: @Composable () -> Unit
7) {
8 val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
9
10 MaterialTheme(
11 colorScheme = colorScheme,
12 typography = Typography,
13 content = content
14 )
15}3.2 Colores del tema#
Material 3 define un sistema de colores con roles semánticos. Nunca uses colores hardcoded en la UI: usa siempre los del tema para que el modo oscuro funcione automáticamente.
1@Composable
2fun EjemploColoresTema() {
3 Column(modifier = Modifier.padding(16.dp)) {
4 // Usar colores del tema, no hardcoded
5 Box(
6 modifier = Modifier
7 .fillMaxWidth()
8 .background(MaterialTheme.colorScheme.primaryContainer)
9 .padding(16.dp)
10 ) {
11 Text(
12 "Color primario",
13 color = MaterialTheme.colorScheme.onPrimaryContainer
14 )
15 }
16
17 // ❌ Evitar: no respeta el tema
18 // Box(modifier = Modifier.background(Color(0xFF6200EE))) { }
19 }
20}Los roles de color más usados:
| Role | Uso |
|---|---|
primary |
Elementos de acción principal (botón principal) |
onPrimary |
Texto/iconos sobre primary |
primaryContainer |
Fondos de contenedores destacados |
surface |
Fondo de Cards, BottomSheet |
onSurface |
Texto/iconos sobre surface |
error |
Mensajes de error |
background |
Fondo general de la pantalla |
3.3 Tipografía#
1@Composable
2fun EjemploTipografia() {
3 Column(
4 modifier = Modifier.padding(16.dp),
5 verticalArrangement = Arrangement.spacedBy(4.dp)
6 ) {
7 Text("Display Large", style = MaterialTheme.typography.displayLarge)
8 Text("Headline Large", style = MaterialTheme.typography.headlineLarge)
9 Text("Title Large", style = MaterialTheme.typography.titleLarge)
10 Text("Body Large", style = MaterialTheme.typography.bodyLarge)
11 Text("Label Large", style = MaterialTheme.typography.labelLarge)
12 }
13}4. Componentes Material 3 avanzados#
4.1 Card#
1@Composable
2fun TarjetaPeliculaCompleta(
3 titulo: String,
4 puntuacion: Double,
5 genero: String,
6 onClick: () -> Unit
7) {
8 Card(
9 onClick = onClick,
10 modifier = Modifier.fillMaxWidth(),
11 colors = CardDefaults.cardColors(
12 containerColor = MaterialTheme.colorScheme.surfaceVariant
13 ),
14 elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
15 shape = RoundedCornerShape(12.dp)
16 ) {
17 Column(modifier = Modifier.padding(16.dp)) {
18 Row(
19 modifier = Modifier.fillMaxWidth(),
20 horizontalArrangement = Arrangement.SpaceBetween,
21 verticalAlignment = Alignment.CenterVertically
22 ) {
23 Text(titulo, style = MaterialTheme.typography.titleMedium)
24 AssistChip(
25 onClick = { },
26 label = { Text("★ $puntuacion") }
27 )
28 }
29 Spacer(modifier = Modifier.height(4.dp))
30 Text(
31 genero,
32 style = MaterialTheme.typography.bodySmall,
33 color = MaterialTheme.colorScheme.onSurfaceVariant
34 )
35 }
36 }
37}4.2 BottomNavigationBar#
Fundamental para apps con múltiples secciones principales:
1// Definición de las secciones de la app
2data class SeccionNavegacion(
3 val ruta: String,
4 val icono: ImageVector,
5 val etiqueta: String
6)
7
8val secciones = listOf(
9 SeccionNavegacion("inicio", Icons.Default.Home, "Inicio"),
10 SeccionNavegacion("buscar", Icons.Default.Search, "Buscar"),
11 SeccionNavegacion("favoritos", Icons.Default.Favorite, "Favoritos"),
12 SeccionNavegacion("perfil", Icons.Default.Person, "Perfil")
13)
14
15@Composable
16fun AppFlixConNavegacion() {
17 var seccionActual by remember { mutableStateOf("inicio") }
18
19 Scaffold(
20 bottomBar = {
21 NavigationBar {
22 secciones.forEach { seccion ->
23 NavigationBarItem(
24 selected = seccionActual == seccion.ruta,
25 onClick = { seccionActual = seccion.ruta },
26 icon = {
27 Icon(seccion.icono, contentDescription = seccion.etiqueta)
28 },
29 label = { Text(seccion.etiqueta) }
30 )
31 }
32 }
33 }
34 ) { paddingValues ->
35 Box(modifier = Modifier.padding(paddingValues)) {
36 when (seccionActual) {
37 "inicio" -> Text("Pantalla de inicio")
38 "buscar" -> Text("Pantalla de búsqueda")
39 "favoritos" -> Text("Pantalla de favoritos")
40 "perfil" -> Text("Pantalla de perfil")
41 }
42 }
43 }
44}En el Bloque 2 se sustituirá este manejo manual de navegación por Navigation Compose con un
NavHost, que es la solución recomendada para apps multi-pantalla reales.
4.3 Chips — etiquetas interactivas#
1@Composable
2fun FiltrosPorGenero() {
3 val generos = listOf("Todos", "Acción", "Drama", "Comedia", "Terror", "Sci-Fi")
4 var generoSeleccionado by remember { mutableStateOf("Todos") }
5
6 LazyRow(
7 horizontalArrangement = Arrangement.spacedBy(8.dp),
8 contentPadding = PaddingValues(horizontal = 16.dp)
9 ) {
10 items(generos) { genero ->
11 FilterChip(
12 selected = genero == generoSeleccionado,
13 onClick = { generoSeleccionado = genero },
14 label = { Text(genero) },
15 leadingIcon = if (genero == generoSeleccionado) {
16 { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
17 } else null
18 )
19 }
20 }
21}4.4 ProgressIndicator — indicadores de carga#
1@Composable
2fun PantallaConCarga(cargando: Boolean) {
3 Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
4 if (cargando) {
5 // Circular — para esperas de duración desconocida
6 CircularProgressIndicator(
7 modifier = Modifier.size(48.dp),
8 color = MaterialTheme.colorScheme.primary
9 )
10 } else {
11 // Linear — para esperas con progreso conocido
12 var progreso by remember { mutableFloatStateOf(0f) }
13 LinearProgressIndicator(
14 progress = { progreso },
15 modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp)
16 )
17 }
18 }
19}5. Animaciones básicas en Compose#
Compose incluye un sistema de animaciones declarativo que permite añadir movimiento de forma sencilla.
5.1 AnimatedVisibility#
1@Composable
2fun EjemploAnimatedVisibility() {
3 var visible by remember { mutableStateOf(true) }
4
5 Column(modifier = Modifier.padding(16.dp)) {
6 Button(onClick = { visible = !visible }) {
7 Text(if (visible) "Ocultar" else "Mostrar")
8 }
9 Spacer(modifier = Modifier.height(8.dp))
10
11 AnimatedVisibility(
12 visible = visible,
13 enter = fadeIn() + expandVertically(),
14 exit = fadeOut() + shrinkVertically()
15 ) {
16 Card(modifier = Modifier.fillMaxWidth()) {
17 Text(
18 "Contenido animado",
19 modifier = Modifier.padding(16.dp)
20 )
21 }
22 }
23 }
24}5.2 animate*AsState — animación de valores#
1@Composable
2fun EjemploAnimacionValor() {
3 var expandido by remember { mutableStateOf(false) }
4
5 // El tamaño se anima automáticamente al cambiar 'expandido'
6 val tamanyoAnimado by animateDpAsState(
7 targetValue = if (expandido) 200.dp else 80.dp,
8 animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
9 label = "tamaño"
10 )
11 val colorAnimado by animateColorAsState(
12 targetValue = if (expandido) MaterialTheme.colorScheme.primaryContainer
13 else MaterialTheme.colorScheme.surfaceVariant,
14 label = "color"
15 )
16
17 Box(
18 modifier = Modifier
19 .size(tamanyoAnimado)
20 .background(colorAnimado, RoundedCornerShape(12.dp))
21 .clickable { expandido = !expandido },
22 contentAlignment = Alignment.Center
23 ) {
24 Text(if (expandido) "✕ Cerrar" else "▶ Abrir")
25 }
26}6. Canvas — gráficos personalizados#
Canvas permite dibujar directamente en pantalla usando primitivas gráficas. Es útil para gráficos, indicadores y elementos visuales que no tienen componente predefinido.
1@Composable
2fun IndicadorPuntuacion(puntuacion: Float, modifier: Modifier = Modifier) {
3 // puntuacion: valor de 0.0 a 10.0
4 val progreso = (puntuacion / 10f).coerceIn(0f, 1f)
5 val colorArco = when {
6 progreso >= 0.8f -> Color(0xFF4CAF50) // verde — muy buena
7 progreso >= 0.6f -> Color(0xFFFF9800) // naranja — buena
8 else -> Color(0xFFF44336) // rojo — mala
9 }
10
11 Canvas(modifier = modifier.size(80.dp)) {
12 val tamanyoArco = size.minDimension
13 val grosor = tamanyoArco * 0.1f
14
15 // Arco de fondo (gris)
16 drawArc(
17 color = Color.LightGray,
18 startAngle = 135f,
19 sweepAngle = 270f,
20 useCenter = false,
21 style = Stroke(width = grosor, cap = StrokeCap.Round)
22 )
23 // Arco de progreso
24 drawArc(
25 color = colorArco,
26 startAngle = 135f,
27 sweepAngle = 270f * progreso,
28 useCenter = false,
29 style = Stroke(width = grosor, cap = StrokeCap.Round)
30 )
31 }
32}Ejemplo práctico completo: pantalla de listado de AppFlix#
Integra todos los elementos de UI de este bloque en una pantalla realista:
1package com.ejemplo.appflix.ui
2
3import androidx.compose.animation.*
4import androidx.compose.foundation.layout.*
5import androidx.compose.foundation.lazy.LazyColumn
6import androidx.compose.foundation.lazy.LazyRow
7import androidx.compose.foundation.lazy.items
8import androidx.compose.material.icons.Icons
9import androidx.compose.material.icons.filled.*
10import androidx.compose.material3.*
11import androidx.compose.runtime.*
12import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.dp
16
17// ─── ui/listado/PantallaListado.kt ───────────────────────────────────────────────────────────────
18
19// Modelo de datos simple (será reemplazado por data class real en B2)
20data class PeliculaUI(
21 val id: Int,
22 val titulo: String,
23 val genero: String,
24 val puntuacion: Double,
25 val esFavorita: Boolean = false
26)
27
28@Composable
29fun PantallaListado() {
30 // Estado local de la pantalla (en B2 pasará al ViewModel)
31 var busqueda by remember { mutableStateOf("") }
32 var generoSeleccionado by remember { mutableStateOf("Todos") }
33 var peliculas by remember {
34 mutableStateOf(
35 listOf(
36 PeliculaUI(1, "Dune: Parte 2", "Sci-Fi", 8.5),
37 PeliculaUI(2, "Oppenheimer", "Drama", 8.9, esFavorita = true),
38 PeliculaUI(3, "Barbie", "Comedia", 7.1),
39 PeliculaUI(4, "Pobres Criaturas", "Drama", 8.0),
40 PeliculaUI(5, "El Libro de las Soluciones", "Comedia", 6.5),
41 PeliculaUI(6, "Alien: Romulus", "Acción", 7.4),
42 PeliculaUI(7, "Twisters", "Acción", 6.7)
43 )
44 )
45 }
46
47 val generos = listOf("Todos") + peliculas.map { it.genero }.distinct().sorted()
48
49 // Filtrado reactivo
50 val peliculasFiltradas = peliculas.filter { pelicula ->
51 val coincideBusqueda = busqueda.isBlank() ||
52 pelicula.titulo.contains(busqueda, ignoreCase = true)
53 val coincideGenero = generoSeleccionado == "Todos" ||
54 pelicula.genero == generoSeleccionado
55 coincideBusqueda && coincideGenero
56 }
57
58 Scaffold(
59 topBar = {
60 TopAppBar(
61 title = { Text("AppFlix") },
62 actions = {
63 IconButton(onClick = { }) {
64 Icon(Icons.Default.AccountCircle, contentDescription = "Perfil")
65 }
66 }
67 )
68 }
69 ) { paddingValues ->
70 Column(modifier = Modifier.padding(paddingValues)) {
71 // Barra de búsqueda
72 OutlinedTextField(
73 value = busqueda,
74 onValueChange = { busqueda = it },
75 modifier = Modifier
76 .fillMaxWidth()
77 .padding(horizontal = 16.dp, vertical = 8.dp),
78 placeholder = { Text("Buscar película...") },
79 leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
80 trailingIcon = {
81 AnimatedVisibility(visible = busqueda.isNotEmpty()) {
82 IconButton(onClick = { busqueda = "" }) {
83 Icon(Icons.Default.Clear, contentDescription = "Borrar búsqueda")
84 }
85 }
86 },
87 singleLine = true
88 )
89
90 // Chips de género
91 LazyRow(
92 contentPadding = PaddingValues(horizontal = 16.dp),
93 horizontalArrangement = Arrangement.spacedBy(8.dp),
94 modifier = Modifier.padding(bottom = 8.dp)
95 ) {
96 items(generos) { genero ->
97 FilterChip(
98 selected = genero == generoSeleccionado,
99 onClick = { generoSeleccionado = genero },
100 label = { Text(genero) }
101 )
102 }
103 }
104
105 // Resultado del filtrado
106 if (peliculasFiltradas.isEmpty()) {
107 Box(
108 modifier = Modifier.fillMaxSize(),
109 contentAlignment = Alignment.Center
110 ) {
111 Column(horizontalAlignment = Alignment.CenterHorizontally) {
112 Icon(
113 Icons.Default.SearchOff,
114 contentDescription = null,
115 modifier = Modifier.size(64.dp),
116 tint = MaterialTheme.colorScheme.onSurfaceVariant
117 )
118 Spacer(modifier = Modifier.height(16.dp))
119 Text(
120 "Sin resultados para \"$busqueda\"",
121 style = MaterialTheme.typography.bodyLarge
122 )
123 }
124 }
125 } else {
126 LazyColumn(
127 contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
128 verticalArrangement = Arrangement.spacedBy(8.dp)
129 ) {
130 items(peliculasFiltradas, key = { it.id }) { pelicula ->
131 ItemPelicula(
132 pelicula = pelicula,
133 onToggleFavorita = { id ->
134 peliculas = peliculas.map {
135 if (it.id == id) it.copy(esFavorita = !it.esFavorita) else it
136 }
137 }
138 )
139 }
140 }
141 }
142 }
143 }
144}
145
146@Composable
147fun ItemPelicula(pelicula: PeliculaUI, onToggleFavorita: (Int) -> Unit) {
148 Card(modifier = Modifier.fillMaxWidth()) {
149 Row(
150 modifier = Modifier
151 .fillMaxWidth()
152 .padding(12.dp),
153 verticalAlignment = Alignment.CenterVertically
154 ) {
155 Column(modifier = Modifier.weight(1f)) {
156 Text(pelicula.titulo, style = MaterialTheme.typography.titleSmall)
157 Text(
158 text = "${pelicula.genero} · ★ ${pelicula.puntuacion}",
159 style = MaterialTheme.typography.bodySmall,
160 color = MaterialTheme.colorScheme.onSurfaceVariant
161 )
162 }
163 IconButton(onClick = { onToggleFavorita(pelicula.id) }) {
164 Icon(
165 imageVector = if (pelicula.esFavorita) Icons.Default.Favorite
166 else Icons.Default.FavoriteBorder,
167 contentDescription = if (pelicula.esFavorita) "Quitar favorito" else "Añadir favorito",
168 tint = if (pelicula.esFavorita) MaterialTheme.colorScheme.error
169 else MaterialTheme.colorScheme.onSurfaceVariant
170 )
171 }
172 }
173 }
174}
175
176@Preview(showBackground = true)
177@Composable
178fun PantallaListadoPreview() {
179 MaterialTheme {
180 PantallaListado()
181 }
182}