Tema 2: Interfaz de usuario

Objetivos de este tema

  • Comprender y aplicar modificadores de Jetpack Compose para ajustar el estilo, disposición y comportamiento de los elementos de la UI.
  • Diseñar y personalizar layouts adaptados a diferentes contextos mediante Modifier.layout, medidas intrínsecas y restricciones.
  • Analizar el proceso de renderizado en Compose.
  • Utilizar componentes gráficos como Canvas y graphicsLayer para enriquecer la experiencia visual con formas, transformaciones y animaciones.
  • Estructurar pantallas completas usando Scaffold, barras de herramientas (TopAppBar) y acciones flotantes (FAB).
  • Crear listas eficientes y reutilizables con LazyColumn y LazyRow (alternativa moderna a RecyclerView).
  • Implementar elementos interactivos esenciales como Snackbar, Toast, cuadros de diálogo (AlertDialog) y menús desplegables (Spinner).
  • Gestionar la navegación entre pantallas utilizando tanto múltiples Activities, como la solución moderna basada en Navigation Compose.
  • Evaluar cuándo conviene usar una arquitectura basada en múltiples actividades frente a una navegación controlada dentro de una única actividad.

2.1. Modificadores

Los modificadores en Jetpack Compose son objetos que permiten modificar, o extender el comportamiento y la apariencia de un elemento Composable, como puede ser su tamaño, padding, clics, animaciones o aspecto gráfico. Son un componente esencial para la creación de interfaces en Compose.

2.1.1. Sintaxis de encadenamiento

La sintaxis de los modificadores está basada en el encadenamiento de funciones, similar a la programación funcional. Se aplica utilizando el operador punto(.) sobre el parámetro modifier que acepta cada Composable.

1Text(
2    text = "Hola mundo",
3    modifier = Modifier
4        .padding(16.dp)
5        .background(Color.LightGray)
6        .clickable { /* acción */ }
7)

Este código de ejemplo, muestra un Text que tiene un padding, un fondo gris y responde al clic.

  • Todos los modificadores devuelven un nuevo Modifier, lo que permite que su encadenamiento sea fluido.
  • Como se verá a continuación, el orden importa.

2.1.2. Orden de aplicación y optimización

Destacar que en Compose, el orden de los modificadores afectará directamente al resultado visual y funcional.

1// El padding se aplica antes que el fondo.
2Modifier
3    .padding(16.dp)
4    .background(Color.Red)

Utilizando este orden se quedará el fondo ajustado al contenido sin incluir el padding. Sin embargo:

1// El fondo se aplica antes del padding.
2Modifier
3    .background(Color.Red)
4    .padding(16.dp)

En este caso el fondo abarca también el espacio del padding, ya que se aplicará primero.

Información

Los modificadores se aplicarán de arriba hacia abajo según el orden escrito, es decir, de izquierda a derecha en el renderizado visual.

Optimización

Jetpack Compose está diseñado para optimizar los modificadores comunes, como padding, size, offset o background, pero:

  • Aplicar muchos modificadores anidados de manera innecesaria puede aumentar el número de nodos en la jerarquía (LayoutNode).
  • Es recomendable agrupar modificadores relacionados y evitar repeticiones.

2.1.3. Modificadores internos vs personalizados

Modificadores internos

Los modificadores internos son los que proporciona Compose, entre los más comunes:

  • padding()
  • background()
  • fillMaxWidth(), height(), size()
  • clickable()
  • offset()
  • graphicsLayer()

Totalmente optimizados y recomendados cuando se ajustan a las necesidades.

Modificadores personalizados

Es posible crear tus propios modificadores cuando sea necesario encapsular lógica de presentación, o comportamiento, para simplificar, mejorar la legibilidad y reutilizar código. Por ejemplo:

1fun Modifier.tarjetaRedonda(): Modifier = this
2    .padding(8.dp)
3    .clip(RoundedCornerShape(16.dp))
4    .background(Color.White)

Este modificador personalizado se utilizaría de la siguiente forma:

1Box(modifier = Modifier.tarjetaRedonda())
Consejo

Puedes también crear modificadores más complejos utilizando funciones como Modifier.drawBehind, Modifier.composed, o incluso Modifier.pointerInput para gestos personalizados.

Ejemplo 2.1. Modificadores

El siguiente ejemplo muestra el encadenamiento de modificadores, el orden en padding y background y un modificador personalizado (tarjetaRedonda).

Para verlo en funcionamiento puedes pegar este código en cualquier @Composable de Android Studio o en el componente de vista previa (@Preview).

 1@Composable
 2fun ModificadoresDemo() {
 3    var isHovered by remember { mutableStateOf(false) }
 4
 5    Column(
 6        modifier = Modifier
 7            .fillMaxSize()
 8            .padding(16.dp),
 9        verticalArrangement = Arrangement.spacedBy(20.dp)
10    ) {
11        Text("1. Orden: Padding antes de Background")
12        Box(
13            modifier = Modifier
14                .padding(16.dp)
15                .background(Color.Red)
16        ) {
17            Text(
18                "Texto con padding interno",
19                modifier = Modifier.padding(8.dp)
20            )
21        }
22
23        Text("2. Orden: Background antes de Padding")
24        Box(
25            modifier = Modifier
26                .background(Color.Green)
27                .padding(16.dp)
28        ) {
29            Text(
30                "Texto con fondo más grande",
31                modifier = Modifier.padding(8.dp)
32            )
33        }
34
35        Text("3. Modificador personalizado: tarjetaRedonda")
36        Box(
37            modifier = Modifier.tarjetaRedonda()
38        ) {
39            Text(
40                "Texto dentro de tarjeta",
41                modifier = Modifier.padding(16.dp)
42            )
43        }
44
45        val context = LocalContext.current
46        Text("4. Con `clickable` (logcat)")
47        Box(
48            modifier = Modifier
49                .tarjetaRedonda()
50                .clickable {
51                    Log.d("Compose", "Tarjeta clicada")
52                    Toast.makeText(context, "Tarjeta clicada", Toast.LENGTH_SHORT).show()
53                }
54        ) {
55            Text(
56                "Haz clic en esta tarjeta",
57                modifier = Modifier.padding(16.dp)
58            )
59        }
60    }
61}
62
63// Modificador personalizado
64fun Modifier.tarjetaRedonda(): Modifier = this
65    .padding(8.dp)
66    .clip(RoundedCornerShape(16.dp))
67    .background(Color.White)
68    .border(2.dp, Color.Gray, RoundedCornerShape(16.dp))

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente código:

1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaModificadores() {
4    ModificadoresDemo()
5}
Información

En este ejemplo se introduce el concepto de Context, su uso en Compose varía. Se utiliza LocalContext.current, que permite acceder al contexto de Android dentro de un método Composable.

2.2. Layout personalizado con Modifier.layout

Modifier.layout permite crear Composables con disposición personalizada, es decir, que no dependen de los layouts predeterminados como Column, Row o Box, sino que definen sus propias medidas y ubicación del contenido.

2.2.1. Cómo crear layouts con reglas propias

Conceptos clave

  • Modifier.layout { measurable, constraints -> ... } permite un control total sobre cómo se mide y posiciona un composable hijo.
  • Trabaja directamente con el ciclo de composición y disposición: Measurable.measure() → Placeable.place().
  • Se pueden implementar reglas personalizadas: alineaciones, offsets, centrar, limitar tamaño, aplicar rotaciones manuales, etc.

Estructura general de uso

1Modifier.layout { measurable, constraints ->
2    val placeable = measurable.measure(constraints)
3    layout(placeable.width, placeable.height) {
4        placeable.place(x = 0, y = 0)
5    }
6}
Detalles importantes
  • measurable: representa el contenido hijo.
  • constraints: definen el tamaño máximo y mínimo permitido.
  • placeable: resultado de medir el hijo.
  • layout(width, height): define el tamaño del layout padre.
  • place(x, y): define la posición del hijo.

Recomendaciones de uso

Tipo Control Uso
Column / Row Medio Layouts comunes
Box Bajo Superposición simple
Modifier.layout Alto Casos muy personalizados

Ejemplos prácticos

2.3. Árbol de Layout y fases de renderizado

En este punto tratará de entenderse como funciona internamente el sistema de renderizado en Jetpack Compose a través del layout tree, o LayoutNode, y las tres fases clave del proceso de renderizado: composición, disposición y dibujo.

2.3.1. ¿Qué es el Layout Tree?

En Jetpack Compose, cada elemento visible es representado como un nodo dentro del layout tree, o LayoutNode. Este árbol definerá:

  • La jerarquía visual de la UI.
  • Cómo se calculan y distribuyen tamaños.
  • El orden en que se dibujan y posicionan los elementos.
Column
 ├── Text("Título")
 ├── Row
 │    ├── Icon
 │    └── Text("Etiqueta")
 └── Button("Aceptar")

Cada nodo tiene una relación padre-hijo, lo que permite al sistema navegar, componer y organizar la interfaz.

2.3.2. Fases de renderizado: composición, disposición, dibujo

Los tres pasos fundamentales que realiza Jetpack Compose para renderizar la UI:

Fase 1: Composición (measure)

  • Cada nodo hijo se compone en función de una serie de restricciones (Constraints) que vienen impuestas por el padre.
  • Debe decidirse el tamaño que debe ocupar el elemento.

Por ejemplo: Un Text dentro de un Box será compuesto con un ancho máximo igual al del elemento contenedor, el Box.

1val placeable = measurable.measure(constraints)

Fase 2: Disposición (place)

  • Una vez compuesto, cada Placeable se colocará en una posición concreta (x, y) dentro del contenedor padre.
  • Se decide dónde irá ubicado el hijo.
1placeable.place(x, y)

Fase 3: Dibujo (draw)

  • Para finalizar, el sistema dibujará los elementos en pantalla.
  • Se aplicarán colores, bordes, sombras, imágenes, animaciones, etc.

Se realiza después de componer y ubicar. En esta última fase se puede intervenir utilizando modificadores como:

1Modifier.drawBehind { ... }
Esquema del ciclo simplificado
Renderizado (UI declarativa)
 Composición (measure)
 Disposición (place)
    Dibujo (draw)

2.3.3. Herramientas relacionadas

  • Layout Inspector (Android Studio): permite ver el LayoutNode en tiempo real.
  • Modifier.layout: permite intervenir directamente en measure y place.
  • Modifier.drawBehind, Modifier.graphicsLayer: permiten intervenir en la fase de dibujo.

2.4. Intrínsecos y restricciones

Es necesario conocer que son las medidas intrínsecas en Jetpack Compose para saber cuándo es útil utilizarlas y cómo se pueden gestionar restricciones de tamaño, utilizando Composables como BoxWithConstraints.

2.4.1. Las medidas intrínsecas

Las medidas intrínsecas son una forma de calcular el tamaño mínimo o máximo que necesita un composable sin tener en cuenta las restricciones del padre. Se pueden usar para ajustar el layout en función del contenido, en lugar de limitarse por fillMaxWidth, wrapContentHeight, etc.

En Jetpack Compose, se pueden utilizar:

  • Modifier.width(IntrinsicSize.Min)
  • Modifier.width(IntrinsicSize.Max)
  • Modifier.height(IntrinsicSize.Min)
  • Modifier.height(IntrinsicSize.Max)

El siguiente ejemplo asegura que ambos textos tengan la altura del más alto, gracias a IntrinsicSize.Min.

 1@Composable
 2fun DemoIntrinsicSize() {
 3    Row(
 4        modifier = Modifier.height(IntrinsicSize.Min)
 5    ) {
 6        Text(
 7            text = "Texto alto",
 8            modifier = Modifier
 9                .background(Color.Red)
10                .padding(8.dp)
11        )
12        HorizontalDivider(
13            color = Color.Black,
14            modifier = Modifier
15                .fillMaxHeight()
16                .width(1.dp)
17        )
18        Text(
19            text = "Texto más corto",
20            modifier = Modifier
21                .background(Color.Green)
22                .padding(8.dp)
23        )
24    }
25}

