Tema 1B. Introducción a Jetpack Compose

  • Bloque: B1 — Fundamentos: Kotlin, Compose y entorno Android
  • Duración aproximada: 3 horas
  • Aplicación de referencia: AppDummy biblioteca personal — proyecto vertebrador.
  • 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-c Se han instalado, configurado y utilizado entornos de trabajo para el desarrollo de aplicaciones para dispositivos móviles.
RA1-f Se ha analizado la estructura de aplicaciones existentes para dispositivos móviles identificando las clases utilizadas.
RA1-g Se han realizado modificaciones sobre aplicaciones existentes.
RA1-h Se han utilizado emuladores para comprobar el funcionamiento de las aplicaciones.

Este tema da comienzo al primer desarrollo práctico guiado del módulo PMDM. Construirás la primera versión de AppDummy, la biblioteca personal que acompaña al módulo, siguiendo los desarrollos guiados de la documentación del bloque 1.
Sigue los siguientes requisitos para la creación del proyecto:\

  1. Crea un proyecto nuevo en Android Studio con la plantilla Empty Activity (Kotlin + Jetpack Compose).
  2. Utiliza como paquete edu.apellido1Nombre.appdummy
  3. Inicializa un repositorio Git desde el primer momento (VCS ▸ Enable Version Control Integration) y haz commits frecuentes a medida que avances.

1. ¿Qué es Jetpack Compose?#

Jetpack Compose es el framework moderno de Android para construir interfaces de usuario de forma declarativa. Fue publicado de forma estable por Google en 2021 y se ha convertido en la forma oficial y recomendada de desarrollar interfaces en Android, sustituyendo progresivamente al sistema clásico basado en archivos XML.

1.1 Declarativo vs imperativo#

El cambio de paradigma es conceptualmente importante. Merece la pena entenderlo bien antes de escribir código.

Sistema clásico (imperativo con XML): La UI se define en archivos XML y se manipula desde Kotlin/Java. El desarrollador le dice al sistema cómo cambiar la UI paso a paso.

1<!-- activity_main.xml -->
2<TextView android:id="@+id/textContador" android:text="0" />
3<Button android:id="@+id/btnIncrementar" android:text="+" />
1// MainActivity.kt — manipulación imperativa
2var contador = 0
3btnIncrementar.setOnClickListener {
4    contador++
5    textContador.text = contador.toString()   // actualiza manualmente la vista
6}

Compose (declarativo): La UI se describe en función del estado. Cuando el estado cambia, Compose recalcula y actualiza automáticamente la parte afectada de la UI. El desarrollador dice qué mostrar, no cómo actualizarlo.

 1// Compose — la UI es una función del estado
 2@Composable
 3fun ContadorScreen() {
 4    var contador by remember { mutableIntStateOf(0) }
 5
 6    Column {
 7        Text(text = "$contador")   // siempre muestra el valor actual de contador
 8        Button(onClick = { contador++ }) {
 9            Text("+")
10        }
11    }
12    // No hay que decirle al Text que se actualice: Compose lo hace automáticamente
13}

1.2 Ventajas principales#

  • Menos código: no se necesitan archivos XML, findViewById, ni adaptadores (Adapters).
  • Reactividad automática: la UI se actualiza sola cuando cambia el estado.
  • Kotlin nativo: aprovecha todas las características del lenguaje (lambdas, extensiones, etc.).
  • Previsualizaciones: permite ver la UI en Android Studio sin ejecutar la app.
  • Interoperabilidad: se puede combinar con vistas XML antiguas cuando sea necesario.

2. Arquitectura interna de Compose#

Compose está formado por tres capas que trabajan de forma coordinada pero independiente:

