Menús En Compose

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.

Tipos de menús en Material Design
Figura 1. Tipos de menús en Material Design

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.

Ejemplos de Menús en Compose Android

Dropdown Menu

Dropdown Menu en Compose
Figura 2. Dropdown Menu en Compose

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 cerrado
  • onDismissRequest: 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 expande
  • properties: Propiedades usadas para configurar el comportamiento del componente PopUp que DropdownMenu() usa en su interior
  • content: Espacio donde incluyes elementos DropdownMenuItem 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 ítem
  • enabled: Determina si ítem está habilitado o deshabilitado (tono grisáceo y no recibe eventos)
  • contentPadding: Relleno aplicado al contenido del ítem
  • content: El espacio donde agregas el componente Text para nombrar al ítem

Ejemplo: DropdownMenu Simple

Figura 3. Ejemplo de Dropdown Menu

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:

  1. Definir la expansión del menú como estado
  2. Declarar la lista de opciones
  3. Invocar a DropdownMenu
  4. 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:

Interacción de dropdown menu en compose
TasksUi() en acción

Ejemplo: Ítem Con Icono, Item Deshabilitado Y Divisor

DropdownMenu con ítems personalizados
Figura 4. DropdownMenu con ítems personalizados

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?

  1. Iconos: Usa el componente Icon en el parámetro content de los ítems. Recuerda que este tiene un recibidor tipo RowScope, por lo que no es necesario añadir un elemento Row
  2. Estado Deshabilitado: Usa el parámetro enabled con el valor de false para deshabilitar un ítem
  3. 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:

  1. La lambda onItemClick ahora toma como parámetro un elemento Option
  2. En consecuencia, tenemos una lista de objetos Options a procesar con el forEach
  3. Usamos when para diferenciar entre Option y Divider
  4. 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 de option.enabled
  5. 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ón
ImagenUi() en acción

Ejemplo: Cambiar Tema De Dropdown Menu

Dropdown Menu temificado
Figura 5. Dropdown Menu temificado

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ón
ThemeTaskMenu() en acción

Exposed Dropdown Menu

Exposed Dropdown Menu en Compose
Figura 6. Exposed Dropdown Menu en Compose

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

Ejemplo de Exposed Dropdown Menu
Figura 7. Ejemplo de Exposed Dropdown Menu

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?

  1. Declarar la lista de tipos de número de teléfonos
  2. Declarar estados tanto para la apertura del menú como para la selección actual
  3. Invocar a ExpandedDropdownMenuBox
  4. Añadir un TextField
  5. 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:

  1. Es necesario recordar como estado la selección actual del TextField
  2. Modificamos el estado de expansión del menú desde onExpandedChange
  3. Como no deseamos recibir texto, marcamos al TextField como solo lectura
  4. El valor del campo de texto es definido por la selección actual
  5. 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
PhoneNumberTypeMenu() en acción

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!