Imagen punto 2.4 Imagen punto 2.4

2.4.2. Limitaciones de las medidas intrínsecas

  • Coste alto de rendimiento: requieren múltiples pases de medición, por lo que se deben evitar en listas largas o layouts complejos.
  • Es preferible el uso de BoxWithConstraints o Modifier.layout cuando el tamaño se predecible o haya que adaptarlo manualmente.

2.4.3. Uso de BoxWithConstraints

BoxWithConstraints permite acceder y modificar las restricciones de tamaño desde dentro del composable, lo que facilita la creación de interfaces adaptativas.

 1@SuppressLint("UnusedBoxWithConstraintsScope")
 2@Composable
 3fun CajaResponsiva() {
 4    BoxWithConstraints(
 5        modifier = Modifier.fillMaxWidth(),
 6        contentAlignment = Alignment.Center
 7    ) {
 8        if (maxWidth < 300.dp)
 9            Text("Pantalla pequeña")
10        else Text("Pantalla grande")
11    }
12}

Se puede usar maxWidth, minWidth, maxHeight y minHeight para realizar evaluaciones lógicas en tiempo de composición.

Diferencias

Enfoque Uso
IntrinsicSize.Min / Max Ajusta el tamaño según el contenido.
BoxWithConstraints Adapta la UI al tamaño del contenedor (responsive).

Para resumir, las medidas intrínsecas permiten que un composable se adapte a su contenido, debiéndose utilizar con cuidado por razones de rendimiento. BoxWithConstraints es preferible para layouts adaptativos o responsive.


Ejemplos prácticos

Ejemplo práctico 3 BoxWithConstraints adaptativo

2.5. Lienzo y gráficos

Ahora se verá cómo utilizar Canvas en Jetpack Compose para dibujar elementos gráficos personalizados como líneas, formas y colores, y comprender cómo se integra esta fase con el ciclo de composición.

2.5.1. ¿Qué es Canvas?

Canvas es un composable especial que permite dibujar directamente sobre la pantalla utilizando para ello primitivas gráficas:

  • Líneas
  • Rectángulos
  • Círculos
  • Texto
  • Imágenes y gradientes

Esta API es similar a la de Canvas tradicional de Android (vistas), pero adaptada a Compose y Kotlin.

2.5.2. Uso básico de Canvas

El siguiente código dibuja un rectángulo azul de 100x100 en la posición (20,20).

 1import androidx.compose.foundation.Canvas
 2...
 3
 4@Composable
 5fun EjemploCanvas() {
 6    Canvas(modifier = Modifier.size(200.dp)) {
 7        drawRect(
 8            color = Color.Blue,
 9            topLeft = Offset(20f, 20f),
10            size = Size(100f, 100f)
11        )
12    }
13}

Imagen punto 2.5 Imagen punto 2.5

2.5.3. Primitivas

Método Descripción
drawRect() Dibuja un rectángulo con tamaño y posición.
drawCircle() Dibuja un círculo dado centro y radio.
drawLine() Dibuja una línea entre dos puntos.
drawPath() Dibuja una figura compleja con líneas y curvas.
drawText() Dibuja texto (requiere herramientas adicionales).

Ejemplo 2.5. Canvas sencillo

 1@Composable
 2fun EjemploCanvasSencillo() {
 3    Canvas(modifier = Modifier.size(200.dp)) {
 4        // Fondo gris claro
 5        drawRect(Color.LightGray, size = size)
 6
 7        // Círculo rojo en el centro
 8        drawCircle(
 9            color = Color.Red,
10            radius = 50f,
11            center = center
12        )
13
14        // Línea diagonal azul
15        drawLine(
16            color = Color.Blue,
17            start = Offset(0f, 0f),
18            end = Offset(size.width, size.height),
19            strokeWidth = 4f
20        )
21    }
22}
23```~
24
25Si quieres que en lugar de dibujar un cuadro de 200 x 200, ocupe toda la pantalla, sustituye `Modifier.size(200.dp)` por `Modifier.fillMaxSize()`,
26
27Visualización del ejemplo:
28
29```kotlin { lineNos="inline" title="Kotlin" }
30@Preview(showBackground = true)
31@Composable
32fun VistaPreviaCanvas() {
33    EjemploCanvasSencillo()
34}

Como puedes observar, el uso de Canvases puramente decorativo, para dibujos personalizados, gráficos o animaciones. Debes tener en cuenta que el orden de las operaciones es importante, cada llamada a un draw... se superpone a las anteriores. Puedes combinar Canvas con otros layouts y modificadores (padding, clip, etc.).


Ejemplos prácticos

2.6. Capas de dibujo y graphicsLayer

El modificador graphicsLayer en Jetpack Compose permite transformaciones a nivel de capa modificando propiedades como rotación, escala, alfa, traslación, etc.

2.6.1. ¿Qué es graphicsLayer?

  • graphicsLayer crea una capa de composición separada para el elemento, permitiendo así transformaciones y efectos visuales que no afectan a otros nodos.
  • Es ideal para animaciones, efectos complejos o para optimizar redibujados cuando se aplican múltiples transformaciones.

Propiedades comunes

Propiedad Descripción
alpha Opacidad (0.0 = transparente, 1.0 = opaco)
rotationX, rotationY, rotationZ Rotación en grados
scaleX, scaleY Escalado
translationX, translationY Traslación en píxeles
shadowElevation Sombra en píxeles (solo para elevación Z)
cameraDistance Distancia de cámara para efectos 3D

Ejemplo 2.6. Rotar y escalar un Box

 1@Composable
 2fun EjemploGraphicsLayer() {
 3    Box(
 4        modifier = Modifier
 5            .size(200.dp)
 6            .graphicsLayer(
 7                rotationZ = 45f,       // Rotación 45 grados
 8                scaleX = 1.5f,         // Escala 1.5x en X
 9                scaleY = 1.5f,         // Escala 1.5x en Y
10                alpha = 0.8f,          // Opacidad 80%
11                shadowElevation = 16f  // Sombra
12            )
13            .background(Color.Red),
14        contentAlignment = Alignment.Center
15    ) {
16        Text("Transformado", color = Color.White, fontSize = 16.sp)
17    }
18}
19
20@Preview(showBackground = true)
21@Composable
22fun VistaPreviaGraphicsLayer() {
23    EjemploGraphicsLayer()
24}

2.6.2. Animaciones con graphicsLayer

Se puede utilizar animateFloatAsState para animar propiedades de la capa, como la rotación.

 1@Composable
 2fun EjemploAnimacionGraphicsLayer() {
 3    var rotar by remember { mutableStateOf(false) }
 4    val rotacionAnimada by animateFloatAsState(targetValue = if (rotar) 360f else 0f)
 5
 6    Box(
 7        modifier = Modifier
 8            .size(200.dp)
 9            .graphicsLayer(
10                rotationZ = rotacionAnimada,
11                scaleX = 1.2f,
12                scaleY = 1.2f
13            )
14            .background(Color.Blue)
15            .clickable { rotar = !rotar },
16        contentAlignment = Alignment.Center
17    ) {
18        Text("Haz clic", color = Color.White)
19    }
20}

Ejemplos prácticos

2.7. Estructura visual con Scaffold y TopAppBar

El componente Scaffold se utiliza para estructurar pantallas completas en Compose, organizando elementos como TopAppBar, BottomAppBar, FloatingActionButton y el contenido principal de la UI mediante secciones.

2.7.1. ¿Qué es Scaffold?

Scaffold es un contenedor base que permite estructurar la pantalla mediante secciones predefinidas.

Permite organizar los elementos comunes de una app:

  • Barra superior (topBar)
  • Barra inferior (bottomBar)
  • Botón flotante (floatingActionButton)
  • Snackbar (snackbarHost)
  • Contenido principal (content)
1Scaffold(
2    topBar = { TopAppBar(...) },
3    bottomBar = { BottomAppBar(...) },
4    floatingActionButton = { FloatingActionButton { ... } },
5    content = { paddingValues ->
6        // Contenido principal con padding
7    }
8)

2.7.2. Uso básico de Scaffold con TopAppBar

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun EjemploScaffoldBasico() {
 4    Scaffold(
 5        topBar = {
 6            TopAppBar(
 7                title = { Text("Mi App") },
 8                colors = topAppBarColors(
 9                    containerColor = MaterialTheme.colorScheme.primaryContainer,
10                    titleContentColor = MaterialTheme.colorScheme.primary,
11                )
12            )
13        },
14        content = { padding ->
15            Box(
16                modifier = Modifier
17                    .fillMaxSize()
18                    .padding(padding),
19                contentAlignment = Alignment.Center
20            ) {
21                Text("Contenido principal")
22            }
23        }
24    )
25}

2.7.3. Añadiendo BottomAppBar y FloatingActionButton

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun EjemploScaffoldBasico() {
 4    Scaffold(
 5        topBar = {
 6            TopAppBar(
 7                title = { Text("Mi App") },
 8                colors = topAppBarColors(
 9                    containerColor = MaterialTheme.colorScheme.primaryContainer,
10                    titleContentColor = MaterialTheme.colorScheme.primary,
11                )
12            )
13        },
14        bottomBar = {
15            BottomAppBar {
16                Text("Barra inferior", modifier = Modifier.padding(16.dp))
17            }
18        },
19        floatingActionButton = {
20            FloatingActionButton(onClick = { /* Acción */ }) {
21                Icon(Icons.Default.Add, contentDescription = "Añadir")
22            }
23        },
24        floatingActionButtonPosition = FabPosition.End,
25    ) { innerPadding ->
26        Column(
27            modifier = Modifier.padding(innerPadding),
28            verticalArrangement = Arrangement.spacedBy(16.dp),
29        ) {
30            Text(
31                modifier = Modifier.padding(8.dp),
32                text = """
33                    This is an example of a scaffold. It uses the Scaffold composable's parameters to create a screen with a simple top app bar, bottom app bar, and floating action button.
34
35                    It also contains some basic inner content, such as this text.
36
37                    You have pressed the floating action button 3 times.
38                """.trimIndent(),
39            )
40        }
41    }
42}

2.7.4. Organización de pantallas por secciones

  • Scaffold divide cada parte de la UI en una sección, facilitando la legibilidad y el mantenimiento del código.
  • Permite diseñar pantallas complejas con contenido adaptado a dispositivos (tablets, móviles).
Sección Uso
topBar Barra superior (menú, título, acciones)
bottomBar Barra inferior (navegación, información)
floatingActionButton Botón flotante para acción principal
snackbarHost Mostrar notificaciones flotantes
content Contenido principal de la pantalla

Ejemplos prácticos

Ejemplo práctico 7 Uso básico de Scaffold

2.8. Elementos básicos de la UI

Este punto tratará los componentes básicos de Jetpack Compose, haciendo una correlación con alugnos de sus equivalente en vistas (XML) de TextView, EditText, CheckBox, RadioButton, ImageView, controlando su estado, estilo e interacción.

2.8.1. Texto o etiqueta: Text (equivalente a TextView)

1@Composable
2fun EjemploText() {
3    Text(
4        text = "Hola, Compose!",
5        style = MaterialTheme.typography.titleLarge,
6        color = Color(0xFF1E88E5),
7        modifier = Modifier.fillMaxWidth().padding(8.dp)
8    )
9}

Se configura el estilo y color utilizando MaterialTheme y Color.

2.8.2. Cuadro de texto: TextField (equivalente a EditText)

 1@Composable
 2fun EjemploTextField() {
 3    var nombre by remember { mutableStateOf("") }
 4
 5    Column(modifier = Modifier.wrapContentHeight().padding(16.dp)) {
 6        TextField(
 7            modifier = Modifier.fillMaxWidth(),
 8            value = nombre,
 9            onValueChange = { nombre = it },
10            label = { Text("Nombre") },
11            placeholder = { Text("Escribe tu nombre") } // Equivalente a hint en XML
12        )
13        Spacer(Modifier.height(16.dp))
14        Text("Hola, $nombre")
15    }
16}