┌─────────────────────────────────────────────────────────────┐
│  Compose UI                                                 │
│  Elementos visuales: Text, Button, Column, Row, Image...    │
│  Es donde trabaja el desarrollador                          │
├─────────────────────────────────────────────────────────────┤
│  Compose Runtime                                            │
│  Motor de ejecución: gestiona el estado, detecta cambios    │
│  y decide qué partes de la UI deben recomponerse            │
├─────────────────────────────────────────────────────────────┤
│  Compose Compiler                                           │
│  Plugin del compilador de Kotlin que transforma las         │
│  funciones @Composable en código optimizado                 │
└─────────────────────────────────────────────────────────────┘

Esta separación en capas hace que Compose sea extensible: se puede reemplazar la capa de UI (hay variantes para Desktop, Web y TV) manteniendo el runtime y el compilador.


3. Estructura de un proyecto Compose#

Al crear un proyecto nuevo en Android Studio con la plantilla Empty Activity, la estructura básica es:

app/
├── src/main/
│   ├── java/com.ejemplo.app/
│   │   └── MainActivity.kt       ← punto de entrada
│   ├── res/
│   │   ├── values/
│   │   │   ├── themes.xml        ← tema Material
│   │   │   └── strings.xml
│   │   └── drawable/             ← recursos gráficos
│   └── AndroidManifest.xml       ← configuración de la app
└── build.gradle.kts              ← dependencias

El archivo MainActivity.kt tiene esta forma básica:

 1class MainActivity : ComponentActivity() {
 2    override fun onCreate(savedInstanceState: Bundle?) {
 3        super.onCreate(savedInstanceState)
 4        // enableEdgeToEdge() activa el diseño de pantalla completa (Android 15+)
 5        enableEdgeToEdge()
 6        setContent {
 7            // AppTheme aplica el tema Material3 definido en themes.xml
 8            AppTheme {
 9                // Composable raíz de la app — punto de entrada de la UI
10                AppDummyApp()
11            }
12        }
13    }
14}

Dependencias necesarias#

 1// build.gradle.kts (app)
 2plugins {
 3    id("com.android.application")
 4    id("org.jetbrains.kotlin.android")
 5}
 6
 7android {
 8    compileSdk = 35
 9    defaultConfig {
10        minSdk = 26       // Android 8.0 — cubre más del 95% de dispositivos activos
11        targetSdk = 35
12    }
13    buildFeatures {
14        compose = true    // activa el compilador de Compose
15    }
16    composeOptions {
17        kotlinCompilerExtensionVersion = "1.5.15"
18    }
19}
20
21dependencies {
22    val composeBom = platform("androidx.compose:compose-bom:2024.10.00")
23    implementation(composeBom)
24
25    // Módulos de Compose (versiones gestionadas por el BOM)
26    implementation("androidx.compose.ui:ui")
27    implementation("androidx.compose.ui:ui-tooling-preview")
28    implementation("androidx.compose.material3:material3")
29    implementation("androidx.activity:activity-compose:1.9.3")
30
31    // Herramientas de desarrollo (solo en debug)
32    debugImplementation("androidx.compose.ui:ui-tooling")
33    debugImplementation("androidx.compose.ui:ui-test-manifest")
34}

¿Qué es el BOM (Bill of Materials)? Es un archivo que gestiona las versiones de todas las dependencias de Compose de forma coordinada. Al declarar platform("androidx.compose:compose-bom:..."), no es necesario especificar versiones individuales para cada módulo de Compose; el BOM garantiza que todas sean compatibles entre sí.


4. Funciones @Composable#

La unidad básica de Compose es la función composable: una función de Kotlin anotada con @Composable que describe un fragmento de la interfaz de usuario.

 1// Convención: el nombre de un Composable empieza por mayúscula (como una clase)
 2@Composable
 3fun Saludo(nombre: String) {
 4    Text(text = "Hola, $nombre")
 5}
 6
 7// Composable que puede visualizarse en el editor sin ejecutar la app
 8@Preview(showBackground = true, name = "Saludo de Ana")
 9@Composable
10fun SaludoPreview() {
11    Saludo("Ana")
12}

