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)