El estado está gestionado con remember y mutableStateOf, se usa de label para asignarle una etiqueta al campo y placeholder para indicar las instrucciones, sería el equivalente a hint para las vistas XML. El input es reactivo al estado, apareciendo el texto en un Text separado por un Spacer.

Puedes cambiar el estilo del cuadro de texto utilizando OutlinedTextField en lugar de TextField.

2.8.3. Casilla: Checkbox

 1@Composable
 2fun EjemploCheckbox() {
 3    var marcado by remember { mutableStateOf(false) }
 4
 5    Row(
 6        verticalAlignment = Alignment.CenterVertically,
 7        modifier = Modifier.fillMaxWidth().padding(16.dp)
 8    ) {
 9        Checkbox(
10            checked = marcado,
11            onCheckedChange = { marcado = it }
12        )
13        Spacer(Modifier.width(8.dp))
14        Text(text = if (marcado) "Marcado" else "No marcado")
15    }
16}

Actualiza el estado al marcar la casilla y se puede añadir texto descriptivo junto con un Text.

2.8.4. Botón de opción: RadioButton

 1@Composable
 2fun EjemploRadioButton() {
 3    val opciones = listOf("Opción A", "Opción B", "Opción C")
 4    var seleccion by remember { mutableStateOf(opciones[0]) }
 5
 6    Column(Modifier
 7        .selectableGroup()
 8        .padding(16.dp)) {
 9        opciones.forEach { texto ->
10            Row(
11                verticalAlignment = Alignment.CenterVertically,
12                modifier = Modifier
13                    .fillMaxWidth()
14                    .selectable(
15                        selected = (texto == seleccion),
16                        onClick = { seleccion = texto },
17                        role = Role.RadioButton
18                    )
19                    .padding(8.dp)
20            ) {
21                RadioButton(
22                    selected = (texto == seleccion),
23                    onClick = null
24                )
25                Spacer(Modifier.width(8.dp))
26                Text(text = texto)
27            }
28        }
29        Text("Seleccionado: $seleccion", modifier = Modifier.padding(top = 8.dp))
30    }
31}

Se hace uso de selectableGroup para accesibilidad y agrupación y muestra la opción seleccionada.

2.8.5. Interruptor: Switch

 1@Composable
 2fun EjemploSwitch() {
 3    var activado by remember { mutableStateOf(false) }
 4
 5    Row(
 6        verticalAlignment = Alignment.CenterVertically,
 7        modifier = Modifier.fillMaxWidth().padding(16.dp)
 8    ) {
 9        Switch(
10            checked = activado,
11            onCheckedChange = { activado = it }
12        )
13        Spacer(Modifier.width(8.dp))
14        Text(text = if (activado) "Activado" else "Desactivado")
15    }
16}

Alterna valores booleanos de forma visual y accesible.

2.8.6. Imagen: Image (equivalente a ImageView)

 1@Composable
 2fun EjemploImage() {
 3    Image(
 4        painter = painterResource(id = R.drawable.ic_launcher_foreground),
 5        contentDescription = "Ejemplo de imagen",
 6        modifier = Modifier
 7            .size(150.dp)
 8            .clip(RoundedCornerShape(8.dp))
 9            .border(2.dp, Color.Gray, RoundedCornerShape(8.dp)),
10        contentScale = ContentScale.Crop
11    )
12}

Se pueden cargar imágenes desde recursos (R), además, se puede recortar (clip), redondear esquinas y aplicar borde.

2.8.7. Cargar imágenes desde URL con librerías externas

Para estos casos es necesario el uso de Internet, por lo que la aplicación deberá tener declarado dicho permiso en el Manifest. Además se añade un segundo permiso para permitir a estas librerías hacer comprobaciones del estado de la red.

1<uses-permission android:name="android.permission.INTERNET" />
2<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

GlideImage

GlideImage pertenece a la librería Glide, actualmente la integración oficial con Jetpack Compose está en fase beta experimental, por lo que su uso puede ser algo impredecible.

En primer lugar se deberá añadir la siguiente dependencia al Gradle y sincronizar.

1// Glide for image loading
2implementation("com.github.bumptech.glide:compose:1.0.0-alpha.1")

Para aplicar Glide, añade el siguiente método Composable:

 1@OptIn(ExperimentalGlideComposeApi::class)
 2@Composable
 3fun ImagenConGlide(imageUrl: String) {
 4    GlideImage(
 5        model = imageUrl,
 6        contentDescription = "Imagen con Glide",
 7        modifier = Modifier
 8            .padding(16.dp)
 9            .size(200.dp)
10            .clip(RoundedCornerShape(12.dp)),
11        contentScale = ContentScale.Crop
12    )
13}

Como puedes observar, se carga la imagen en model, se añade una descripción para la imagen y se modifica el contenedor de la imagen con el padding, el tamaño y se recorta (clip) redondeando las esquinas. Por último se escala la imagen.

Para utilizar este método bastará con pasar la URL por parámetro al llamarlo.

1ImagenConGlide(imageUrl = "https://via.placeholder.com/300")

AsyncImage

Otra opción para cargar imágenes desde URL en Jetpack Compose es usando la librería Coil y el Composable AsyncImage. En primer lugar se deberá añadir las siguientes dependencias al Gradle y sincronizar.

1// Coil for image loading
2implementation("io.coil-kt.coil3:coil-compose:3.2.0")
3implementation("io.coil-kt.coil3:coil-network-okhttp:3.2.0")

El método Composable para cargar una imagen desde una URL puede ser como se muestra a continuación:

 1@Composable
 2fun ImagenConAsyncImage(imageUrl: String) {
 3    AsyncImage(
 4        model = ImageRequest.Builder(LocalContext.current)
 5            .data(imageUrl)
 6            .crossfade(true)
 7            .build(),
 8        contentDescription = "Imagen con Coil",
 9        placeholder = painterResource(R.drawable.loading),
10        error = painterResource(R.drawable.error),
11        contentScale = ContentScale.Crop,
12        modifier = Modifier
13            .padding(16.dp)
14            .size(200.dp)
15            .clip(RoundedCornerShape(12.dp))
16            .border(2.dp, Color.Gray, RoundedCornerShape(12.dp))
17    )
18}

Esta es una versión más compleja, ya que se utiliza ImageRequest para habilitar crossfade, utilizando LocalContext.current para construirlo. Además, se especifica placeholder y error. A diferencia de Glide, se puede añadir un borde a la imagen.

Para utilizar este método bastará con pasar la URL por parámetro al llamarlo.

1ImagenConAsyncImage(imageUrl = "https://via.placeholder.com/300")

Conclusiones

  • Coil actualmente tiene una integración estable, uso sencillo, tamaño reducido y una API moderna para Compose.
  • Glide tiene mejor rendimiento para GIFs o caching, y también tiene integración con Compose, pero hay que tener en cuenta que está en beta y puede requerir cambios futuros. Es más pesado que Coil, pero tiene mejor rendimiento en listas.

2.8.8. Desplazamiento del contenido: ScrollView

El desplazamiento de contenido en Compose puede aplicarse directamente sobre Column y Row con los modificadores verticalScroll y horizontalScroll, que equivalen al clásico ScrollView en XML.

Un scroll de este tipo, no es lazy, como se verá más adelante, carga todo el contenido.

 1Column(
 2    modifier = Modifier
 3        .fillMaxSize()
 4        .verticalScroll(rememberScrollState())
 5        .padding(innerPadding)
 6) {
 7    ...
 8}
 9```~
10
11```kotlin { lineNos="inline" title="Kotlin" }
12Row(
13    modifier = Modifier
14        .fillMaxWidth()
15        .horizontalScroll(rememberScrollState())
16        .padding(16.dp)
17) {
18    ...
19}

El uso recomendado de este elemento es para vistas fijas, tipo formularios o de contenido corto pero que no cabe en la parte visible.

2.9. Listas con LazyColumn y LazyRow

Para crear listas eficientes y escalables en Jetpack Compose se utiliza LazyColumn y LazyRow, estos vienen a ser los equivalentes modernos a RecyclerView.

2.9.1. Diferencias clave respecto a RecyclerView

Si conocéis RecyclerView para vistas XML, la siguiente tabla os aclarará algunos conceptos.

Apartado RecyclerView LazyColumn / LazyRow
Arquitectura Basado en ViewHolder + Adapter Declarativo, sin adapter
Layout XML + inflado manual Composable
Ciclo de vida Fragment / Activity Composable puro
Reutilización Sí, con pool de vistas Sí, de forma implícita y lazy
Configuración Compleja (LayoutManager, Adapter) Muy simple (items o itemsIndexed)
Escalabilidad Muy buena Excelente para listas dinámicas

2.9.2. Estructura básica con items

Por ejemplo, se crea un companion que se pasará en la llamada al método desde onCreate() que contrendrá la lista de datos.

1class MainActivity : ComponentActivity() {
2    companion object {
3        val itemsList = List(100) { "Item #$it" }
4    }
5    ...
6}

El Composable que permite crear una lista básica utilizando items puede ser como se muestra:

 1@Composable
 2fun ExampleLazyColum(itemsList: List<String>) {
 3    LazyColumn(
 4        modifier = Modifier.fillMaxSize(),
 5        verticalArrangement = Arrangement.spacedBy(8.dp) // Espacio entre los elementos.
 6    ) {
 7        items(itemsList) { item ->
 8            Text(text = item)
 9        }
10    }
11}

Se suele utilizar para listas de elementos de tipo simple o complejo, y puede usarse con o sin key para mejorar el rendimiento.

2.9.3. Utilizando itemsIndexed

Añade la siguiente lista al companion:

1val dias = listOf("Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo")

El Composable utilizando itemsIndexed podría ser como se muestra:

1fun ExampleLazyColum2(dias: List<String>) {
2    LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
3        itemsIndexed(dias) { index, dia ->
4            Text("[$index] Día: $dia")
5        }
6    }
7}

Esta versión permite acceder al índice y al valor en cada iteración. Perfecto para numeraciones, saltos condicionales o detección del último elemento.

2.9.4. Delegación de eventos y selección de ítems

Para este ejemplo, se añade al companion una nueva lista.

 1val frutas = listOf(
 2    "Manzana",
 3    "Pera",
 4    "Naranja",
 5    "Plátano",
 6    "Fresa",
 7    "Kiwi",
 8    "Mango",
 9    "Piña",
10    "Uva",
11    "Sandía"
12)

Y se crea el siguiente método Composable.

 1@Composable
 2fun ListaConSeleccion(frutas: List<String>) {
 3    var seleccionada by remember { mutableStateOf<String?>(null) }
 4
 5    LazyColumn {
 6        items(frutas) { fruta ->
 7            Text(
 8                text = fruta,
 9                modifier = Modifier
10                    .fillMaxWidth()
11                    .clickable { // Acción al hacer clic.
12                        seleccionada = fruta
13                        Log.d("Seleccionada", "Fruta seleccionada: $seleccionada")
14                    }
15                    .background(if (seleccionada == fruta) Color.LightGray else Color.Transparent)
16                    .padding(16.dp)
17            )
18        }
19    }
20}

Se delega el evento de clic utilizando Modifier.clickable, y se gestiona el estado de selección en la vista, sin necesidad de adapter, simplemente controlando la selección en la propiedad background.

2.9.5. Optimización de listas grandes

Si la lista a utilizar tiene elementos con IDs únicos se utilizará items(..., key = { it.id }):

1items(listaUsuarios, key = { it.id }) { usuario -> ... }
  • Utiliza remember y derivedStateOf para evitar recomposiciones innecesarias.
  • Controla la composición de elementos pesados o animados.

2.9.6. Ejemplo completo con LazyColumn

Crea la siguiente lista en el companion de la clase:

 1val usuarios = listOf(
 2    "Ana",
 3    "Luis",
 4    "Carlos",
 5    "Lucía",
 6    "María",
 7    "Javier",
 8    "Patricia",
 9    "Sofía",
10    "Pedro",
11    "Laura",
12    "David",
13    "Isabel"
14)