Reglas de las funciones @Composable#

  1. Solo pueden llamarse desde otras funciones @Composable (o desde un ámbito de composición como setContent).
  2. No deben tener efectos secundarios directos: no modificar variables externas, no escribir en base de datos, no hacer llamadas de red directamente.
  3. Deben ser idempotentes: llamarlas varias veces con los mismos parámetros debe producir el mismo resultado.
  4. Pueden ejecutarse en cualquier orden y en paralelo (el runtime optimiza esto).

Composición y recomposición#

Cuando el estado del que depende un Composable cambia, Compose vuelve a ejecutar ese Composable (y solo ese, en la medida de lo posible). Este proceso se llama recomposición:

Estado inicial        Estado tras click
─────────────         ────────────────
contador = 0          contador = 1
     ↓                      ↓
Text("0")             Text("1")   ← recompuesto automáticamente

Compose es inteligente: si el parámetro de un Composable no cambia, no lo recompone. Esto hace que la UI sea eficiente incluso con muchos elementos.


5. Layouts: Column, Row, Box#

Los layouts son Composables que organizan sus hijos en el espacio. Los tres fundamentales son:

Column — apilamiento vertical#

 1@Composable
 2fun EjemploColumn() {
 3    Column(
 4        modifier = Modifier
 5            .fillMaxSize()
 6            .padding(16.dp),
 7        horizontalAlignment = Alignment.CenterHorizontally,   // centra horizontalmente
 8        verticalArrangement = Arrangement.spacedBy(8.dp)      // espacio entre hijos
 9    ) {
10        Text("Primer elemento")
11        Text("Segundo elemento")
12        Button(onClick = { }) { Text("Botón") }
13    }
14}

Row — apilamiento horizontal#

 1@Composable
 2fun EjemploRow() {
 3    Row(
 4        modifier = Modifier.fillMaxWidth().padding(8.dp),
 5        verticalAlignment = Alignment.CenterVertically,
 6        horizontalArrangement = Arrangement.SpaceBetween   // distribución entre extremos
 7    ) {
 8        Text("Izquierda")
 9        Icon(Icons.Default.Star, contentDescription = "Favorito")
10        Text("Derecha")
11    }
12}

Box — apilamiento en profundidad (Z)#

 1@Composable
 2fun EjemploBox() {
 3    Box(
 4        modifier = Modifier.size(200.dp),
 5        contentAlignment = Alignment.Center   // centra el contenido por defecto
 6    ) {
 7        Image(
 8            painter = painterResource(R.drawable.poster),
 9            contentDescription = "Poster",
10            modifier = Modifier.fillMaxSize()
11        )
12        // Este texto se superpone sobre la imagen
13        Text(
14            text = "★ 8.5",
15            color = Color.White,
16            modifier = Modifier
17                .align(Alignment.BottomEnd)   // esquina inferior derecha
18                .padding(8.dp)
19        )
20    }
21}

Composición de layouts#

Los layouts se pueden anidar libremente para crear cualquier distribución:

 1@Composable
 2fun TarjetaPelicula(titulo: String, puntuacion: Double, posterUrl: String) {
 3    Row(
 4        modifier = Modifier
 5            .fillMaxWidth()
 6            .padding(8.dp),
 7        verticalAlignment = Alignment.Top
 8    ) {
 9        // Poster a la izquierda
10        Box(modifier = Modifier.size(80.dp, 120.dp)) {
11            // Aquí iría AsyncImage de Coil (Bloque 3)
12        }
13        Spacer(modifier = Modifier.width(12.dp))
14
15        // Información a la derecha
16        Column(modifier = Modifier.weight(1f)) {
17            Text(text = titulo, style = MaterialTheme.typography.titleMedium)
18            Spacer(modifier = Modifier.height(4.dp))
19            Text(text = "★ $puntuacion", style = MaterialTheme.typography.bodyMedium)
20        }
21    }
22}

6. Componentes básicos de UI#

