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
ygraphicsLayer
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
yLazyRow
(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
Ejemplo práctico 1 Centrar un Text usando Modifier.layout
Ejemplo práctico 2 Limitar al 50% del espacio disponible usando Modifier.layout
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}
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
oModifier.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}
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 Canvas
es 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
Ejemplo práctico 4 Barra de progreso circular personalizada
Ejemplo práctico 5 Fondo decorativo con formas
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
Ejemplo práctico 6 Animación con graphicsLayer y Scaffold
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
yderivedStateOf
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 deScaffold
. - 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}
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
Ejemplo práctico 8 Menú básico en TopAppBar con devolución de selección vía callback
Ejemplo práctico 9 Menú con DropdownMenu en BottomAppBar
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
- Modificadores de Compose
- Diseños personalizados
- Rendimiento de Jetpack Compose
- Medidas intrínsecas en los diseños de Compose
- Imágenes y gráficos en Compose
- Modificadores gráficos
- Scaffold
- Glide
- Coil
- Listas y cuadrículas (LazyColumn y LazyRow)
- Snackbar
- Toast
- Menús
- Cuadros de diálogo
- Dialogs Material3