Siguiendo los ejemplos anteriores, se creará el siguiente Composable.

 1@Composable
 2fun ListaUsuarios(usuarios: List<String>) {
 3    val context = LocalContext.current
 4
 5    LazyColumn(
 6        contentPadding = PaddingValues(12.dp),
 7        verticalArrangement = Arrangement.spacedBy(8.dp)
 8    ) {
 9        items(usuarios, key = { it }) { nombre ->
10            Card(
11                modifier = Modifier
12                    .fillMaxWidth()
13                    .clickable {
14                        Toast.makeText(
15                            context,
16                            "Usuario seleccionado: $nombre",
17                            Toast.LENGTH_SHORT
18                        ).show()
19                    }
20                    .padding(4.dp)
21            ) {
22                Row {
23                    Image(
24                        painter = painterResource(id = R.drawable.ic_launcher_foreground),
25                        contentDescription = "Imagen de usuario",
26                        modifier = Modifier
27                            .wrapContentSize()
28                            .size(50.dp)
29                    )
30                    Text(
31                        text = nombre,
32                        modifier = Modifier.fillMaxSize().padding(16.dp),
33                        style = MaterialTheme.typography.bodyLarge
34                    )
35                }
36            }
37        }
38    }
39}
  • En este caso se ha utilizado items para crear una lista de tarjetas con los nombres de los usuarios.
  • Cada tarjeta es clicable y muestra un Toast al seleccionarla, recuerda que hay que recuperar el contexto actual para poder mostrarlo.
  • El uso de key = { it } asegura que cada elemento tenga una clave única, si por ejemplo, Ana estuviese duplicada, se produciría el siguiente error:
java.lang.IllegalArgumentException: Key "Ana" was already used. If you are using LazyColumn/Row please make sure you provide a unique key for each item.

2.9.7. LazyRow para scroll horizontal

1LazyRow {
2    items(listOf("🍎", "🍊", "🍌", "🍇", "🍏", "🍋", "🥑", "🍉", "🍓")) { fruta ->
3        Text(fruta, fontSize = 48.sp, modifier = Modifier.padding(8.dp))
4    }
5}

2.10. Notificaciones visuales: Snackbar y Toast

En este punto se verá cómo mostrar notificaciones visuales al usuario utilizando Snackbar (propio de Compose y Material Design) y Toast (clásico de Android), viendo su uso e implementación de forma correcta en Compose.

2.10.1. Diferencias clave entre Snackbar y Toast

Características Snackbar Toast
Visibilidad Dentro de la UI (Scaffold) Flotante, fuera del árbol Compose
Interactivo ✅ Soporta acciones (con botón) ❌ No permite interacción
Estilo Material ✅ Integra con temas de Compose Material ❌ Estilo clásico de Android
Control desde Compose ✅ Totalmente declarativo ⚠️ Necesita Context
Uso recomendado Mensajes importantes o con acción Mensajes breves e informativos

2.10.2. Mostrar un Snackbar con acción

 1@Composable
 2fun SnackbarConAccionEjemplo() {
 3    val snackbarHostState = remember { SnackbarHostState() }
 4    val scope = rememberCoroutineScope()
 5
 6    Scaffold(
 7        snackbarHost = { SnackbarHost(snackbarHostState) },
 8        floatingActionButton = {
 9            FloatingActionButton(onClick = {
10                scope.launch {
11                    val resultado = snackbarHostState.showSnackbar(
12                        message = "Se ha borrado un elemento",
13                        actionLabel = "Deshacer",
14                        duration = SnackbarDuration.Short
15                    )
16                    // Manejo del resultado del Snackbar.
17                    if (resultado == SnackbarResult.ActionPerformed) {
18                        Log.d("SNACKBAR", "El usuario pulsó Deshacer")
19                    }
20                }
21            }) {
22                Icon(Icons.Default.Delete, contentDescription = "Eliminar")
23            }
24        }
25    ) { innerPadding ->
26        Box(
27            modifier = Modifier
28                .padding(innerPadding)
29                .fillMaxSize(),
30            contentAlignment = Alignment.Center
31        ) {
32            Text("Haz clic en el FAB para mostrar el Snackbar.")
33        }
34    }
35}

SnackbarHostState mantiene el estado del Snackbar, con el método showSnackbar() se lanza una corutina para mostrarlo en pantalla. A continuación, se comprueba (if) si el usuario pulsa la acción con SnackbarResult y la constante ActionPerformed, para comprobar que el usuario no la puede utilizarse Dismissed.

1if (resultado == SnackbarResult.Dismissed) {
2    Log.d("SNACKBAR", "El usuario descartó el Snackbar")
3}

2.10.3. Mostrar un Toast en Compose

 1@Composable
 2fun ToastEjemplo() {
 3    val context = LocalContext.current
 4
 5    Button(onClick = {
 6        Toast.makeText(context, "Mensaje desde Toast", Toast.LENGTH_SHORT).show()
 7    }) {
 8        Text("Mostrar Toast")
 9    }
10}

Para este case es neceario usar LocalContext.current para acceder a un Context que pueda ser utilizado. El Toast se muestra como en el sistema clásico de vistas de Android y no depende de Scaffold.

Ejemplo 2.10. Combinando ambos

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun EjemploSnackbarYToast() {
 4    val context = LocalContext.current
 5    val snackbarHostState = remember { SnackbarHostState() }
 6    val scope = rememberCoroutineScope()
 7
 8    Scaffold(
 9        snackbarHost = { SnackbarHost(snackbarHostState) },
10        topBar = {
11            TopAppBar(title = { Text("Notificaciones") })
12        },
13        content = { padding ->
14            Column(
15                modifier = Modifier
16                    .fillMaxSize()
17                    .padding(padding)
18                    .padding(16.dp),
19                verticalArrangement = Arrangement.spacedBy(16.dp) // Espacio entre los elementos.
20            ) {
21                Button(
22                    modifier = Modifier.fillMaxWidth(),
23                    onClick = {
24                        scope.launch {
25                            snackbarHostState.showSnackbar("Esto es un Snackbar")
26                        }
27                    }) {
28                    Text("Mostrar Snackbar")
29                }
30
31                Button(
32                    modifier = Modifier.fillMaxWidth(),
33                    onClick = {
34                        Toast.makeText(context, "Esto es un Toast", Toast.LENGTH_SHORT).show()
35                    }) {
36                    Text("Mostrar Toast")
37                }
38            }
39        }
40    )
41}

Recomendaciones

  • Utiliza Snackbar dentro de Scaffold.
  • Usa Toast para mostrar información rápida.
  • No muestres múltiples Toast seguidos (no cancelables por el usuario).
  • Se recomienda observar el resultado del Snackbar si se utilizan acciones.

2.11. Menús en Jetpack Compose

Ahora se verá como implementar diferentes tipos de menús en Jetpack Compose, desde los más simples hasta los personalizados o en cascada, tratando de comprender cuándo usar cada uno.

2.11.1. Menú básico: DropdownMenu

 1@Composable
 2fun MenuBasico() {
 3    var expanded by remember { mutableStateOf(false) }
 4    val context = LocalContext.current // Contexto para mostrar Toast.
 5
 6    Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
 7        IconButton(onClick = { expanded = !expanded }) {
 8            Icon(Icons.Default.MoreVert, contentDescription = "Más opciones") // Icono 3 puntos.
 9        }
10        DropdownMenu(
11            expanded = expanded,
12            onDismissRequest = { expanded = false }
13        ) {
14            DropdownMenuItem(text = { Text("Opción 1") }, onClick = { /*...*/ })
15            DropdownMenuItem(text = { Text("Opción 2") }, onClick = {
16                Toast.makeText(context, "Opción 2 seleccionada", Toast.LENGTH_SHORT).show()
17                expanded = false // Cierra el menú al seleccionar una opción.
18            })
19        }
20    }
21}

Imagen punto 2.11.1. Imagen punto 2.11.1.

Este menú está formado por DropdownMenu, el menú en sí, y DropdownMenuItem, las opciones del menú. El popup aparecerá anclado al elemento que dispara el menú, en este caso el IconButton.

2.11.2. Menú en overflow de TopAppBar

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun TopAppBarConMenu() {
 4    var showMenu by remember { mutableStateOf(false) }
 5
 6    Scaffold(
 7        topBar = {
 8            TopAppBar(
 9                title = { Text("AppBar con Menú") },
10                actions = {
11                    IconButton(onClick = { /* acción principal */ }) {
12                        Icon(Icons.Default.Share, contentDescription = "Compartir")
13                    }
14                    IconButton(onClick = { showMenu = !showMenu }) {
15                        Icon(Icons.Default.MoreVert, contentDescription = "Más")
16                    }
17                    DropdownMenu(
18                        expanded = showMenu,
19                        onDismissRequest = { showMenu = false }
20                    ) {
21                        DropdownMenuItem(text = { Text("Guardar") }, onClick = { /* ... */ })
22                        DropdownMenuItem(text = { Text("Eliminar") }, onClick = { /* ... */ })
23                    }
24                }
25            )
26        }
27    ) { innerPadding ->
28        /* ... */
29    }
30}

Este patrón es ideal para pantallas con un menú de opciones en la barra superior. Si observas el código, es el mismo que el utilizado para el menú básico.

Ahora bien, lo ideal sería reutilizar código, algo bastante habitual en los menús, creando un método que contenga la estructura del menú, dejando el Scaffold más limpio. En primer lugar se crearía el Composable que crea el menú.

 1@Composable
 2fun AppBarOverflowMenu(onSave: () -> Unit) { // Callback para guardar.
 3    var showMenu by remember { mutableStateOf(false) }
 4
 5    IconButton(onClick = { /* acción principal */ }) {
 6        Icon(Icons.Default.Share, contentDescription = "Compartir")
 7    }
 8    IconButton(onClick = { showMenu = !showMenu }) {
 9        Icon(Icons.Default.MoreVert, contentDescription = "Más opciones") // Icono 3 puntos.
10    }
11    DropdownMenu(
12        expanded = showMenu,
13        onDismissRequest = { showMenu = false }
14    ) {
15        DropdownMenuItem(text = { Text("Guardar") }, onClick = { /* ... */ })
16        DropdownMenuItem(text = { Text("Eliminar") }, onClick = {
17            showMenu = false // Cierra el menú al seleccionar una opción.
18            onSave() // Llama al callback para guardar.
19        })
20    }
21}

En este código se introduce la delegación del callback para hacerlo más reutilizable. El Scaffold quedaría más limpio, extrayendo el callback, además, se lleva fuera, dejándolo más sencillo.

 1setContent {
 2    Example_t02_11Theme {
 3        Scaffold(
 4            topBar = {
 5                TopAppBar(
 6                    title = { Text("Menús") },
 7                    actions = {
 8                        AppBarOverflowMenu(onSave = showToast)
 9                    }
10                )
11            }
12        )
13        { innerPadding ->
14            /* ... */
15        }
16    }
17}

El objeto showToast quedaría como una propiedad de la clase MainActivity.

1class MainActivity : ComponentActivity() {
2    private val showToast: () -> Unit = {
3        Toast.makeText(this, "Guardado correctamente", Toast.LENGTH_SHORT).show()
4    }
5    ...
6}

Ventajas de este sistema o esquema de uso:

  • Reusabilidad: El menú está encapsulado y puede utilizarse en varias pantallas.
  • Claridad: Separa la lógica del menú, facilitando su lectura y pruebas.
  • Flexibilidad: Se pueden pasar callbacks (onSave) para definir acciones desde cada pantalla.

2.11.3. Menú largo y scrollable

 1@Composable
 2fun MenuLargo() {
 3    var expanded by remember { mutableStateOf(false) }
 4    val opciones = List(50) { "Opción ${it + 1}" }
 5    val context = LocalContext.current // Contexto para mostrar Toast.
 6
 7    Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
 8        // Botón para mostrar el menú desplegable.
 9        IconButton(onClick = { expanded = !expanded }) {
10            Icon(Icons.Default.MoreVert, contentDescription = "Más opciones")
11        }
12        DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
13            opciones.forEach { opc ->
14                DropdownMenuItem(text = { Text(opc) }, onClick = {
15                    Toast.makeText(context, "$opc seleccionada", Toast.LENGTH_SHORT).show()
16                    expanded = false // Cierra el menú al seleccionar una opción.
17                })
18            }
19        }
20    }
21}