Text#

 1@Composable
 2fun EjemploText() {
 3    Column(modifier = Modifier.padding(16.dp)) {
 4        // Estilo Material3
 5        Text(
 6            text = "Título principal",
 7            style = MaterialTheme.typography.headlineLarge
 8        )
 9        Text(
10            text = "Subtítulo",
11            style = MaterialTheme.typography.titleMedium,
12            color = MaterialTheme.colorScheme.primary
13        )
14        Text(
15            text = "Texto con formato avanzado usando AnnotatedString",
16            style = MaterialTheme.typography.bodyMedium,
17            maxLines = 2,
18            overflow = TextOverflow.Ellipsis   // "..." si no cabe
19        )
20    }
21}

Button y variantes#

 1@Composable
 2fun EjemploBotones() {
 3    Column(
 4        modifier = Modifier.padding(16.dp),
 5        verticalArrangement = Arrangement.spacedBy(8.dp)
 6    ) {
 7        // Botón relleno — acción principal
 8        Button(onClick = { /* acción */ }) {
 9            Icon(Icons.Default.PlayArrow, contentDescription = null)
10            Spacer(modifier = Modifier.width(4.dp))
11            Text("Reproducir")
12        }
13
14        // Botón con borde — acción secundaria
15        OutlinedButton(onClick = { }) {
16            Text("Añadir a favoritos")
17        }
18
19        // Botón de texto — acción terciaria
20        TextButton(onClick = { }) {
21            Text("Ver detalles")
22        }
23
24        // FAB (Floating Action Button)
25        FloatingActionButton(onClick = { }) {
26            Icon(Icons.Default.Add, contentDescription = "Añadir película")
27        }
28    }
29}

TextField#

 1@Composable
 2fun EjemploTextField() {
 3    var texto by remember { mutableStateOf("") }
 4
 5    OutlinedTextField(
 6        value = texto,
 7        onValueChange = { texto = it },
 8        label = { Text("Buscar película") },
 9        leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
10        trailingIcon = {
11            if (texto.isNotEmpty()) {
12                IconButton(onClick = { texto = "" }) {
13                    Icon(Icons.Default.Clear, contentDescription = "Borrar")
14                }
15            }
16        },
17        singleLine = true,
18        modifier = Modifier.fillMaxWidth()
19    )
20}

Image#

 1@Composable
 2fun EjemploImagen() {
 3    // Imagen local (de la carpeta res/drawable)
 4    Image(
 5        painter = painterResource(id = R.drawable.logo),
 6        contentDescription = "Logo de AppDummy",   // obligatorio para accesibilidad
 7        modifier = Modifier
 8            .size(120.dp)
 9            .clip(CircleShape),                    // recorte circular
10        contentScale = ContentScale.Crop           // rellena recortando si hace falta
11    )
12}

7. Modifier 🔑#

Modifier es el mecanismo de Compose para ajustar el aspecto, comportamiento y disposición de cualquier Composable. Se aplica de forma encadenada y el orden importa:

 1@Composable
 2fun EjemploModifier() {
 3    // El orden de los modificadores afecta al resultado visual
 4    Text(
 5        text = "Con padding fuera del fondo",
 6        modifier = Modifier
 7            .background(Color.Yellow)   // el fondo NO incluye el padding
 8            .padding(16.dp)             // el padding se aplica DENTRO del fondo
 9    )
10
11    Spacer(modifier = Modifier.height(16.dp))
12
13    Text(
14        text = "Con fondo sobre el padding",
15        modifier = Modifier
16            .padding(16.dp)             // el padding se aplica ANTES del fondo
17            .background(Color.Yellow)   // el fondo SÍ incluye el padding
18    )
19}

Modificadores más usados#

 1Modifier
 2    // Tamaño
 3    .fillMaxSize()              // ocupa todo el espacio disponible
 4    .fillMaxWidth()             // ancho máximo
 5    .fillMaxHeight()            // alto máximo
 6    .size(100.dp)               // tamaño fijo
 7    .width(200.dp)
 8    .height(50.dp)
 9    .wrapContentSize()          // se ajusta al contenido
