En este tutorial verás el uso de menús en Compose, con el fin de desplegar una lista de opciones en superficies que aparecen temporalmente hasta que el usuario selecciona una de ellas, o cancela la selección.
Verás que existen dos tipos de menús: Dropdown Menu y Exposed Dropdown Menu. Para ambos se presentan ejemplos que te permiten comprender su funcionalidad para que puedas incluirlos en tus proyectos.
La siguiente es una tabla con más detalle sobre el contenido de la lección:
Repositorio De Menús En Compose
El código de los ejemplos aquí descritos hace parte de mi repositorio para la guía de Jetpack Compose:
Los encuentras en el módulo :p7_componentes
, al interior del paquete examples/menus
. Recuerda presionar el botón de ⭐Star si este repositorio te es de utilidad.
Dropdown Menu
Usa la función componible DropdownMenu()
para crear un menú desplegable por debajo del elemento que desencadena su aparición. Su definición en la librería es:
@Composable
fun DropdownMenu(
expanded: Boolean!,
onDismissRequest: (() -> Unit)?,
modifier: Modifier! = Modifier,
offset: DpOffset! = DpOffset(0.dp, 0.dp),
properties: PopupProperties! = PopupProperties(focusable = true),
content: (@Composable @ExtensionFunctionType ColumnScope.() -> Unit)?
): Unit
Donde:
expanded
: Determina si el menú está abierto o cerradoonDismissRequest
: Lambda llamada cuando el usuario solicita descartar el menú (tap por fuera del menú o tap en el Back button)offset
: La cantidad de desplazamiento aplicado al menú según la dirección en que se expandeproperties
: Propiedades usadas para configurar el comportamiento del componentePopUp
queDropdownMenu()
usa en su interiorcontent
: Espacio donde incluyes elementosDropdownMenuItem
para representar los ítems del menú
Agregar Items Al Menú
Para agregar las opciones del menú invoca al componente DropdownMenuItem
por cada elemento. Su definición es la siguiente:
@Composable
fun DropdownMenuItem(
onClick: (() -> Unit)?,
modifier: Modifier! = Modifier,
enabled: Boolean! = true,
contentPadding: PaddingValues! = MenuDefaults.DropdownMenuItemContentPadding,
interactionSource: MutableInteractionSource! = remember { MutableInteractionSource() },
content: (@Composable @ExtensionFunctionType RowScope.() -> Unit)?
): Unit
Ten en cuenta que:
onClick
: Es la lambda ejecutada cuando el usuario hace clic en el ítemenabled
: Determina si ítem está habilitado o deshabilitado (tono grisáceo y no recibe eventos)contentPadding
: Relleno aplicado al contenido del ítemcontent
: El espacio donde agregas el componenteText
para nombrar al ítem
Ejemplo: DropdownMenu Simple
Supongamos que tenemos una App de tareas y deseamos incluir en su vista de lista un menú para realizar las siguientes acciones:
- Cambiar nombre
- Enviar por email
- Copiar enlace
- Ocultar subtareas completas
- Eliminar
Para crear el menú debemos:
- Definir la expansión del menú como estado
- Declarar la lista de opciones
- Invocar a
DropdownMenu
- Añadir cinco elementos
DropdownMenuItem
El código de la solución lo encuentras en la función TaskMenu()
:
@Composable
fun TaskMenu(
expanded: Boolean, // (1)
onItemClick: (String) -> Unit,
onDismiss: () -> Unit
) {
val options = listOf( // (2)
"Cambiar nombre",
"Enviar por email",
"Copiar enlace",
"Ocultar subtareas completas",
"Eliminar"
)
DropdownMenu( // (3)
expanded = expanded,
onDismissRequest = onDismiss
) {
options.forEach { option ->
DropdownMenuItem( // (4)
onClick = {
onItemClick(option)
onDismiss()
}
) {
Text(text = option)
}
}
}
}
Como ves, hemos elevado el estado de la expansión y las funciones ejecutadas al clickear un ítem y al descartar el menú. Estos valores son proveídos desde la función TasksUi()
, la cual se encarga de dibujar la tarea y el icono que muestra al menú:
@Composable
fun TasksUi() {
var taskMenuOpen by remember { mutableStateOf(false) }
var action by remember { mutableStateOf("Ninguna") }
Box(
Modifier
.border(width = 1.dp, shape = RectangleShape, color = Color.LightGray)
.padding(horizontal = 16.dp)
.fillMaxWidth()
.height(56.dp)
) {
Row(
Modifier
.fillMaxWidth()
.align(Alignment.CenterStart),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = false,
onCheckedChange = {},
modifier = Modifier
.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "Limpiar alacena"
)
Text(text = "Acción: $action", style = MaterialTheme.typography.caption)
}
}
IconButton(
onClick = { taskMenuOpen = true },
modifier = Modifier
.size(24.dp)
.align(Alignment.CenterEnd)
) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "Acciones para tarea"
)
TaskMenu(
expanded = taskMenuOpen,
onItemClick = { action = it },
onDismiss = {
taskMenuOpen = false
}
)
}
}
}
Al ejecutar la función componible tendrás:
TasksUi()
en acciónEjemplo: Ítem Con Icono, Item Deshabilitado Y Divisor
Probemos con un caso donde podamos personalizar los ítems como el que se muestra en la imagen anterior.
Consideremos una App donde permitamos al usuario desplegar un menú para un imagen que visualiza, cuando hace clic prolongado. Las opciones asociadas y las decoraciones son:
- Previsualizar (Icono)
- Compartir (Icono)
- Copiar Enlace (Icono)
- Descargar (Icono + Deshabilitado)
- Denunciar (Icono + Divisor)
¿Cómo enfrentar estos requerimientos de interfaz?
- Iconos: Usa el componente
Icon
en el parámetrocontent
de los ítems. Recuerda que este tiene un recibidor tipoRowScope
, por lo que no es necesario añadir un elementoRow
- Estado Deshabilitado: Usa el parámetro
enabled
con el valor defalse
para deshabilitar un ítem - Divisor: Invoca al componente
Divider
entre los ítems donde deseas visualizarlo. En nuestro caso es antes de «Descargar»
Los elementos previos podemos agruparlos en una clase de datos llamada Option
. Además, como deseamos tratar al divisor como otro ítem, podemos crear una interfaz sellada MenuItem
que contenga ambos tipos:
sealed interface MenuItem {
data class Option(
val name: String,
val icon: ImageVector?,
val enabled: Boolean = true
) : MenuItem
object Divider : MenuItem
}
Teniendo en cuenta lo anterior, te será fácil comprender la función de ejemplo ImageMenu()
:
@Composable
fun ImageMenu(
expanded: Boolean,
onItemClick: (Option) -> Unit, // (1)
onDismiss: () -> Unit
) {
val options = listOf( // (2)
Option(
"Previsualizar",
ImageVector.vectorResource(R.drawable.ic_visibility)
),
Option("Compartir", Icons.Filled.Share),
Option("Copiar Enlace", ImageVector.vectorResource(R.drawable.ic_link)),
Option("Descargar", ImageVector.vectorResource(R.drawable.ic_file_download), false),
Divider,
Option("Denunciar", ImageVector.vectorResource(R.drawable.ic_flag)),
)
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss,
offset = DpOffset(50.dp, 250.dp),
modifier = Modifier.width(192.dp)
) {
options.forEach { option ->
when (option) { // (3)
is Option -> {
DropdownMenuItem(
enabled = option.enabled,
onClick = {
onItemClick(option)
onDismiss()
}
) {
option.icon?.let { // (4)
val values = LocalContentAlpha provides
if (option.enabled)
ContentAlpha.medium
else ContentAlpha.disabled
CompositionLocalProvider(values) {
Icon(it, contentDescription = null)
}
}
Spacer(Modifier.width(24.dp))
Text(text = option.name)
}
}
Divider -> Divider() // (5)
}
}
}
}
Algunos puntos resaltados:
- La lambda
onItemClick
ahora toma como parámetro un elementoOption
- En consecuencia, tenemos una lista de objetos
Options
a procesar con elforEach
- Usamos
when
para diferenciar entreOption
yDivider
- Si existe el icono en la opción, entonces mostramos un elemento
Icon
que está recubierto por un proveedor local que modifica su canal alfa según el valor deoption.enabled
- Invocamos la función
Divider()
La función ImageMenu()
es ejecutada desde ImageUI()
, lugar en el que se muestra una imagen de ejemplo que desplegará el menú en un evento de clic prolongado:
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImageUi() {
var imageMenuOpen by remember { mutableStateOf(false) }
Box(contentAlignment = Alignment.TopStart) {
Image(
painterResource(id = R.drawable.image),
contentDescription = null,
modifier = Modifier
.combinedClickable(
onLongClick = {
imageMenuOpen = true
},
onClick = {},
onDoubleClick = {}
)
.height(250.dp)
)
ImageMenu(
expanded = imageMenuOpen,
onItemClick = {},
onDismiss = {
imageMenuOpen = false
}
)
}
}
El ejemplo en acción se ve así:
ImagenUi()
en acciónEjemplo: Cambiar Tema De Dropdown Menu
La anterior ilustración muestra el cambio de la forma del diálogo a esquinas rectangulares y de su color de superficie por Azul 50.
Como ya viste en el tutorial de Temas en Compose, es posible llegar a dicho resultado creando un nuevo elemento MaterialTheme
y pasando un elemento Shapes
y otro Colors
.
En la siguiente función se evidencia la temificación:
@Composable
fun ThemedTaskMenu() {
var taskMenuOpen by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { taskMenuOpen = true }) {
Text(text = "Abrir Menú")
}
MaterialTheme(
colors = MaterialTheme.colors.copy(surface = Color(0xFFe3f2fd)),
shapes = MaterialTheme.shapes.copy(medium = CutCornerShape(size = 25f))
) {
TaskMenu(
expanded = taskMenuOpen,
onItemClick = {},
onDismiss = { taskMenuOpen = false }
)
}
}
}
Recuerda que los diálogos hacen parte de la categoría medium
de las formas y el color de superficie se indica con el parámetro surface
.
Para este ejemplo se agrega un OutlinedButton
que abre el menú al hacer click:
ThemeTaskMenu()
en acciónExposed Dropdown Menu
Usa la función componible ExposedDropdownMenuBox()
para mostrar un menú desplegable expuesto, con el objetivo de mantener visible la selección actual por encima de las demás opciones.
Esta es la firma de la función:
@ExperimentalMaterialApi
@Composable
fun ExposedDropdownMenuBox(
expanded: Boolean!,
onExpandedChange: ((Boolean) -> Unit)?,
modifier: Modifier! = Modifier,
content: (@Composable @ExtensionFunctionType ExposedDropdownMenuBoxScope.() -> Unit)?
): Unit
Nota: Como ves, en el momento que escribo este tutorial, aún está marcada como experimental.
Sus parámetros son similares a DropdownMenu
, salvo onExpandedChange
que es una lambda que se ejecuta cuando el usuario hace clic sobre el menú.
Ejemplo: Expanded Dropdown Menu Simple
Tomemos como ilustración un menú expuesto que permite seleccionar el tipo de teléfono que será asociado a un contacto (figura 7), donde las posibles opciones son:
- Fijo
- Móvil
- Trabajo
- Otro
¿Cómo abordar este simple escenario?
- Declarar la lista de tipos de número de teléfonos
- Declarar estados tanto para la apertura del menú como para la selección actual
- Invocar a
ExpandedDropdownMenuBox
- Añadir un
TextField
- Añadir un
DropdownMenu
con los items
La función componible PhoneNumberTypeMenu()
es la encargada de aplicar las tareas anteriores:
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PhoneNumberTypeMenu() {
val types = listOf("Fijo", "Móvil", "Trabajo", "Otro")
val default = 0
var expanded by remember { mutableStateOf(false) }
var selectedType by remember { mutableStateOf(types[default]) } // (1)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded // (2)
},
modifier = Modifier.width(150.dp)
) {
TextField(
readOnly = true, // (3)
value = selectedType, // (4)
onValueChange = { },
label = { Text("Tipo") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon( // (5)
expanded = expanded
)
},
colors = ExposedDropdownMenuDefaults.textFieldColors()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
}
) {
types.forEach { selectionOption ->
DropdownMenuItem(
onClick = {
selectedType = selectionOption
expanded = false
}
) {
Text(text = selectionOption)
}
}
}
}
}
Puntos a tener en cuenta del código preliminar:
- Es necesario recordar como estado la selección actual del
TextField
- Modificamos el estado de expansión del menú desde
onExpandedChange
- Como no deseamos recibir texto, marcamos al
TextField
como solo lectura - El valor del campo de texto es definido por la selección actual
- El icono del final del campo de texto es proveído por
ExposedDropdownMenuDefaults.TrailingIcon()
. Si revisas su implementación, verás que se cambia la rotación del icono de expansión según el valor pasado como parámetro
Al previsualizar en modo de interacción el resultado es:
PhoneNumberTypeMenu()
en acción