Si el menú sobrepasa el espacio disponible, automáticamente se activa el scroll.

2.11.4. Menú con iconos y divisores

 1@Composable
 2fun MenuConDetalles() {
 3    var expanded by remember { mutableStateOf(false) }
 4
 5    Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
 6        IconButton(onClick = { expanded = !expanded }) {
 7            Icon(Icons.Default.MoreVert, contentDescription = "Menú")
 8        }
 9        DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
10            DropdownMenuItem(text = { Text("Perfil") },
11                leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
12                onClick = { /*...*/ })
13            HorizontalDivider(
14                modifier = Modifier.padding(horizontal = 8.dp), // Espacio horizontal (opcional).
15                thickness = 1.dp, // Espacio entre los elementos del menú (opcional).
16                color = Color.Red // Color del divisor (opcional).
17            )
18            DropdownMenuItem(text = { Text("Configuración") },
19                leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
20                onClick = { /*...*/ })
21        }
22    }
23}

Esta puede ser una buena opción para menús organizados, con secciones y elementos visuales.

2.11.5. Menú expuesto (Spinner): ExposedDropdownMenuBox

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun MenuSpinner() {
 4    val opciones = listOf("Rojo", "Verde", "Azul", "Amarillo")
 5    var expanded by remember { mutableStateOf(false) }
 6    var seleccion by remember { mutableStateOf(opciones[0]) }
 7
 8    ExposedDropdownMenuBox(
 9        expanded = expanded,
10        onExpandedChange = { expanded = !expanded },
11        modifier = Modifier.fillMaxWidth()
12    ) {
13        TextField(
14            value = seleccion,
15            onValueChange = {},
16            readOnly = true,
17            label = { Text("Color favorito") },
18            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
19            modifier = Modifier
20                .fillMaxWidth()
21                .menuAnchor(MenuAnchorType.PrimaryEditable, true)
22        )
23        ExposedDropdownMenu(
24            expanded = expanded,
25            onDismissRequest = { expanded = false }
26        ) {
27            opciones.forEach { color ->
28                DropdownMenuItem(
29                    text = { Text(color) },
30                    onClick = {
31                        seleccion = color
32                        expanded = false
33                    }
34                )
35            }
36        }
37    }
38}

Permite mostrar el elemento seleccionado en un TextField o OutlinedTextField, puede venir bien para selección de una dimensión fija.

2.11.6. Menú expuesto editable (autocomplete)

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun SpinnerAutocomplete() {
 4    val opciones = listOf("Alicante", "Barcelona", "Bilbao", "Madrid", "Valencia", "Zaragoza")
 5    var expanded by remember { mutableStateOf(false) }
 6    var text by remember { mutableStateOf("") }
 7    val filtradas = opciones.filter { it.contains(text, true) }
 8
 9    ExposedDropdownMenuBox(
10        expanded = expanded && filtradas.isNotEmpty(),
11        onExpandedChange = { expanded = !expanded }) {
12        OutlinedTextField(
13            value = text,
14            onValueChange = {
15                text = it
16                expanded = true
17            },
18            singleLine = true,
19            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
20            label = { Text("Ciudad") },
21            modifier = Modifier
22                .fillMaxWidth()
23                .menuAnchor(MenuAnchorType.PrimaryEditable, true) // Ancla el menú al campo de texto.
24        )
25        ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
26            filtradas.forEach { ciudad ->
27                DropdownMenuItem(text = { Text(ciudad) }, onClick = {
28                    text = ciudad
29                    expanded = false
30                })
31            }
32        }
33    }
34}

Este menú permite escribir para filtrar las opciones, muy útil para selecciones extensas. También se conoce como autocompletar.

2.11.7. Menú en cascada: CascadeDropdownMenu

Estos son un tipo de menú que actualmente no se encuentran en el core de Compose, pero pueden añadirse utilizando la librería cascade-compose.

1implementation("me.saket.cascade:cascade-compose:2.3.0")

Este tipo de menú puede ser una buena opción para añadirlo a la TopAppBar.

 1@Composable
 2fun CascadeMenu() {
 3    var expanded by remember { mutableStateOf(false) }
 4    val context = LocalContext.current
 5
 6    Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
 7        IconButton(onClick = { expanded = !expanded }) {
 8            Icon(Icons.Default.MoreVert, contentDescription = "Menú cascada")
 9        }
10
11        CascadeDropdownMenu(
12            expanded = expanded,
13            onDismissRequest = { expanded = false }
14        ) {
15            // Item principal con submenú
16            DropdownMenuItem(
17                text = { Text("Opciones Avanzadas ▸") },
18                children = {
19                    DropdownMenuItem(
20                        text = { Text("Sub‑opción 1") },
21                        onClick = {
22                            expanded = false
23                            Toast.makeText(context, "Sub‑opción 1", Toast.LENGTH_SHORT).show()
24                        }
25                    )
26                    DropdownMenuItem(
27                        text = { Text("Sub‑opción 2") },
28                        onClick = {
29                            expanded = false
30                            Toast.makeText(context, "Sub‑opción 2", Toast.LENGTH_SHORT).show()
31                        }
32                    )
33                }
34            )
35            // Otro item principal
36            DropdownMenuItem(
37                text = { Text("Acerca de") },
38                onClick = {
39                    expanded = false
40                    Toast.makeText(context, "Acerca de", Toast.LENGTH_SHORT).show()
41                }
42            )
43        }
44    }
45}

Las ventajas de utilizar esta librería, principalmente, es la simplificación a la hora de crear menús jerárquicos. Además, añade animaciones en la expansión y contracción de los menús y es compatible con Material Design y Compose.


Ejemplos prácticos

2.12. Cuadros de diálogo (AlertDialog)

Este punto pretendre introducir la creación y gestión de cuadros de diálogo en Jetpack Compose con AlertDialog (MaterialAlertDialog versión para vistas), y entender cómo personalizarlos para diferentes contextos: confirmación, alerta, formulario, etc.

Los diálogos son un componente modal que interrumpe el flujo de la interfaz para presentar información importante o solicitar una acción del usuario.

2.12.1. Cuadro de diálogo informativo

El siguiente código muestra un cuadro de diálogo muy simple, de uso informativo.

 1val openInfoDialog = remember { mutableStateOf(false) }
 2
 3/* ... */
 4
 5Button(onClick = { openInfoDialog.value = true }) {
 6    Text("Mostrar info")
 7}
 8
 9if (openInfoDialog.value) {
10    AlertDialog(
11        onDismissRequest = { openInfoDialog.value = false }, // Se cierra el diálogo al tocar fuera de él.
12        title = { Text("Información") },
13        text = { Text("Esta es una notificación informativa.") },
14        confirmButton = {
15            TextButton(onClick = { openInfoDialog.value = false }) {
16                Text(LocalContext.current.getString(android.R.string.ok))
17            }
18        }
19    )
20}

2.12.2. Cuadro de diálogo básico

El siguiente código muestra el método Composable para mostrar un cuadro de diálogo, además, se añaden los parámetros necesarios para delegar las acciones, un posible icono y el texto. Esto hará del componente más reutilizable.

 1@Composable
 2fun AlertDialogExample(
 3    onDismissRequest: () -> Unit,
 4    onConfirmation: () -> Unit,
 5    dialogTitle: String,
 6    dialogText: String,
 7    icon: ImageVector = Icons.Default.Warning,
 8) {
 9    AlertDialog(
10        icon = { Icon(icon, contentDescription = "Example Icon") },
11        title = { Text(text = dialogTitle) },
12        text = { Text(text = dialogText) },
13        properties = DialogProperties(
14            dismissOnBackPress = false, // Se evita el cierre al presionar atrás.
15            dismissOnClickOutside = false // Se evita el cierre al tocar fuera del diálogo.
16        ),
17        onDismissRequest = { onDismissRequest() }, // Se llama a la función de cierre del diálogo si no está bloqueado el cierre.
18        confirmButton = {
19            TextButton(onClick = { onConfirmation() }) {
20                Text(LocalContext.current.getString(android.R.string.ok))
21            }
22        },
23        dismissButton = {
24            TextButton(onClick = { onDismissRequest() }) {
25                Text(LocalContext.current.getString(android.R.string.cancel))
26            }
27        }
28    )
29}

El uso de este cuadro de diálogo será como se muestra a continuación.

 1@ExperimentalMaterial3Api
 2@Composable
 3fun PantallaPrincipal() {
 4    val openAlertDialog = remember { mutableStateOf(false) }
 5
 6    Scaffold(
 7        topBar = {
 8            TopAppBar(
 9                title = { Text("Cuadros de diálogo") },
10                colors = topAppBarColors(
11                    containerColor = MaterialTheme.colorScheme.primaryContainer,
12                    titleContentColor = MaterialTheme.colorScheme.primary,
13                )
14            )
15        },
16        modifier = Modifier.fillMaxSize()
17    ) { innerPadding ->
18        Column(
19            modifier = Modifier
20                .fillMaxSize()
21                .padding(innerPadding),
22            horizontalAlignment = Alignment.CenterHorizontally,
23            verticalArrangement = Arrangement.Center
24        ) {
25            Button(onClick = { openAlertDialog.value = true }) {
26                Text("Mostrar diálogo")
27            }
28
29            if (openAlertDialog.value) {
30                AlertDialogExample(
31                    onDismissRequest = { openAlertDialog.value = false },
32                    onConfirmation = {
33                        openAlertDialog.value = false
34                        println("Confirmación recibida")
35                    },
36                    dialogTitle = "Título del Diálogo",
37                    dialogText = "Este es un ejemplo de cuadro de diálogo en Jetpack Compose.",
38                    icon = Icons.Default.Info
39                )
40            }
41        }
42    }
43}

Observa como se dejan los callbacks preparados para que hagan las acciones según la situación.

2.12.3. Cuadro de diálogo personalizado

Ahora se verá como mostrar un cuadro de diálogo personalizado, añadiendo elementos más allá de texto.

 1@Composable
 2fun CustomDialog(onSave: (String) -> Unit) {
 3    val abierto: MutableState<Boolean> = remember { mutableStateOf(false) }
 4    var texto by remember { mutableStateOf("") }
 5
 6    Column(horizontalAlignment = Alignment.CenterHorizontally) {
 7        Button(onClick = { abierto.value = true }) {
 8            Text("Nuevo elemento")
 9        }
10
11        if (abierto.value) {
12            AlertDialog(
13                onDismissRequest = { abierto.value = false },
14                title = { Text("Crear elemento") },
15                text = {
16                    Column {
17                        OutlinedTextField(
18                            label = { Text("Introduce un nombre") },
19                            value = texto,
20                            onValueChange = { texto = it },
21                            singleLine = true
22                        )
23                    }
24                },
25                confirmButton = {
26                    TextButton(onClick = {
27                        if (texto.isNotBlank()) {
28                            onSave(texto)
29                            abierto.value = false
30                            texto = ""
31                        }
32                    }) {
33                        Text("Guardar")
34                    }
35                },
36                dismissButton = {
37                    TextButton(onClick = {
38                        abierto.value = false
39                        texto = ""
40                    }) {
41                        Text(LocalContext.current.getString(R.string.cancel))
42                    }
43                }
44            )
45        }
46    }
47}

Como puedes observar en este código, se ha optado por una estrategia diferente, el propio método se encarga de gestionar el estado de visibilidad del cuadro de diálogo y de mostrar el botón que desencadena la acción. Se propaga el dato recogido mediante el callback onSave.

1CustomDialog(
2    onSave = { newItem ->
3        println("Nuevo elemento guardado: $newItem")
4    }
5)