10
11    // Espaciado
12    .padding(16.dp)             // todos los lados
13    .padding(horizontal = 16.dp, vertical = 8.dp)
14    .padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp)
15
16    // Apariencia
17    .background(Color.Blue)
18    .background(MaterialTheme.colorScheme.surface)
19    .clip(RoundedCornerShape(8.dp))    // esquinas redondeadas
20    .clip(CircleShape)
21    .border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
22    .alpha(0.5f)                       // opacidad
23
24    // Interacción
25    .clickable { /* acción */ }
26    .clickable(enabled = condicion) { /* acción */ }
27
28    // Posicionamiento
29    .offset(x = 8.dp, y = 4.dp)
30    .align(Alignment.CenterHorizontally)   // dentro de Column
31    .weight(1f)                            // en Row o Column: ocupa el espacio proporcional

8. Estado y recomposición 🔑#

El estado en Compose es cualquier valor cuyo cambio debe provocar una actualización de la UI. Este es el concepto más importante de Compose: la UI es siempre una función del estado.

UI = f(Estado)

remember y mutableStateOf#

 1@Composable
 2fun ContadorCompleto() {
 3    // remember: guarda el valor entre recomposiciones
 4    // mutableStateOf: crea un observable que Compose vigila
 5    // by: delegación de propiedades (by remember { ... } vs = remember { ... })
 6    var contador by remember { mutableIntStateOf(0) }   // optimizado para Int
 7
 8    Column(
 9        modifier = Modifier.fillMaxSize(),
10        horizontalAlignment = Alignment.CenterHorizontally,
11        verticalArrangement = Arrangement.Center
12    ) {
13        Text(
14            text = "Contador: $contador",
15            style = MaterialTheme.typography.displayMedium
16        )
17        Spacer(modifier = Modifier.height(24.dp))
18        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
19            Button(
20                onClick = { if (contador > 0) contador-- },
21                enabled = contador > 0
22            ) { Text("−") }
23
24            Button(onClick = { contador++ }) { Text("+") }
25        }
26    }
27}

¿Por qué remember?#

Sin remember, el estado se reinicializaría en cada recomposición (cada vez que Compose redibuja el Composable). Con remember, el valor sobrevive a las recomposiciones:

 1@Composable
 2fun DemoSinRemember() {
 3    var x = 0   // ⚠️ se reinicia a 0 en cada recomposición
 4    Button(onClick = { x++ }) { Text("x = $x") }
 5    // x nunca supera 0: se reinicia antes de mostrarse
 6}
 7
 8@Composable
 9fun DemoConRemember() {
10    var x by remember { mutableIntStateOf(0) }   // ✅ persiste entre recomposiciones
11    Button(onClick = { x++ }) { Text("x = $x") }
12}

remember solo sobrevive a recomposiciones. Si la Activity se destruye (rotación de pantalla, proceso killed), el estado se pierde. Para que el estado sobreviva a estos eventos, se usa rememberSaveable o, en arquitectura MVVM, el ViewModel (Bloque 2).

rememberSaveable#

 1@Composable
 2fun DemoRememberSaveable() {
 3    // Sobrevive a rotación de pantalla y a death del proceso
 4    var texto by rememberSaveable { mutableStateOf("") }
 5
 6    OutlinedTextField(
 7        value = texto,
 8        onValueChange = { texto = it },
 9        label = { Text("Persiste al rotar") }
10    )
11}

