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}

Referencias#

Calendar  Última modificación: viernes, 26 de junio de 2026