Pero… según la documentación oficial “si quieres crear un diálogo más complejo, quizás con formularios y varios botones, debes usar Dialog con contenido personalizado” aquí

Si se adapta al uso de Dialog, el método CustomDialog podría quedar así:

 1@Composable
 2fun CustomDialog(onSave: (String) -> Unit) {
 3    val abierto: MutableState<Boolean> = remember { mutableStateOf(false) }
 4    var texto by remember { mutableStateOf("") }
 5
 6    Column(horizontalAlignment = Alignment.CenterHorizontally) {
 7        Button(onClick = { abierto.value = true }) {
 8            Text("Nuevo elemento")
 9        }
10
11        if (abierto.value) {
12            Dialog(onDismissRequest = { abierto.value = false }) {
13                Column(
14                    modifier = Modifier.padding(16.dp),
15                    horizontalAlignment = Alignment.CenterHorizontally
16                ) {
17                    Card(
18                        modifier = Modifier.padding(16.dp),
19                        shape = RoundedCornerShape(16.dp),
20                    ) {
21                        Column(
22                            modifier = Modifier.wrapContentSize(),
23                            verticalArrangement = Arrangement.Center,
24                            horizontalAlignment = Alignment.CenterHorizontally,
25                        ) {
26                            Spacer(Modifier.height(16.dp))
27                            Text("Crear nuevo elemento")
28                            OutlinedTextField(
29                                label = { Text("Introduce un nombre") },
30                                value = texto,
31                                onValueChange = { texto = it },
32                                singleLine = true,
33                                modifier = Modifier.fillMaxWidth(0.9f) // Ajusta el ancho del campo de texto (%)
34                            )
35                            Row(
36                                modifier = Modifier.fillMaxWidth(),
37                                horizontalArrangement = Arrangement.Center,
38                            ) {
39                                TextButton(
40                                    onClick = { abierto.value = false },
41                                    modifier = Modifier.padding(8.dp),
42                                ) {
43                                    Text(LocalContext.current.getString(R.string.cancel))
44                                }
45                                TextButton(
46                                    onClick = {
47                                        if (texto.isNotBlank()) {
48                                            onSave(texto)
49                                            abierto.value = false
50                                            texto = ""
51                                        }
52                                    },
53                                    modifier = Modifier.padding(8.dp)
54                                ) {
55                                    Text("Guardar")
56                                }
57                            }
58                        }
59                    }
60                }
61            }
62        }
63    }
64}

2.12.4. Selección de hora: TimePicker

El siguiente código muestra una forma sencilla de crear un cuadro de diálgo para la selección de una hora.

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun DialogoSeleccionHora() {
 4    var mostrarDialogo by remember { mutableStateOf(false) }
 5    val timePickerState = rememberTimePickerState() // Hora actual del sistema.
 6    var horaSeleccionada by remember { mutableStateOf("") }
 7
 8    Column(horizontalAlignment = Alignment.CenterHorizontally) {
 9        Button(onClick = { mostrarDialogo = true }) {
10            Text("Seleccionar hora")
11        }
12
13        Spacer(Modifier.height(8.dp))
14        Text(text = "Hora seleccionada: $horaSeleccionada")
15
16        if (mostrarDialogo) {
17            AlertDialog(
18                onDismissRequest = { mostrarDialogo = false },
19                confirmButton = {
20                    TextButton(onClick = {
21                        val h = timePickerState.hour.toString().padStart(2, '0')
22                        val m = timePickerState.minute.toString().padStart(2, '0')
23                        horaSeleccionada = "$h:$m"
24                        mostrarDialogo = false
25                    }) { Text("Aceptar") }
26                },
27                dismissButton = {
28                    TextButton(onClick = { mostrarDialogo = false }) {
29                        Text("Cancelar")
30                    }
31                },
32                title = { Text("Selecciona la hora") },
33                text = { TimePicker(state = timePickerState) }
34            )
35        }
36    }
37}

2.12.5. Selección de fecha: DatePicker

Este caso es similar al anterior pero, en esta ocasión, para seleccionar una fecha.

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun DialogoSeleccionFecha() {
 4    var mostrarDialogo by remember { mutableStateOf(false) }
 5    val datePickerState = rememberDatePickerState() // Fecha actual del sistema.
 6    var fechaSeleccionada by remember { mutableStateOf("") }
 7
 8    Column(horizontalAlignment = Alignment.CenterHorizontally) {
 9        Button(onClick = { mostrarDialogo = true }) {
10            Text("Seleccionar fecha")
11        }
12
13        Spacer(Modifier.height(8.dp))
14        Text("Fecha seleccionada: $fechaSeleccionada")
15
16        if (mostrarDialogo) {
17            AlertDialog(
18                onDismissRequest = { mostrarDialogo = false },
19                confirmButton = {
20                    TextButton(onClick = {
21                        datePickerState.selectedDateMillis?.let { millis ->
22                            val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
23                            val fecha = Instant.ofEpochMilli(millis) // Convertir milisegundos a Instant.
24                                .atZone(ZoneId.systemDefault()) // Convertir a zona horaria local.
25                                .toLocalDate() // Obtener la fecha local.
26                            fechaSeleccionada = formatter.format(fecha)
27                        }
28                        mostrarDialogo = false
29                    }) { Text("Aceptar") }
30                },
31                dismissButton = {
32                    TextButton(onClick = { mostrarDialogo = false }) {
33                        Text("Cancelar")
34                    }
35                },
36                title = { Text("Selecciona la fecha") },
37                text = { DatePicker(state = datePickerState) }
38            )
39        }
40    }
41}

2.12.6. Buenas prácicas

Recomendación Explicación
Control con remember Utiliza variables de estado para controlar la visibilidad del diálogo
Confirmación explícita Utiliza botones confirm/dismiss con acciones claras
Diálogo no bloqueante Usa onDismissRequest para permitir al usuario cerrar tocando fuera
Evita usarlo en recomposiciones frecuentes Solo muestra el diálogo cuando el estado lo indique

Ejemplos prácticos

Ejemplo práctico 10 Login en un cuadro de diálgo

Fuentes


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Subsecciones de Tema 2: Interfaz de usuario

Ejemplo práctico 1: Centrar un Text usando Modifier.layout

Objetivo

Aprende a crear un layout personalizado que centre un composable hijo (en este caso, un Text) manualmente, sin usar Box o Arrangement.Center.

Estructura del Modifier.layout

 1Modifier.layout { measurable, constraints ->
 2    // Se mide el hijo con las restricciones.
 3    val placeable = measurable.measure(constraints)
 4
 5    // Se calcula el tamaño total del padre.
 6    val width = constraints.maxWidth
 7    val height = constraints.maxHeight
 8
 9    // se calcula la posición centrada.
10    val x = (width - placeable.width) / 2
11    val y = (height - placeable.height) / 2
12
13    // Se devuelve el layout y se coloca el hijo.
14    layout(width, height) {
15        placeable.place(x, y)
16    }
17}

Ejemplo completo

Se crea el método composable:

 1@Composable
 2fun LayoutPersonalizadoDemo() {
 3    Box(
 4        modifier = Modifier
 5            .wrapContentHeight() // Se ajusta al contenido
 6            .background(Color(0xFFEFEFEF)) // Fondo gris claro
 7            .centroManual() // Modificador personalizado
 8    ) {
 9        Text(
10            text = "Texto centrado con layout personalizado",
11            fontSize = 18.sp,
12            color = Color.Black
13        )
14    }
15}

A continuación, se crea el modificador personalizado centroManual():

 1fun Modifier.centroManual(): Modifier = this.then(
 2    Modifier.layout { measurable, constraints ->
 3        val placeable = measurable.measure(constraints)
 4
 5        val width = constraints.maxWidth
 6        val height = constraints.maxHeight
 7
 8        val x = (width - placeable.width) / 2
 9        val y = (height - placeable.height) / 2
10
11        layout(width, height) {
12            placeable.place(x, y)
13        }
14    }
15)

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:

1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaLayoutPersonalizado() {
4    LayoutPersonalizadoDemo()
5}

Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 2: Limitar al 50% del espacio disponible usando Modifier.layout

Objetivo

Se creará un modificador personalizado llamado limitarAnchoAl50Porciento() que limite el ancho del hijo al 50% del ancho máximo disponible. Además, colocará el hijo centrado horizontalmente dentro del espacio total y mantendrá la altura original del hijo.

Modificador personalizado

 1fun Modifier.limitarAnchoAl50Porciento(): Modifier = this.then(
 2    Modifier.layout { measurable, constraints ->
 3        // Se calcula el 50% del ancho disponible.
 4        val anchoDisponible = constraints.maxWidth
 5        val anchoLimitado = anchoDisponible / 2
 6
 7        // Se crean nuevas restricciones con ancho máximo reducido.
 8        val newConstraints = constraints.copy(maxWidth = anchoLimitado)
 9
10        // Se mide el hijo con esas restricciones.
11        val placeable = measurable.measure(newConstraints)
12
13        // La altura del padre será la del hijo, ancho será el original.
14        layout(anchoDisponible, placeable.height) {
15            // Se centra horizontalmente
16            val x = (anchoDisponible - placeable.width) / 2
17            placeable.place(x, 0)
18        }
19    }
20)

A continuación, se creará el método composable:

 1@Composable
 2fun LayoutAnchoLimitadoDemo() {
 3    Box(
 4        modifier = Modifier
 5            .fillMaxHeight()
 6            .background(Color(0xFFEFEFEF))
 7            .limitarAnchoAl50Porciento()
 8    ) {
 9        Text(
10            text = "Ancho limitado al 50%",
11            fontSize = 16.sp,
12            color = Color.Black,
13            modifier = Modifier
14                .background(Color.Yellow)
15                .padding(8.dp)
16        )
17    }
18}

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:

1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaAnchoLimitado() {
4    LayoutAnchoLimitadoDemo()
5}

Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 3: BoxWithConstraints adaptativo

Objetivo

Este ejemplo muestra un texto diferente según si el ancho del contenedor es mayor o menor a 300.dp. Además, el fondo cambia de color para mayor visibilidad.

Composable EjemploBoxWithConstraints

 1@SuppressLint("UnusedBoxWithConstraintsScope")
 2@Composable
 3fun EjemploBoxWithConstraints() {
 4    BoxWithConstraints(
 5        modifier = Modifier
 6            .fillMaxWidth()
 7            .height(200.dp)
 8            .background(Color.LightGray)
 9    ) {
10        val isPantallaGrande = maxWidth > 300.dp
11
12        Box(
13            modifier = Modifier
14                .fillMaxSize()
15                .background(if (isPantallaGrande) Color.Cyan else Color.Magenta),
16            contentAlignment = Alignment.Center
17        ) {
18            Text(
19                text = if (isPantallaGrande) "Pantalla ancha" else "Pantalla estrecha",
20                fontSize = 20.sp,
21                color = Color.White
22            )
23        }
24    }
25}

Para visualizar el ejemplo en la vista previa de Android Studio añade los siguientes métodos, ajustando los anchos de pantalla:

 1@Preview(showBackground = true, widthDp = 400)
 2@Composable
 3fun VistaPreviaPantallaAncha() {
 4    EjemploBoxWithConstraints()
 5}
 6
 7@Preview(showBackground = true, widthDp = 250)
 8@Composable
 9fun VistaPreviaPantallaEstrecha() {
10    EjemploBoxWithConstraints()
11}

Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 4: Barra de progreso circular personalizada

Objetivo

Este ejemplo muestra cómo crear una barra de progreso circular usando Canvas.

Composable BarraProgresoCircular

 1@Composable
 2fun BarraProgresoCircular(progreso: Float) {
 3    Canvas(modifier = Modifier.size(150.dp)) {
 4        // Fondo del círculo (gris)
 5        drawCircle(
 6            color = Color.LightGray,
 7            radius = size.minDimension / 2,
 8            center = center,
 9            style = Stroke(width = 20f)
10        )
11
12        // Progreso (azul)
13        drawArc(
14            color = Color.Blue,
15            startAngle = -90f,
16            sweepAngle = 360 * progreso,
17            useCenter = false,
18            style = Stroke(width = 20f, cap = StrokeCap.Round),
19            size = size
20        )
21    }
22}

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente métodos:

1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaBarraProgreso() {
4    BarraProgresoCircular(progreso = 0.65f) // 65% de progreso
5}

Para dotar de movimiento a la barra de progreso, se añadirá el siguiente código en el método onCreate(). Además, se podrá ver una breve introducción al uso de Scaffold, Toolbar y FloatingActionButton.

 1override fun onCreate(savedInstanceState: Bundle?) {
 2    super.onCreate(savedInstanceState)
 3    enableEdgeToEdge()
 4    setContent {
 5        Examplet02Theme {
 6            var progreso by remember { mutableStateOf(0.0f) }
 7
 8            Scaffold(
 9                modifier = Modifier.fillMaxSize(),
10                topBar = {
11                    TopAppBar(
12                        title = { Text("Scaffold con Canvas") }
13                    )
14                },
15                floatingActionButton = {
16                    FloatingActionButton(
17                        onClick = {
18                            progreso += 0.1f
19                            if (progreso > 1.01f) progreso = 0f
20                        }
21                    ) {
22                        Icon(Icons.Default.Refresh, contentDescription = "Incrementar Progreso")
23                    }
24                }
25            ) { padding ->
26                Box(
27                    modifier = Modifier
28                        .fillMaxSize()
29                        .padding(padding),
30                    contentAlignment = Alignment.Center
31                ) {
32                    BarraProgresoCircular(progreso = progreso)
33                }
34            }
35        }
36    }
37}

Para verla en acción deberás lanzar la aplicación contra un emulador o un dispositivo físico.

Ejemplo práctico 4 Ejemplo práctico 4


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 5: Fondo decorativo con formas

Objetivo

Este ejemplo crea un fondo decorativo con círculos y líneas, ideal para personalizar pantallas.

Composable FondoDecorativo

 1@Composable
 2fun FondoDecorativo(modifier: Modifier = Modifier) {
 3    Canvas(modifier = modifier.fillMaxSize()) {
 4        val ancho = size.width
 5        val alto = size.height
 6
 7        // Fondo general
 8        drawRect(Color(0xFFEFEFEF))
 9
10        // Círculo azul en esquina superior izquierda
11        drawCircle(
12            color = Color.Blue,
13            radius = ancho / 4,
14            center = Offset(ancho / 4, alto / 4)
15        )
16
17        // Línea diagonal decorativa
18        drawLine(
19            color = Color.Magenta,
20            start = Offset(0f, alto),
21            end = Offset(ancho, 0f),
22            strokeWidth = 10f
23        )
24
25        // Pequeños círculos decorativos
26        for (i in 1..5) {
27            drawCircle(
28                color = Color.Green,
29                radius = 20f,
30                center = Offset(ancho * i / 6, alto * i / 6)
31            )
32        }
33    }
34}

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:

 1@Preview(showBackground = true)
 2@Composable
 3fun VistaPreviaFondoDecorativo() {
 4    Box(modifier = Modifier.size(300.dp, 300.dp)) {
 5        FondoDecorativo()
 6        Text(
 7            text = "Contenido",
 8            modifier = Modifier.align(Alignment.Center),
 9            color = Color.Black,
10            fontSize = 18.sp
11        )
12    }
13}

Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 6: Animación con graphicsLayer y Scaffold

Objetivo

Este ejemplo se creará un Scaffold con TopAppBar y FloatingActionButton. Se creará un composable que anime su rotación y escala usando graphicsLayer y un botón flotante que inicie o detenga la animación.

Composable EjemploAvanzadoGraphicsLayer

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun EjemploAvanzadoGraphicsLayer() {
 4    var animar by remember { mutableStateOf(false) }
 5    val rotacion by animateFloatAsState(
 6        targetValue = if (animar) 360f else 0f,
 7        animationSpec = tween(durationMillis = 2000, easing = LinearEasing)
 8    )
 9    val escala by animateFloatAsState(
10        targetValue = if (animar) 1.5f else 1f,
11        animationSpec = tween(durationMillis = 2000, easing = LinearEasing)
12    )
13
14    Scaffold(
15        topBar = {
16            TopAppBar(title = { Text("GraphicsLayer Avanzado") })
17        },
18        floatingActionButton = {
19            FloatingActionButton(onClick = { animar = !animar }) {
20                Icon(Icons.Default.PlayArrow, contentDescription = "Animar")
21            }
22        }
23    ) { padding ->
24        Box(
25            modifier = Modifier
26                .fillMaxSize()
27                .padding(padding),
28            contentAlignment = Alignment.Center
29        ) {
30            Box(
31                modifier = Modifier
32                    .size(150.dp)
33                    .graphicsLayer(
34                        rotationZ = rotacion,
35                        scaleX = escala,
36                        scaleY = escala,
37                        alpha = 0.8f,
38                        shadowElevation = 16f
39                    )
40                    .background(Color(0xFF6200EE)),
41                contentAlignment = Alignment.Center
42            ) {
43                Text("Animado", color = Color.White, fontSize = 18.sp)
44            }
45        }
46    }
47}

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:

1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaEjemploAvanzado() {
4    EjemploAvanzadoGraphicsLayer()
5}

En resumen, se coloca un Scaffold con barra superior y un FAB, se crea un Box central ocupando toda la pantalla que rota 360 grados y se escala a 1.5x cuando se pulsa el FAB. El estado animar controla si se inicia o detiene la animación. Por último, se utiliza animateFloatAsState para interpolar suavemente.


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 7: Uso básico de Scaffold

Objetivo

En este ejemplo se utiliza Material 3 para añadir un Scaffold con TopAppBar, BottomAppBar, y FloatingActionButton. Además, se utiliza un Snackbar mediante SnackbarHostState y cambio de contenido al pulsar el FAB, mostrando Snackbar cuando el contador alcanza el máximo (5).

Composable ScaffoldMaterial3ConSnackbar

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun ScaffoldMaterial3ConSnackbar() {
 4    val snackbarHostState = remember { SnackbarHostState() }
 5    val scope = rememberCoroutineScope()
 6    var contador by remember { mutableStateOf(0) }
 7
 8    Scaffold(
 9        snackbarHost = { SnackbarHost(snackbarHostState) },
10        topBar = {
11            TopAppBar(
12                title = { Text("Mi App Simple M3") },
13                colors = topAppBarColors(
14                    containerColor = MaterialTheme.colorScheme.primaryContainer,
15                    titleContentColor = MaterialTheme.colorScheme.primary,
16                )
17            )
18        },
19        bottomBar = {
20            BottomAppBar {
21                IconButton(onClick = { /* Acción 1 */ }) {
22                    Icon(Icons.Default.Home, contentDescription = "Home")
23                }
24                Spacer(Modifier.weight(1f))
25                IconButton(onClick = { /* Acción 2 */ }) {
26                    Icon(Icons.Default.Favorite, contentDescription = "Favoritos")
27                }
28            }
29        },
30        floatingActionButton = {
31            ExtendedFloatingActionButton(
32                onClick = {
33                    if (contador < 5) {
34                        contador++
35                        Log.d("ScaffoldM3", "Contador incrementado: $contador")
36                    } else {
37                        scope.launch {
38                            snackbarHostState.showSnackbar(
39                                "Conteo máximo alcanzado",
40                                actionLabel = "Reiniciar",
41                                duration = SnackbarDuration.Short
42                            ).let { result ->
43                                if (result == SnackbarResult.ActionPerformed) {
44                                    contador = 0
45                                }
46                            }
47                        }
48                    }
49                }
50            ) {
51                Text("Sumar")
52            }
53        },
54        floatingActionButtonPosition = FabPosition.End
55    ) { innerPadding ->
56        Box(
57            modifier = Modifier
58                .fillMaxSize()
59                .padding(innerPadding),
60            contentAlignment = Alignment.Center
61        ) {
62            Text(
63                text = "Conteo: $contador",
64                fontSize = 24.sp,
65                fontWeight = FontWeight.Bold
66            )
67        }
68    }
69}

Para visualizar el ejemplo en la vista previa de Android Studio añade el siguiente método:

1@Preview(showBackground = true)
2@Composable
3fun VistaPrevia() {
4    ScaffoldMaterial3ConSnackbar()
5}

En resumen, se utiliza remember { SnackbarHostState() } para evitar que se cree una nueva en cada recomposición. El Scaffold recibe el snackbarHost, enlazado con el SnackbarHostState creado.

El FAB incrementa contador que, al llegar a 5, mostrará un Snackbar con opción “Reiniciar” y resetea el contador si se pulsa la acción.

innerPadding hace que el contenido central respete las barras del Scaffold.


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 8: Menú básico en TopAppBar con devolución de selección vía callback

Objetivo

Este ejemplo trata de plantear una posible solución a problemas que pueden plantearse durante el desarrollo de aplicaciones móviles. La idea es crear un componente para montar una TopAppBar con un menú, evaluando la selección del usuario mediante un único callback, comprobando la respuesta producida y actuando en consecuencia. Debes tener en cuenta que en Compose los métodos no devuelven valores, de ahí el uso de callbacks.

Recursos en string.xml

Tratará de evitarse lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.

 1<resources>
 2    <string name="app_name">ExampleT2_8</string>
 3
 4    <string name="txt_welcome">Selecciona una opción del menú</string>
 5
 6    <string name="txt_option_title">Más opciones</string>
 7
 8    <string name="txt_option_share">Compartir</string>
 9    <string name="txt_option_save">Guardar</string>
10    <string name="txt_option_logout">Cerrar sesión</string>
11
12    <string name="txt_share">Has seleccionado la opción <b>Compartir</b>.</string>
13    <string name="txt_save">Has seleccionado la opción <b>Guardar</b>.</string>
14    <string name="txt_logout">Has seleccionado la opción <b>Cerrar sesión</b>.</string>
15</resources>

Sealed Class

Para simplificar el código, se creará la siguiente sealed class en un fichero a parte, lo que permite reducir las evaluaciones para este caso.

 1sealed class OpcionMenu {
 2    object Compartir : OpcionMenu()
 3    object Guardar : OpcionMenu()
 4    object Logout : OpcionMenu()
 5
 6    override fun toString(): String {
 7        return when (this) {
 8            Compartir -> "Compartir"
 9            Guardar -> "Guardar"
10            Logout -> "Cerrar sesión"
11        }
12    }
13}

Compose TopBarConMenu

Supón que quieres reutilizar este componente en más de una vista, para eso se creará este componente en un fichero separado, por ejemplo, Utils.kt.

 1@OptIn(ExperimentalMaterial3Api::class)
 2@Composable
 3fun TopBarConMenu(
 4    onOpcionSeleccionada: (OpcionMenu) -> Unit
 5) {
 6    var expanded by remember { mutableStateOf(false) }
 7    val context = LocalContext.current
 8
 9    TopAppBar(
10        title = { Text("TopAppBar con Menú") },
11        colors = topAppBarColors(
12            containerColor = MaterialTheme.colorScheme.primaryContainer,
13            titleContentColor = MaterialTheme.colorScheme.primary,
14        ),
15        actions = {
16            IconButton(onClick = { expanded = true }) {
17                Icon(Icons.Default.MoreVert, contentDescription = context.getString(R.string.txt_option_title))
18            }
19            DropdownMenu(
20                expanded = expanded,
21                onDismissRequest = { expanded = false }
22            ) {
23                DropdownMenuItem(
24                    text = { Text(context.getString(R.string.txt_option_share)) },
25                    onClick = {
26                        expanded = false
27                        onOpcionSeleccionada(OpcionMenu.Compartir)
28                    }
29                )
30                DropdownMenuItem(
31                    text = { Text(context.getString(R.string.txt_option_save)) },
32                    onClick = {
33                        expanded = false
34                        onOpcionSeleccionada(OpcionMenu.Guardar)
35                    }
36                )
37                DropdownMenuItem(
38                    text = { Text(context.getString(R.string.txt_option_logout)) },
39                    onClick = {
40                        expanded = false
41                        onOpcionSeleccionada(OpcionMenu.Logout)
42                    }
43                )
44            }
45        }
46    )
47}