Estado derivado#

 1@Composable
 2fun ListaConFiltro() {
 3    val peliculas = remember {
 4        listOf("Dune", "Inception", "Interstellar", "Dune: Parte 2", "Morbius")
 5    }
 6    var filtro by remember { mutableStateOf("") }
 7
 8    // derivedStateOf: recalcula solo cuando cambien sus dependencias
 9    val peliculasFiltradas by remember(filtro) {
10        derivedStateOf {
11            if (filtro.isBlank()) peliculas
12            else peliculas.filter { it.contains(filtro, ignoreCase = true) }
13        }
14    }
15
16    Column(modifier = Modifier.padding(16.dp)) {
17        OutlinedTextField(
18            value = filtro,
19            onValueChange = { filtro = it },
20            label = { Text("Filtrar") },
21            modifier = Modifier.fillMaxWidth()
22        )
23        Spacer(modifier = Modifier.height(8.dp))
24        peliculasFiltradas.forEach { pelicula ->
25            Text(pelicula, modifier = Modifier.padding(vertical = 4.dp))
26        }
27    }
28}

Puedes ampliar información sobre remember y otras funciones de estado en la documentación oficial y en el Anexo 2 de este tema.


9. Listas eficientes: LazyColumn y LazyRow 🔑#

Para listas con muchos elementos, Compose proporciona composables “perezosos” que solo renderizan los elementos visibles, equivalentes al RecyclerView del sistema clásico:

 1@Composable
 2fun ListaPeliculas(peliculas: List<String>) {
 3    LazyColumn(
 4        contentPadding = PaddingValues(16.dp),          // padding exterior
 5        verticalArrangement = Arrangement.spacedBy(8.dp) // espacio entre elementos
 6    ) {
 7        // items con lista
 8        items(
 9            items = peliculas,
10            key = { pelicula -> pelicula }   // clave única para optimizar recomposiciones
11        ) { pelicula ->
12            TarjetaPelicula(pelicula)
13        }
14
15        // item individual (cabecera, separador, etc.)
16        item {
17            Divider()
18            Text(
19                text = "${peliculas.size} películas en total",
20                style = MaterialTheme.typography.bodySmall,
21                modifier = Modifier.padding(8.dp)
22            )
23        }
24    }
25}
26
27// Elemento individual de la lista
28@Composable
29fun TarjetaPelicula(titulo: String) {
30    Card(
31        modifier = Modifier.fillMaxWidth(),
32        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
33    ) {
34        Row(
35            modifier = Modifier
36                .fillMaxWidth()
37                .padding(16.dp),
38            verticalAlignment = Alignment.CenterVertically
39        ) {
40            Icon(
41                imageVector = Icons.Default.Movie,
42                contentDescription = null,
43                tint = MaterialTheme.colorScheme.primary
44            )
45            Spacer(modifier = Modifier.width(12.dp))
46            Text(
47                text = titulo,
48                style = MaterialTheme.typography.bodyLarge,
49                modifier = Modifier.weight(1f)
50            )
51            Icon(
52                imageVector = Icons.Default.ChevronRight,
53                contentDescription = "Ver detalle"
54            )
55        }
56    }
57}

LazyRow — lista horizontal#

 1@Composable
 2fun CarruselGeneros(generos: List<String>, seleccionado: String, onSeleccion: (String) -> Unit) {
 3    LazyRow(
 4        horizontalArrangement = Arrangement.spacedBy(8.dp),
 5        contentPadding = PaddingValues(horizontal = 16.dp)
 6    ) {
 7        items(generos) { genero ->
 8            FilterChip(
 9                selected = genero == seleccionado,
10                onClick = { onSeleccion(genero) },
11                label = { Text(genero) }
12            )
13        }
14    }
15}

10. Scaffold: estructura básica de pantalla 🔑#

