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
CanvasygraphicsLayerpara 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
LazyColumnyLazyRow(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. Layouts
En este apartado se explicará cómo funcionan los layouts más comunes en Jetpack Compose, la creación de layouts personalizados utilizando Modifier.layout, y cuándo es adecuado utilizarlo frente a los layouts predeterminados como Column, Row o Box.
2.2.1. Introducción a los layouts
Los layouts en Jetpack Compose son componentes que organizan y posicionan otros Composables en la pantalla. Los layouts predeterminados más comunes son:
Column: organiza los elementos en una columna vertical.Row: organiza los elementos en una fila horizontal.Box: superpone elementos uno encima de otro.
Estos layouts proporcionan una forma sencilla y eficiente de estructurar la interfaz de usuario. La estructura básica de un layout es la siguiente:
1Column(
2 modifier = Modifier.fillMaxSize(),
3 verticalArrangement = Arrangement.Center,
4 horizontalAlignment = Alignment.CenterHorizontally
5) {
6 Text("Elemento 1")
7 Text("Elemento 2")
8 Button(onClick = { /* Acción */ }) {
9 Text("Botón")
10 }
11}Entre las propiedades más comunes de los layouts se pueden encontrar:
| Propiedad | Descripción |
|---|---|
modifier |
Permite aplicar modificadores para ajustar tamaño, padding, etc. |
verticalArrangement |
Define cómo se distribuyen los elementos verticalmente (solo en Column). |
horizontalArrangement |
Define cómo se distribuyen los elementos horizontalmente (solo en Row). |
contentAlignment |
Define la alineación del contenido dentro del layout (solo en Box). |
weight |
Permite que un elemento ocupe un espacio proporcional dentro del layout. |
padding |
Añade espacio alrededor del layout o de los elementos dentro de él. |
Estos layouts son altamente personalizables mediante modificadores y permiten crear interfaces complejas de manera sencilla.
2.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.
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): ajusta el ancho del componente al mínimo necesario para que su contenido no se recorte.Modifier.width(IntrinsicSize.Max): ajusta el ancho del componente al máximo permitido según el contenido más ancho de sus hijos.Modifier.height(IntrinsicSize.Min): ajusta la altura del componente al mínimo necesario para mostrar correctamente su contenido.Modifier.height(IntrinsicSize.Max): ajusta la altura del componente al máximo permitido según el hijo más alto o el contenido más grande.
El siguiente ejemplo permite visualizar cómo las medidas intrínsecas afectan el tamaño de los contenedores en función del contenido.
1@Composable
2fun IntrinsicSizeExample() {
3 Row(
4 modifier = Modifier
5 .fillMaxWidth()
6 .padding(16.dp),
7 horizontalArrangement = Arrangement.SpaceEvenly
8 ) {
9 // 1. width(IntrinsicSize.Min)
10 Column(
11 modifier = Modifier
12 .width(IntrinsicSize.Min)
13 .background(Color(0xFFE1BEE7))
14 .padding(8.dp)
15 ) {
16 Text("Corto")
17 Text("Texto más largo")
18 }
19
20 // 2. width(IntrinsicSize.Max)
21 Column(
22 modifier = Modifier
23 .width(IntrinsicSize.Max)
24 .background(Color(0xFFBBDEFB))
25 .padding(8.dp)
26 ) {
27 Text("Corto")
28 Text("Texto más largo")
29 }
30
31 // 3. height(IntrinsicSize.Min)
32 Row(
33 modifier = Modifier
34 .height(IntrinsicSize.Min)
35 .background(Color(0xFFC8E6C9))
36 .padding(8.dp)
37 ) {
38 Text("Línea 1\nLínea 2")
39 HorizontalDivider(
40 color = Color.Black,
41 modifier = Modifier
42 .fillMaxHeight()
43 .width(2.dp)
44 )
45 Text("Texto más corto")
46 }
47
48 // 4. height(IntrinsicSize.Max)
49 Row(
50 modifier = Modifier
51 .height(IntrinsicSize.Max)
52 .background(Color(0xFFFFF9C4))
53 .padding(8.dp)
54 ) {
55 Text("Texto corto")
56 HorizontalDivider(
57 modifier = Modifier
58 .fillMaxHeight()
59 .width(2.dp),
60 thickness = DividerDefaults.Thickness, color = Color.Black
61 )
62 Text("Línea 1\nLínea 2\nLínea 3")
63 }
64 }
65}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
BoxWithConstraintsoModifier.layoutcuando 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}Si quieres que en lugar de dibujar un cuadro de 200 x 200, ocupe toda la pantalla, sustituye Modifier.size(200.dp) por Modifier.fillMaxSize(),
Visualización del ejemplo:
1@Preview(showBackground = true)
2@Composable
3fun VistaPreviaCanvas() {
4 EjemploCanvasSencillo()
5}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
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?
graphicsLayercrea 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
Scaffolddivide 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.
El cuadro de texto puede configurarse para distintos tipos de entrada, como correo electrónico, número, contraseña, etc., utilizando el parámetro keyboardOptions.
1import androidx.compose.ui.text.input.KeyboardType
2...TextField(
3 ...
4 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
5)Cuando se utiliza KeyboardType.Password, es recomendable añadir visualTransformation = PasswordVisualTransformation() para ocultar el texto introducido.
1import androidx.compose.ui.text.input.PasswordVisualTransformation
2...TextField(
3 ...
4 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
5 visualTransformation = PasswordVisualTransformation()
6)En definitiva, TextField es un componente versátil para entrada de texto en Compose, con múltiples opciones de personalización y gestión de estado. Su uso hace que aparezca el teclado virtual automáticamente al enfocarlo, pero en ocasiones puede ser necesario controlarlo manualmente mediante SoftwareKeyboardController, por ejemplo, para ocultarlo tras pulsar un botón. Para ello, se puede utilizar LocalSoftwareKeyboardController.current para obtener una instancia del controlador del teclado.
1import androidx.compose.ui.platform.LocalSoftwareKeyboardController
2...@Composable
3fun EjemploTextFieldConTeclado() {
4 val keyboardController = LocalSoftwareKeyboardController.current
5 var texto by remember { mutableStateOf("") }
6
7 Column(modifier = Modifier.padding(16.dp)) {
8 TextField(
9 value = texto,
10 onValueChange = { texto = it },
11 label = { Text("Escribe algo") },
12 modifier = Modifier.fillMaxWidth()
13 )
14 Spacer(Modifier.height(8.dp))
15 Button(onClick = {
16 keyboardController?.hide() // Oculta el teclado
17 }) {
18 Text("Ocultar teclado")
19 }
20 }
21}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
Coilactualmente tiene una integración estable, uso sencillo, tamaño reducido y una API moderna para Compose.Glidetiene 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}1Row(
2 modifier = Modifier
3 .fillMaxWidth()
4 .horizontalScroll(rememberScrollState())
5 .padding(16.dp)
6) {
7 ...
8}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
rememberyderivedStateOfpara 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
itemspara crear una lista de tarjetas con los nombres de los usuarios. - Cada tarjeta es clicable y muestra un
Toastal 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
Snackbardentro deScaffold. - Usa
Toastpara mostrar información rápida. - No muestres múltiples
Toastseguidos (no cancelables por el usuario). - Se recomienda observar el resultado del
Snackbarsi 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ácticas
| 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