Resultado de la MainActivity

Ahora, la actividad principal tendrá un aspecto más limpio al hacer uso de la sealed class y el componente creado en un fichero a parte.

 1class MainActivity : ComponentActivity() {
 2    override fun onCreate(savedInstanceState: Bundle?) {
 3        super.onCreate(savedInstanceState)
 4        enableEdgeToEdge()
 5
 6        setContent {
 7            ExampleT2_8Theme {
 8                PantallaPrincipal()
 9            }
10        }
11    }
12}
13
14@Preview(showBackground = true)
15@Composable
16fun PantallaPrincipal() {
17    val context = LocalContext.current
18    var mensaje by remember { mutableStateOf(context.getString(R.string.txt_welcome)) }
19
20    Scaffold(
21        topBar = {
22            TopBarConMenu { opcion ->
23                mensaje = when (opcion) {
24                    is OpcionMenu.Compartir -> context.getString(R.string.txt_share)
25                    is OpcionMenu.Guardar -> context.getString(R.string.txt_save)
26                    is OpcionMenu.Logout -> context.getString(R.string.txt_logout)
27                }
28            }
29        },
30        modifier = Modifier.fillMaxSize()
31    ) { innerPadding ->
32        Box(
33            modifier = Modifier
34                .padding(innerPadding)
35                .fillMaxSize(),
36            contentAlignment = Alignment.Center
37        ) {
38            Text(
39                text = mensaje,
40                fontSize = 18.sp
41            )
42        }
43    }
44}

El uso de este esquema permite el tipado seguro, evitando así errores de escritura en las cadenas, es escalable, está integrado con when lo que permite una evaluación exhaustiva y permite la reutilización, ya que la acción se realizará en el when, y no en el método encargado de montar el menú.

Puede ser más óptimo, por ejemplo, añadiendo propiedades como label o icon dentro de la sealed class.


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 9: Menú con DropdownMenu en BottomAppBar

Objetivo

Este ejemplo es una variante del anterior. Se sustituirá la TopAppBar con un menú por una BottomAppBar, evaluando la selección del usuario mediante un único callback, comprobando la respuesta recibida y actuando en consecuencia. Debes tener en cuenta que en Compose los métodos no devuelven valores, de ahí el uso de callbacks. Se mantendrá la misma estructura de sealed class, añadiendo label e icon.

Recursos en string.xml

Como en la versión anterior, se tratará de evitar lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.

 1<resources>
 2    <string name="app_name">ExampleT2_8</string>
 3
 4    <string name="txt_welcome">Selecciona una opción del menú</string>
 5
 6    <string name="txt_option_title">Más opciones</string>
 7
 8    <string name="txt_option_share">Compartir</string>
 9    <string name="txt_option_save">Guardar</string>
10    <string name="txt_option_logout">Cerrar sesión</string>
11
12    <string name="txt_share">Has seleccionado la opción <b>Compartir</b>.</string>
13    <string name="txt_save">Has seleccionado la opción <b>Guardar</b>.</string>
14    <string name="txt_logout">Has seleccionado la opción <b>Cerrar sesión</b>.</string>
15</resources>

Sealed Class

Para simplificar el código, se creará la siguiente sealed class en un fichero a parte, lo que permite reducir las evaluaciones para este caso. Como se ha comentado, se añadirán dos nuevas propiedades a la clase label e icon.

1sealed class OpcionMenu(val label: String, val icon: ImageVector) {
2    object Compartir : OpcionMenu("Compartir", Icons.Default.Share)
3    object Guardar : OpcionMenu("Guardar", Icons.Default.Add)
4    object Logout : OpcionMenu("Cerrar sesión", Icons.AutoMirrored.Filled.ExitToApp)
5
6    companion object {
7        val todas = listOf(Compartir, Guardar, Logout)
8    }
9}

Compose BottomAppBarConMenu

Supón que quieres reutilizar este componente en más de una vista, para eso se creará este componente en un fichero separado, por ejemplo, Utils.kt. Esta versión está mejorada con respecto al ejemplo anterior, se crea un bucle para mostrar las opciones que se vayan añadiendo en la sealed class.

 1@Composable
 2fun BottomAppBarConMenu(
 3    onOpcionSeleccionada: (OpcionMenu) -> Unit
 4) {
 5    var expanded by remember { mutableStateOf(false) }
 6
 7    BottomAppBar(
 8        actions = {
 9            IconButton(onClick = { expanded = !expanded }) {
10                Icon(Icons.Default.MoreVert, contentDescription = "Menú inferior")
11            }
12
13            DropdownMenu(
14                expanded = expanded,
15                onDismissRequest = { expanded = false }
16            ) {
17                OpcionMenu.todas.forEach { opcion ->
18                    DropdownMenuItem(
19                        text = { Text(opcion.label) },
20                        leadingIcon = { Icon(opcion.icon, contentDescription = null) },
21                        onClick = {
22                            expanded = false
23                            onOpcionSeleccionada(opcion)
24                        }
25                    )
26                }
27            }
28        }
29    )
30}

Resultado de la MainActivity

Ahora, la actividad principal tendrá un aspecto más limpio al hacer uso de la sealed class y el componente creado en un fichero a parte.

 1class MainActivity : ComponentActivity() {
 2    override fun onCreate(savedInstanceState: Bundle?) {
 3        super.onCreate(savedInstanceState)
 4        enableEdgeToEdge()
 5        setContent {
 6            ExampleT2_9Theme {
 7                PantallaPrincipal()
 8            }
 9        }
10    }
11}
12
13@Preview(showBackground = true)
14@Composable
15fun PantallaPrincipal() {
16    val context = LocalContext.current
17    var mensaje by remember { mutableStateOf(context.getString(R.string.txt_welcome)) }
18
19    Scaffold(
20        bottomBar = {
21            BottomAppBarConMenu { opcion ->
22                mensaje = when (opcion) {
23                    is OpcionMenu.Compartir -> context.getString(R.string.txt_share)
24                    is OpcionMenu.Guardar -> context.getString(R.string.txt_save)
25                    is OpcionMenu.Logout -> context.getString(R.string.txt_logout)
26                }
27            }
28        },
29        modifier = Modifier.fillMaxSize()
30    ) { innerPadding ->
31        Box(
32            modifier = Modifier
33                .fillMaxSize()
34                .padding(innerPadding),
35            contentAlignment = Alignment.Center
36        ) {
37            Text(mensaje)
38        }
39    }
40}

Como puedes ver, BottomAppBar permite el uso de actions igual que la TopAppBar. También es posible implementar este menú utilizando la sección floatingActionButton y utilizando FloatingActionButton, y se puede combinar ambas barras (topBar y bottomBar) en el mismo Scaffold.


Autor/a: Javier Carrasco Última modificación: 27/08/2025

Ejemplo práctico 10: Login en un cuadro de diálgo

Objetivo

Este ejemplo permite crear un AlertDialog personalizado para solicitar usuario y contraseña.

Recursos en string.xml

Como en la versión anterior, se tratará de evitar lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.

 1<resources>
 2    <string name="app_name">ExampleT2_10</string>
 3
 4    <string name="txt_title">Login</string>
 5    <string name="txt_user">Usuario</string>
 6    <string name="txt_password">Contraseña</string>
 7
 8    <string name="txt_login_error">Credenciales incorrectas</string>
 9    <string name="txt_login_ok">Credenciales correctas</string>
10</resources>

Compose LoginDialog

Se creará el siguiente compose para mostrar el cuadro de diálogo personalizado, en esta ocasión se utiliza AlertDialog ya que la personalización es mínima y simplifica el código, pero para los cuadros de diálogo personalizados se recomienda el uso de Dialog.

 1@Composable
 2fun LoginDialog(onLogin: (String, String) -> Unit = { _, _ -> }) {
 3    val ctxt = LocalContext.current
 4    val openDialog = remember { mutableStateOf(false) }
 5    var user by remember { mutableStateOf("") }
 6    var pass by remember { mutableStateOf("") }
 7
 8    Box(
 9        modifier = Modifier.fillMaxSize(),
10        contentAlignment = Alignment.Center
11    ) {
12        Button(onClick = { openDialog.value = true }) {
13            Text(text = ctxt.getString(R.string.txt_title))
14        }
15
16        if (openDialog.value) {
17            AlertDialog(
18                onDismissRequest = { openDialog.value = true }, // Se mantiene el diálogo abierto.
19                title = { Text(text = ctxt.getString(R.string.txt_title)) },
20                text = {
21                    Column {
22                        OutlinedTextField(
23                            value = user,
24                            onValueChange = { user = it },
25                            singleLine = true,
26                            label = { Text(ctxt.getString(R.string.txt_user)) }
27                        )
28                        OutlinedTextField(
29                            value = pass,
30                            onValueChange = { pass = it },
31                            singleLine = true,
32                            label = { Text(ctxt.getString(R.string.txt_password)) },
33                            visualTransformation = PasswordVisualTransformation()
34                        )
35                    }
36                },
37                confirmButton = {
38                    TextButton(onClick = {
39                        if (user.isNotBlank() && pass.isNotBlank()) {
40                            onLogin(user, pass)
41                            openDialog.value = false
42                            // Limpiar los campos después del inicio de sesión.
43                            user = ""
44                            pass = ""
45                        }
46                    }) {
47                        Text(ctxt.getString(android.R.string.ok))
48                    }
49                },
50                dismissButton = {
51                    TextButton(onClick = {
52                        openDialog.value = false
53                        user = ""
54                        pass = ""
55                    }) {
56                        Text(ctxt.getString(android.R.string.cancel))
57                    }
58                }
59            )
60        }
61    }
62}

Observa el uso de visualTransformation en el OutlinedTextField, este permite la ocultación del password.

Compose MainScreen

Siguiendo con la reutilización y actualización del estado, se creará el siguiente composable para mostrar el botón de login o el mensaje de login correcto.

 1@Composable
 2fun MainScreen() {
 3    var isLoggedIn by remember { mutableStateOf(false) }
 4    val ctxt = LocalContext.current
 5
 6    if (!isLoggedIn) {
 7        // Se muestra el diálogo de inicio de sesión
 8        LoginDialog(
 9            onLogin = { user, pass ->
10                // Aquí se maneja la lógica de inicio de sesión
11                // Por ejemplo, verificar las credenciales
12                if (user == "admin" && pass == "1234") {
13                    isLoggedIn = true // Simulación de inicio de sesión exitoso
14                    println("Inicio de sesión correcto. Usuario: $user, Contraseña: $pass")
15                } else {
16                    // Se muestra un mensaje de error o manejar el fallo de inicio de sesión
17                    Toast.makeText(
18                        ctxt,
19                        ctxt.getString(R.string.txt_login_error),
20                        Toast.LENGTH_SHORT
21                    ).show()
22                }
23            }
24        )
25    } else {
26        // Contenido principal de la aplicación
27        Text(text = ctxt.getString(R.string.txt_login_ok))
28    }
29}

Resultado de la MainActivity

Ahora, la actividad principal tendrá el siguiente aspecto.

 1class MainActivity : ComponentActivity() {
 2    @OptIn(ExperimentalMaterial3Api::class)
 3    override fun onCreate(savedInstanceState: Bundle?) {
 4        super.onCreate(savedInstanceState)
 5        enableEdgeToEdge()
 6        setContent {
 7            ExampleT2_10Theme {
 8                Scaffold(
 9                    topBar = { TopAppBar(title = { Text(getString(R.string.app_name)) }) }
10                ) { innerPadding ->
11                    Box(
12                        modifier = Modifier
13                            .fillMaxSize()
14                            .padding(innerPadding),
15                        contentAlignment = Alignment.Center
16                    ) {
17                        MainScreen()
18                    }
19                }
20            }
21        }
22    }
23}