Scaffold implementa la estructura visual estándar de Material Design: barra superior, barra inferior, botón de acción flotante y contenido principal.

 1@Composable
 2fun AppDummyScreen() {
 3    // snackbarHostState gestiona los mensajes temporales (Snackbar)
 4    val snackbarHostState = remember { SnackbarHostState() }
 5    val scope = rememberCoroutineScope()
 6
 7    Scaffold(
 8        topBar = {
 9            TopAppBar(
10                title = { Text("AppDummy") },
11                navigationIcon = {
12                    IconButton(onClick = { /* abrir menú o navegar atrás */ }) {
13                        Icon(Icons.Default.Menu, contentDescription = "Menú")
14                    }
15                },
16                actions = {
17                    IconButton(onClick = { /* búsqueda */ }) {
18                        Icon(Icons.Default.Search, contentDescription = "Buscar")
19                    }
20                },
21                colors = TopAppBarDefaults.topAppBarColors(
22                    containerColor = MaterialTheme.colorScheme.primaryContainer
23                )
24            )
25        },
26        floatingActionButton = {
27            FloatingActionButton(onClick = { /* añadir */ }) {
28                Icon(Icons.Default.Add, contentDescription = "Añadir película")
29            }
30        },
31        snackbarHost = { SnackbarHost(snackbarHostState) }
32    ) { paddingValues ->
33        // paddingValues contiene el espacio ocupado por TopAppBar y FAB
34        // SIEMPRE debe aplicarse al contenido principal
35        LazyColumn(contentPadding = paddingValues) {
36            items(10) { index ->
37                TarjetaPelicula("Película $index")
38            }
39        }
40    }
41}

11. Diálogos y notificaciones#

AlertDialog#

 1@Composable
 2fun EjemploDialogo() {
 3    var mostrarDialogo by remember { mutableStateOf(false) }
 4
 5    Button(onClick = { mostrarDialogo = true }) {
 6        Text("Eliminar favorito")
 7    }
 8
 9    if (mostrarDialogo) {
10        AlertDialog(
11            onDismissRequest = { mostrarDialogo = false },
12            title = { Text("Confirmar eliminación") },
13            text = { Text("¿Seguro que quieres eliminar esta película de favoritos?") },
14            confirmButton = {
15                TextButton(onClick = {
16                    mostrarDialogo = false
17                    /* acción de eliminación */
18                }) { Text("Eliminar") }
19            },
20            dismissButton = {
21                TextButton(onClick = { mostrarDialogo = false }) {
22                    Text("Cancelar")
23                }
24            }
25        )
26    }
27}

Snackbar#

 1@Composable
 2fun EjemploSnackbar() {
 3    val snackbarHostState = remember { SnackbarHostState() }
 4    val scope = rememberCoroutineScope()
 5
 6    Scaffold(
 7        snackbarHost = { SnackbarHost(snackbarHostState) }
 8    ) { padding ->
 9        Column(modifier = Modifier.padding(padding)) {
10            Button(
11                onClick = {
12                    scope.launch {
13                        val resultado = snackbarHostState.showSnackbar(
14                            message = "Película añadida a favoritos",
15                            actionLabel = "Deshacer",
16                            duration = SnackbarDuration.Short
17                        )
18                        if (resultado == SnackbarResult.ActionPerformed) {
19                            // El usuario pulsó "Deshacer"
20                        }
21                    }
22                }
23            ) { Text("Añadir a favoritos") }
24        }
25    }
26}

12. Previsualizaciones en Android Studio#

Las anotaciones @Preview permiten ver el Composable directamente en el editor sin ejecutar la app. Puedes tener múltiples previsualizaciones en el mismo archivo:

 1// Previsualización básica
 2@Preview(showBackground = true)
 3@Composable
 4fun TarjetaPeliculaPreview() {
 5    AppTheme {
 6        TarjetaPelicula("Dune: Parte 2")
 7    }
 8}
 9
10// Previsualización en modo oscuro
11@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES, name = "Modo oscuro")
12@Composable
13fun TarjetaPeliculaDarkPreview() {
14    AppTheme {
15        TarjetaPelicula("Dune: Parte 2")
16    }
17}
18
19// Previsualización con tamaño de dispositivo específico
20@Preview(
21    showBackground = true,
22    device = "spec:width=411dp,height=891dp",
23    name = "Pixel 5"
24)
25@Composable
26fun PantallaCompletaPreview() {
27    AppTheme {
28        AppDummyScreen()
29    }
30}

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

