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                                │
│  (AppDummy, 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 AppDummyTheme(
 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 AppDummyConNavegacion() {
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}

Desarrollo práctico guiado: pantalla de listado de AppDummy 💻#

Integra todos los elementos de UI de este bloque en una pantalla realista, continúa con el proyecto AppDummy creado en el bloque anterior. Si no lo tienes, puedes continuar con la versión v1. Ejemplo práctico Tema 1B .

  1package es.ejemplo.appdummy.screens
  2
  3import android.util.Patterns
  4import androidx.compose.animation.*
  5import androidx.compose.foundation.layout.*
  6import androidx.compose.foundation.lazy.LazyRow
  7import androidx.compose.foundation.lazy.grid.GridCells
  8import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
  9import androidx.compose.foundation.lazy.grid.items
 10import androidx.compose.foundation.lazy.items
 11import androidx.compose.material.icons.Icons
 12import androidx.compose.material.icons.filled.*
 13import androidx.compose.material3.*
 14import androidx.compose.runtime.*
 15import androidx.compose.ui.Alignment
 16import androidx.compose.ui.Modifier
 17import androidx.compose.ui.layout.ContentScale
 18import androidx.compose.ui.platform.LocalContext
 19import androidx.compose.ui.tooling.preview.Preview
 20import androidx.compose.ui.unit.dp
 21import coil3.compose.AsyncImage
 22import coil3.request.ImageRequest
 23import coil3.request.crossfade
 24import es.ejemplo.appdummy.R
 25
 26// ─── screens/PantallaListado.kt ───────────────────────────────────────────────────────────────
 27
 28// Modelo de datos simple (será reemplazado por data class real en B2)
 29data class LibroUI(
 30    val id: Int = 0,
 31    val titulo: String = "",
 32    val autor: String = "",
 33    val year: Int? = 1900,
 34    val isbn: String = "",
 35    val cover: String = "",
 36    val esFavorito: Boolean = false,
 37    val leido: Boolean = false
 38)
 39
 40@OptIn(ExperimentalMaterial3Api::class)
 41@Composable
 42fun PantallaListado() {
 43    // Estado local de la pantalla (en B2 pasará al ViewModel)
 44    var busqueda by remember { mutableStateOf("") }
 45    var autorSeleccionado by remember { mutableStateOf("Todos") }
 46    var libros by remember {
 47        mutableStateOf(
 48            listOf(
 49                LibroUI(
 50                    id = 1,
 51                    titulo = "Proyecto Hail Mary",
 52                    autor = "Andy Weir",
 53                    year = 2021,
 54                    isbn = "9788418037016",
 55                    cover = "https://covers.openlibrary.org/b/isbn/9788418037016-L.jpg",
 56                    esFavorito = true,
 57                    leido = false
 58                ),
 59                LibroUI(
 60                    id = 2,
 61                    titulo = "Juego de tronos",
 62                    autor = "George R.R. Martin",
 63                    year = 1996,
 64                    isbn = "9780307951182",
 65                    cover = "https://covers.openlibrary.org/b/isbn/9780307951182-L.jpg",
 66                    esFavorito = true,
 67                    leido = true
 68                ),
 69                LibroUI(
 70                    id = 3,
 71                    titulo = "Festín de cuervos",
 72                    autor = "George R.R. Martin",
 73                    year = 2005,
 74                    isbn = "9780307951212",
 75                    esFavorito = false,
 76                    leido = false
 77                ),
 78                LibroUI(
 79                    id = 4,
 80                    titulo = "Cementerio de Animales",
 81                    autor = "Stephen King",
 82                    year = 1983,
 83                    isbn = "9788401499845",
 84                    esFavorito = false,
 85                    leido = true
 86                ),
 87                LibroUI(
 88                    id = 5,
 89                    titulo = "El juego de Ender",
 90                    autor = "Orson Scott Card",
 91                    year = 1985,
 92                    isbn = "9788498720068",
 93                    esFavorito = false,
 94                    leido = true
 95                )
 96            )
 97        )
 98    }
 99
100    val autores = listOf("Todos") + libros.map { it.autor }.distinct().sorted()
101
102    // Filtrado reactivo
103    val librosFiltrados = libros.filter { libro ->
104        val coincideBusqueda = busqueda.isBlank() ||
105                libro.titulo.contains(busqueda, ignoreCase = true)
106        val coincideGenero = autorSeleccionado == "Todos" ||
107                libro.autor == autorSeleccionado
108        coincideBusqueda && coincideGenero
109    }
110
111    Scaffold(
112        topBar = {
113            TopAppBar(
114                title = { Text("AppDummy") },
115                actions = {
116                    IconButton(onClick = { }) {
117                        Icon(Icons.Default.AccountCircle, contentDescription = "Perfil")
118                    }
119                }
120            )
121        }
122    ) { paddingValues ->
123        Column(modifier = Modifier.padding(paddingValues)) {
124            // Barra de búsqueda
125            OutlinedTextField(
126                value = busqueda,
127                onValueChange = { busqueda = it },
128                modifier = Modifier
129                    .fillMaxWidth()
130                    .padding(horizontal = 16.dp, vertical = 8.dp),
131                placeholder = { Text("Buscar libros...") },
132                leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
133                trailingIcon = {
134                    AnimatedVisibility(visible = busqueda.isNotEmpty()) {
135                        IconButton(onClick = { busqueda = "" }) {
136                            Icon(Icons.Default.Clear, contentDescription = "Borrar búsqueda")
137                        }
138                    }
139                },
140                singleLine = true
141            )
142
143            // Chips de autores
144            LazyRow(
145                contentPadding = PaddingValues(horizontal = 16.dp),
146                horizontalArrangement = Arrangement.spacedBy(8.dp),
147                modifier = Modifier.padding(bottom = 8.dp)
148            ) {
149                items(autores) { autor ->
150                    FilterChip(
151                        selected = autor == autorSeleccionado,
152                        onClick = { autorSeleccionado = autor },
153                        label = { Text(autor) }
154                    )
155                }
156            }
157
158            // Resultado del filtrado
159            if (librosFiltrados.isEmpty()) {
160                Box(
161                    modifier = Modifier.fillMaxSize(),
162                    contentAlignment = Alignment.Center
163                ) {
164                    Column(horizontalAlignment = Alignment.CenterHorizontally) {
165                        Icon(
166                            Icons.Default.SearchOff,
167                            contentDescription = null,
168                            modifier = Modifier.size(64.dp),
169                            tint = MaterialTheme.colorScheme.onSurfaceVariant
170                        )
171                        Spacer(modifier = Modifier.height(16.dp))
172                        Text(
173                            "Sin resultados para \"$busqueda\"",
174                            style = MaterialTheme.typography.bodyLarge
175                        )
176                    }
177                }
178            } else {
179                LazyVerticalGrid(
180                    contentPadding = PaddingValues(horizontal = 8.dp),
181                    columns = GridCells.Fixed(2),
182                    horizontalArrangement = Arrangement.spacedBy(8.dp),
183                    verticalArrangement = Arrangement.spacedBy(8.dp)
184                ) {
185                    items(librosFiltrados, key = { it.id }) { libro ->
186                        ItemLibro(
187                            libro = libro,
188                            onToggleLeido = { id ->
189                                libros = libros.map {
190                                    if (it.id == id) it.copy(leido = !it.leido) else it
191                                }
192                            },
193                            onToggleFavorito = { id ->
194                                libros = libros.map {
195                                    if (it.id == id) it.copy(esFavorito = !it.esFavorito) else it
196                                }
197                            }
198                        )
199                    }
200                }
201            }
202        }
203    }
204}
205
206@Composable
207fun ItemLibro(libro: LibroUI, onToggleLeido: (Int) -> Unit, onToggleFavorito: (Int) -> Unit) {
208    Card(modifier = Modifier.fillMaxWidth()) {
209        Column(
210            modifier = Modifier
211                .fillMaxWidth()
212                .padding(8.dp)
213        ) {
214            Text(libro.titulo, style = MaterialTheme.typography.titleSmall)
215            if (Patterns.WEB_URL.matcher(libro.cover).matches())
216                AsyncImage(
217                    model = ImageRequest.Builder(LocalContext.current)
218                        .data(libro.cover)
219                        .crossfade(true)
220                        .build(),
221                    contentDescription = "Portada de ${libro.titulo}",
222                    contentScale = ContentScale.Crop,
223                    modifier = Modifier.width(200.dp)
224                )
225            else // Si no es una URL válida, se muestra una imagen por defecto
226                AsyncImage(
227                    model = R.drawable.nocover,
228                    contentDescription = "Portada de ${libro.titulo}",
229                    contentScale = ContentScale.Crop,
230                    modifier = Modifier.width(200.dp)
231                )
232
233            Text(
234                text = "${libro.autor}${libro.year}",
235                style = MaterialTheme.typography.bodySmall,
236                color = MaterialTheme.colorScheme.onSurfaceVariant
237            )
238            Row(
239                modifier = Modifier.fillMaxWidth(),
240                horizontalArrangement = Arrangement.Center
241            ) {
242                IconButton(
243                    modifier = Modifier
244                        .fillMaxSize()
245                        .weight(0.5f),
246                    onClick = { onToggleLeido(libro.id) }) {
247                    Icon(
248                        imageVector = if (libro.leido) Icons.Default.BookmarkAdded
249                        else Icons.Default.BookmarkBorder,
250                        contentDescription = if (libro.leido) "Quitar leído" else "Marcar como leído",
251                        tint = if (libro.leido) MaterialTheme.colorScheme.error
252                        else MaterialTheme.colorScheme.onSurfaceVariant
253                    )
254                }
255                IconButton(
256                    modifier = Modifier
257                        .fillMaxSize()
258                        .weight(0.5f),
259                    onClick = { onToggleFavorito(libro.id) }) {
260                    Icon(
261                        imageVector = if (libro.esFavorito) Icons.Default.Favorite
262                        else Icons.Default.FavoriteBorder,
263                        contentDescription = if (libro.esFavorito) "Quitar favorito" else "Añadir favorito",
264                        tint = if (libro.esFavorito) MaterialTheme.colorScheme.error
265                        else MaterialTheme.colorScheme.onSurfaceVariant
266                    )
267                }
268            }
269        }
270    }
271}
272
273@Preview(showBackground = true)
274@Composable
275fun PantallaListadoPreview() {
276    MaterialTheme {
277        PantallaListado()
278    }
279}

Para esta pantalla se añade la dependencia para Coil build.gradle.kts(:app):

1// Coil para la carga de imágenes
2implementation("io.coil-kt.coil3:coil-compose:3.5.0")
3implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")

O en su versión de catálogo de dependencias (TOML), añandiendo la siguiente línea al archivo libs.versions.toml:

1[versions]
2...
3coilCompose = "3.5.0"
4
5[libraries]
6coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
7coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilCompose" }

Y luego en el build.gradle.kts(:app):

1// Coil para la carga de imágenes
2implementation(libs.coil.compose)
3implementation(libs.coil.network.okhttp)

Referencias#

Calendar  Última modificación: jueves, 16 de julio de 2026