Este ejemplo integra todos los conceptos de la sección en una pantalla autocontenida y totalmente funcional. Se partirá de un proyecto nuevo de Android Studio con plantilla Empty Activity, o puedes hacer uso del proyecto ya creado v0. Creación inicial del proyecto AppDummy . Se añadirá el siguiente Composable, creando para ello un nuevo archivo PantallaBienvenida.kt en el paquete com.ejemplo.appdummy:

Si haces uso del proyecto de ejemplo, verás que el nombre del paquete es es.javiercarrasco.appdummy. Puedes cambiarlo a com.ejemplo.appdummy o con tu nombre si lo deseas, pero no es necesario para que funcione.

 1package com.ejemplo.appdummy
 2
 3import androidx.compose.foundation.layout.*
 4import androidx.compose.foundation.shape.RoundedCornerShape
 5import androidx.compose.material.icons.Icons
 6import androidx.compose.material.icons.filled.Book
 7import androidx.compose.material3.*
 8import androidx.compose.runtime.*
 9import androidx.compose.ui.Alignment
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.text.style.TextAlign
12import androidx.compose.ui.tooling.preview.Preview
13import androidx.compose.ui.unit.dp
14
15// ─── PantallaBienvenida.kt ───────────────────────────────────────────────────────────────────────
16@Composable
17fun PantallaBienvenida(onEntrar: () -> Unit) {
18    var nombreUsuario by remember { mutableStateOf("") }
19    val botonHabilitado = nombreUsuario.trim().length >= 3
20
21    Column(
22        modifier = Modifier
23            .fillMaxSize()
24            .padding(32.dp),
25        horizontalAlignment = Alignment.CenterHorizontally,
26        verticalArrangement = Arrangement.Center
27    ) {
28        // Icono y título
29        Icon(
30            imageVector = Icons.Default.Book,
31            contentDescription = null,
32            modifier = Modifier.size(80.dp),
33            tint = MaterialTheme.colorScheme.primary
34        )
35        Spacer(modifier = Modifier.height(16.dp))
36        Text(
37            text = "AppDummy",
38            style = MaterialTheme.typography.displayMedium,
39            color = MaterialTheme.colorScheme.primary
40        )
41        Text(
42            text = "Tu catálogo de libros",
43            style = MaterialTheme.typography.bodyLarge,
44            color = MaterialTheme.colorScheme.onSurfaceVariant,
45            textAlign = TextAlign.Center
46        )
47
48        Spacer(modifier = Modifier.height(48.dp))
49
50        // Campo de nombre
51        OutlinedTextField(
52            value = nombreUsuario,
53            onValueChange = { nombreUsuario = it },
54            label = { Text("¿Cómo te llamas?") },
55            singleLine = true,
56            modifier = Modifier.fillMaxWidth(),
57            supportingText = {
58                Text("Mínimo 3 caracteres (${nombreUsuario.length}/3)")
59            }
60        )
61
62        Spacer(modifier = Modifier.height(24.dp))
63
64        // Botón de acceso
65        Button(
66            onClick = onEntrar,
67            enabled = botonHabilitado,
68            modifier = Modifier
69                .fillMaxWidth()
70                .height(56.dp),
71            shape = RoundedCornerShape(8.dp)
72        ) {
73            Text(
74                text = if (botonHabilitado) "Entrar como ${nombreUsuario.trim()}" else "Entrar",
75                style = MaterialTheme.typography.labelLarge
76            )
77        }
78    }
79}
80
81@Preview(showBackground = true)
82@Composable
83fun PantallaBienvenidaPreview() {
84    MaterialTheme {
85        PantallaBienvenida(onEntrar = { })
86    }
87}

Este ejemplo hace uso de la dependencia de Material de Icons extendida, por lo que es necesario añadirla al build.gradle.kts(:app):

1// Material Icons Extended
2implementation("androidx.compose.material:material-icons-extended:1.7.8")

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

1androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }

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

1// Material Icons Extended
2implementation("androidx.compose.material:material-icons-extended")

Referencias#

Calendar  Última modificación: jueves, 17 de septiembre de 2026