Bottom Sheets En Android

Las Bottom Sheets son superficies que se despliegan contenido suplementario anclado a la parte inferior de la pantalla.

Estándar – Modal

Existen dos tipos de bottom sheet: estándar y modal. La primera permite al usuario interactuar entre ambas regiones del contenido y la segunda solo permite acciones en la región de la bottom sheet cuando es visible.

Veamos cómo implementarlas en Jetpack Compose.


1. Proyecto Con Bottom Sheets

Lo primero que haremos es crear un nuevo proyecto en Android Studio que use Compose. Nómbralo «Bottom Sheets» y establece el paquete que desees (en mi caso será com.develou.bottomsheets).

Puedes revisar el código completo de este ejemplo por si tienes problemas siguiendo el tutorial, descargarlo desde el repositorio en GitHub:

Lo siguiente es crear un nuevo archivo llamado BottomSheetsScreen.kt. Este contendrá la interfaz gráfica principal, donde estudiaremos la invocación de las funciones de Compose para las bottom sheets:

import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun BottomSheetsScreen() {
}

@Preview
@Composable
private fun Preview() {
    BottomSheetsTheme {
        BottomSheetsScreen()
    }
}

Ahora abre MainActivity.kt y reemplaza el código por defecto de la plantilla por la invocación de BottomSheetsScreen() en setContent():

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BottomSheetsTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    BottomSheetsScreen()
                }
            }
        }
    }
}

Con esto realizado ya podemos comenzar a estudiar las bottom sheets estándar.


2. Bottom Sheet Estándar

Standard Bottom Sheet
Standard Bottom Sheet

Una bottom sheet estándar permite al usuario ver e interactuar en ambas regiones de la pantalla. La única diferencia con la modal, es que no tiene scrim, es decir, el contenido al que superpone no es oscurecido.

La imagen previa señala la anatomía de un diseño con bottom sheet en una pantalla. Tenemos el contenido principal y el contenido de la sheet.

Veamos como construir el ejemplo mencionado.

1. Crear Viewmodel

Antes de crear el viewmodel lo primero será crear el estado de nuestra pantalla.

El diseño del contenido principal está compuesto por una columna con dos secciones separadas por un divisor. Como ves, la primera sección muestra propiedades de la standard sheet y la segunda solo la última acción seleccionada en la modal sheet.

1. Así que, para darle vida a este componente, creamos la clase MainState y añadimos una propiedad por cada elemento:

const val DefaultPartiallyExpandedHeight = 56

data class MainState(
    val swipeGestureEnabled: Boolean,
    val sheetPartiallyExpandedHeight: Int,
    val sheetExpandedHeightPercentage: Int,
    val isModalSheetVisible: Boolean,
    val lastModalSheetAction: String
) {
    init {
        require(sheetExpandedHeightPercentage in 25..100)
        require(sheetPartiallyExpandedHeight >= DefaultPartiallyExpandedHeight)
    }

    companion object {
        val Default = MainState(
            swipeGestureEnabled = true,
            sheetPartiallyExpandedHeight = DefaultPartiallyExpandedHeight,
            sheetExpandedHeightPercentage = 25,
            isModalSheetVisible = false,
            lastModalSheetAction = "Ninguna"
        )
    }
}

El propósito de cada propiedad:

  • swipeGestureEnabled: Estado de la habilitación del gesto de swipe
  • sheetPartiallyExpandedHeight: Altura de la hoja al estar parcialmente expandida (debe ser mayor o igual a 56dp)
  • sheetExpandedHeightPercentage: Altura de la hoja al estar expandida. Su valor es un porcentaje de la pantalla ocupada entre [25%, 100%]
  • isModalSheetVisible: Determina la visibilidad de la modal sheet que incluiremos más adelante
  • lastModalSheetAction: Es la última acción seleccionada de la modal sheet

2. Con el estado creado, procedamos a crear la clase MainViewModel:

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

class MainViewModel : ViewModel() {
    private val _state = MutableStateFlow(MainState.Default)
    val state = _state.asStateFlow()

    fun updateSwipe(enable: Boolean) {
        _state.update { it.copy(swipeGestureEnabled = enable) }
    }

    fun updateModalVisibility(show: Boolean) {
        _state.update { it.copy(isModalSheetVisible = show) }
    }

    fun updateExpandedHeight(value: String) {
        _state.update { it.copy(sheetExpandedHeightPercentage = value.toInt()) }
    }

    fun updatePartiallyHeight(value: String) {
        _state.update { it.copy(sheetPartiallyExpandedHeight = value.toInt()) }
    }

    fun updateLastModalAction(value: String) {
        _state.update { it.copy(lastModalSheetAction = value) }
    }
}

Naturalmente añadimos una propiedad del estado mutable y otra de lectura. Además de métodos de actualización para cada propiedad del estado.

Recuerda que MutableStateFlow.update() te permite actualizar el valor del flujo. En nuestro caso la actualización se basa en crear copias del estado actual para cambiar el estado en la propiedad enfocada.

3. Debido a que necesitamos transmitir los eventos del usuario al viewmodel, crearemos una clase llamada MainEvents. Esta contiene una propiedad de tipo función por cada evento del usuario.

data class MainEvents(
    val onShowModalSheetClick: (Boolean) -> Unit,
    val setSwipe: (Boolean) -> Unit,
    val setPartiallyExpandedHeight: (String) -> Unit,
    val setExpandedHeight: (String) -> Unit,
    val onLastActionChange: (String) -> Unit
) {
    companion object {
        val NoActions = MainEvents({}, {}, {}, {}, {})
    }
}

4. Ahora vamos a BottomSheetsScreen() e instanciamos el viewmodel para leer su estado:

import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun BottomSheetsScreen(viewModel: MainViewModel = viewModel()) {
    val mainState by viewModel.state.collectAsState()

    StandardBottomSheet(
        mainState = mainState,
        events = sheetEvents(viewModel)
    )
}

@Composable
internal fun sheetEvents(viewModel: MainViewModel): MainEvents {
    return MainEvents(
        setSwipe = viewModel::updateSwipe,
        setExpandedHeight = viewModel::updateExpandedHeight,
        setPartiallyExpandedHeight = viewModel::updatePartiallyHeight,
        onShowModalSheetClick = viewModel::updateModalVisibility,
        onLastActionChange = viewModel::updateLastModalAction
    )
}

De esta forma ya quedamos listos para implementar a nuestra StandardBoottomSheet(). Componente que veremos a continuación.

2. Crear Standard Bottom Sheet

Para crear una Bottom Sheet Standard usamos la función BottomSheetScaffold(), la cual nos otorga espacios para el contenido de la sheet (sheetContent) y el del contenido principal (content).

@Composable
@ExperimentalMaterial3Api
fun BottomSheetScaffold(
    sheetContent: @Composable ColumnScope.() -> Unit,
    modifier: Modifier = Modifier,
    scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(),
    sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
    sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
    sheetShape: Shape = BottomSheetDefaults.ExpandedShape,
    sheetContainerColor: Color = BottomSheetDefaults.ContainerColor,
    sheetContentColor: Color = contentColorFor(sheetContainerColor),
    sheetTonalElevation: Dp = BottomSheetDefaults.Elevation,
    sheetShadowElevation: Dp = BottomSheetDefaults.Elevation,
    sheetDragHandle: (@Composable () -> Unit)? = { BottomSheetDefaults.DragHandle() },
    sheetSwipeEnabled: Boolean = true,
    topBar: (@Composable () -> Unit)? = null,
    snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
    containerColor: Color = MaterialTheme.colorScheme.surface,
    contentColor: Color = contentColorFor(containerColor),
    content: @Composable (PaddingValues) -> Unit
): Unit

Por supuesto contamos con una multitud de parámetros más, pero para este ejemplo solo usaremos a:

  • sheetContent: Contenido de la hoja
  • scaffoldState: Instancia BottomSheetScaffoldState que contiene al estado de la hoja y el snackbar host. Usaremos este valor para abrir la hoja programáticamente.
  • sheetPeekHeight: Altura de la hoja al estar parcialmente expandida. Usaremos este valor para mostrarlo en el contenido principal y modificarlo desde la hoja
  • sheetSwipeEnabled: Habilita el gesto de swipe para expandir la hoja con la manija de arrastre. Mostraremos y modificaremos este valor en la interfaz
  • topBar: La Top App Bar en el layout
  • content: Contenido principal

Con esto en mente:

Crea un nuevo archivo StandardBottomSheet.kt y añade una función componible con ese mismo nombre. En su interior invoca a BottomSheetScaffold() de la siguiente manera:

import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.coroutines.launch

@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun StandardBottomSheet(mainState: MainState, events: MainEvents) {
    val scaffoldState = rememberBottomSheetScaffoldState() // (1)
    val scope = rememberCoroutineScope()

    BottomSheetScaffold(// (2)
        scaffoldState = scaffoldState,
        sheetPeekHeight = mainState.partialHeightDp,
        sheetSwipeEnabled = mainState.swipeGestureEnabled,
        topBar = {
            TopBar(
                onShowModalSheetClick = { events.onShowModalSheetClick(true) },
                onExpandStandardSheet = {
                    scope.launch { scaffoldState.bottomSheetState.expand() }
                }
            )
        },
        sheetContent = { SheetContent(mainState, events) }
    ) { padding ->
        MainContent(
            padding = padding,
            sheetState = scaffoldState.bottomSheetState.currentValue.name,
            mainState = mainState
        )
    }
}

@Preview
@Composable
private fun Preview() {
    StandardBottomSheet(MainState.Default, MainEvents.NoActions)
}

Aclarando:

  1. Comenzamos declarando el estado inicial del scaffold con la utilidad rememberBottomSheetScaffoldState()
  2. Invocamos a BottomSheetScaffold()
    • scaffoldState: El argumento es el estado previo
    • sheetPeekHeight: Determina la altura de la hoja cuando está parcialmente expandida. Por supuesto aquí pasamos el valor de nuestro MainState
    • sheetSwipeEnabled: Habilita o deshabilita el gesto de swipe sobre la manija de arrastre
    • topBar: Top App Bar usada en el scaffold. En seguida veremos su creación
      • onShowModalSheetClick: Procesamos el click para mostrar la sheet modal pasando el evento que creamos para este propósito
      • onExpandStandardSheet: Expandimos la sheet estándar programáticamente con SheetState.expand(). Obviamente, el estado de la sheet estará en la propiedad BottomSheetScaffoldState.bottomSheetState.
    • sheetContent: Es el contenido de la hoja. Veremos su creación más tarde
    • content: Slot para el contenido principal que será superpuesto por la bottom sheet. Veremos su creación más tarde. Aunque es importante notar que la propiedad Sheetstate.currentValue.name nos da el valor del estado actual de la hoja.

3. Crear Top App Bar

Top App Bar Preview

La top bar estará compuesta por el título y dos botones de acción. El primero expande completamente a la standard bottom sheet y el segundo lo usaremos para mostrar a la modal bottom sheet más adelante.

Con la definición anterior, creamos un nuevo archivo llamado TopBar.kt con su función componible y aplicamos los parámetros:

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LooksOne
import androidx.compose.material.icons.filled.LooksTwo
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview

@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun TopBar(
    onShowModalSheetClick: () -> Unit,
    onExpandStandardSheet: () -> Unit
) {
    TopAppBar(
        title = { Text(stringResource(R.string.app_name)) },
        actions = {
            IconButton(onClick = onExpandStandardSheet) {
                Icon(
                    imageVector = Icons.Default.LooksOne,
                    contentDescription = "Mostrar sheet estándar"
                )
            }
            IconButton(onClick = onShowModalSheetClick) {
                Icon(
                    imageVector = Icons.Default.LooksTwo,
                    contentDescription = "Mostrar sheet modal"
                )
            }
        }
    )
}

@Preview
@Composable
private fun Preview() {
    TopBar({}, {})
}

4. Crear Contenido Principal

Contenido principal

2. Ahora creemos el archivo StandardMainContent.kt con la función @Composable:

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
internal fun MainContent(
    padding: PaddingValues,
    sheetState: String,
    mainState: MainState
) {
    Column(
        modifier = Modifier
            .padding(padding)
            .padding(16.dp)
    ) {

        Title("Standard Bottom Sheet")

        MediumSpace()

        Property("Estado", sheetState)

        SmallSpace()

        Property("Swipe habilitado", mainState.swipeGestureEnabledString)

        SmallSpace()

        Property("Altura al estar parcialmente expandida", mainState.partialHeightString)

        SmallSpace()

        Property("Altura al estar expandida", mainState.expandedHeightInMain)

        Divider(Modifier.padding(vertical = 16.dp))

        Title("Modal Bottom Sheet")

        MediumSpace()

        Property("Acción", mainState.lastModalSheetAction)
    }
}

@Composable
private fun Property(label: String, body: String) {
    Column(
        Modifier
            .height(56.dp)
            .padding(horizontal = 16.dp),
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            label,
            style = MaterialTheme.typography.labelMedium,
            color = MaterialTheme.colorScheme.primary
        )
        Spacer(modifier = Modifier.size(4.dp))
        Text(body, style = MaterialTheme.typography.bodyLarge)
    }
}


@Composable
private fun Title(text: String) {
    Text(text, style = MaterialTheme.typography.titleMedium)
}

@Preview
@Composable
private fun Preview() {
    MainContent(
        padding = PaddingValues(0.dp),
        sheetState = "Expanded",
        mainState = MainState.Default
    )
}

Como ves MainContent() recibe el padding del scaffold, el estado actual de la standard sheet y el estado principal.

3. Verás que en la definición de las propiedades existe la invocación de unas propiedades de MainState que no definimos antes.

Esto se debe a que son propiedades de extensión que formatean a las propiedades originales para la UI.

Defínelas como:

import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

val MainState.expandedHeightInMain get() = "$sheetExpandedHeightPercentage%"

val MainState.partialHeightString get() = sheetPartiallyExpandedHeight.toString() + "dp"

val MainState.swipeGestureEnabledString get() = if (swipeGestureEnabled) "Si" else "No"

val MainState.expandedHeightString get() = sheetExpandedHeightPercentage.toString()

val MainState.partialHeightDp get() = sheetPartiallyExpandedHeight.dp

fun MainState.expandedHeightDp(screenHeightDp: Int): Dp {
    return (screenHeightDp * sheetExpandedHeightPercentage / 100).dp
}

Cada elemento anterior transforma los estados en presentaciones de datos que usaremos directamente en la vista.

5. Crear Contenido De Sheet

Contenido de Sheet

El contenido de la Sheet es accionable a diferencia del principal. En ella podremos:

  • Habilitar el gesto de swipe
  • Seleccionar la altura cuando está parcialmente expandida (56dp,156dp, 256dp y 348dp)
  • Seleccionar la altura al estar expandida (25%, 50% y 100%)

Apoyados en lo anterior, pasamos a crear el componente de UI en el archivo StandardSheetContent.kt. La estructura que debemos seguir según la imagen del ejemplo es:

  • Columna
    • Texto para título
    • Espacio
    • Switch para swipe
    • Espacio
    • Exposed Dropdown Menu para altura en estado parcialmente expandido
    • Espacio
    • Exposed Dropdown Menu para altura en estado expandido

Al codificar tendrás:

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
internal fun SheetContent(
    state: MainState,
    events: MainEvents
) {
    val screenHeight = LocalConfiguration.current.screenHeightDp
    val sheetHeightDp = state.expandedHeightDp(screenHeight)

    Column(
        Modifier
            .fillMaxWidth()
            .height(sheetHeightDp)
            .padding(16.dp)
            .verticalScroll(rememberScrollState())
    ) {

        Text(
            text = "Bottom Sheet Estándar",
            style = MaterialTheme.typography.titleMedium,
            modifier = Modifier.align(Alignment.CenterHorizontally)
        )

        MediumSpace()

        LabelledSwitch(
            checked = state.swipeGestureEnabled,
            label = "sheetSwipeEnable",
            onCheckedChange = { events.setSwipe(it) }
        )

        MediumSpace()

        PartiallyExpandedHeightField(
            onOptionSelection = { peekHeight ->
                events.setPartiallyExpandedHeight(peekHeight)
            },
            optionSelected = state.partialHeightString
        )

        MediumSpace()

        ExpandedHeightField(
            sizeSelected = state.expandedHeightString,
            onSizeSelection = { size ->
                events.setExpandedHeight(size)
            }
        )
    }
}

@Composable
private fun ExpandedHeightField(
    sizeSelected: String,
    onSizeSelection: (String) -> Unit
) {
    ExposedDropdownMenu(
        ExposedDropdownMenuState(
            label = "Altura de Sheet (%)",
            options = listOf("25", "50", "100"),
            optionSelected = sizeSelected,
            onOptionSelection = onSizeSelection
        )
    )
}

@Composable
private fun PartiallyExpandedHeightField(
    onOptionSelection: (String) -> Unit,
    optionSelected: String
) {
    ExposedDropdownMenu(
        ExposedDropdownMenuState(
            label = "peekHeight (Dp)",
            options = listOf("56", "156", "256", "348"),
            optionSelected = optionSelected,
            onOptionSelection = onOptionSelection
        )
    )
}

@Preview
@Composable
private fun Preview() {
    SheetContent(
        state = MainState.Default.copy(sheetExpandedHeightPercentage = 50),
        events = MainEvents()
    )
}

En la implementación anterior se ve como cada componente vincula las acciones de MainEvents en los parámetros de acción de cada componente.

Finaliza ejecutando la App y prueba los diferentes estados:

Ejemplo de Standard Bottom Sheet

3. Modal Bottom Sheet

Ejemplo Modal Bottom Sheet
Modal Bottom Sheet

A diferencia de bottom sheet estándar, la bottom sheet modal deshabilita el contenido principal hasta su ocultamiento.

La usaremos como alternativa a menús y diálogos, en casos donde hay muchas opciones o el diseño de las mismas requiere espacio para personalizar, como añadir iconos y descripciones.

Construye este elemento con la función ModalBottomSheet():

@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheet(
    onDismissRequest: () -> Unit,
    modifier: Modifier = Modifier,
    sheetState: SheetState = rememberModalBottomSheetState(),
    sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
    shape: Shape = BottomSheetDefaults.ExpandedShape,
    containerColor: Color = BottomSheetDefaults.ContainerColor,
    contentColor: Color = contentColorFor(containerColor),
    tonalElevation: Dp = BottomSheetDefaults.Elevation,
    scrimColor: Color = BottomSheetDefaults.ScrimColor,
    dragHandle: (@Composable () -> Unit)? = { BottomSheetDefaults.DragHandle() },
    windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
    content: @Composable ColumnScope.() -> Unit
): Unit

La idea es tomar la función anterior y crear una modal bottom sheet con un menú que contenga las copiones:

  • Compartir
  • Obtener link
  • Eliminar

Manos a la obra.

1. Crear Modal Bottom Sheet

Lo primero será crear un nuevo archivo llamado ModalBottomSheet.kt y añadir su respectiva función componible.

import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModalBottomSheet(isVisible: Boolean, events: ModalEvents) {
    if (!isVisible) return // (1)

    val sheetState = rememberModalBottomSheetState() // (2)
    val scope = rememberCoroutineScope()
    val options = options(events) / (3)

    ModalBottomSheet(// (4)
        onDismissRequest = events.onMenuDismiss,
        sheetState = sheetState
    ) {
        Options( // (5)
            options = options,
            hideSheet = {
                scope.launch { sheetState.hide() }.invokeOnCompletion {
                    if (!sheetState.isVisible) {
                        events.onMenuDismiss()
                    }
                }
            }
        )
    }
}

Del código anterior:

  1. Si la sheet no es visible evitamos la composición
  2. Iniciamos el estado por defecto con rememberModalBottomSheetState()
  3. Construimos una lista de opciones
  4. Invocamos a ModalBottomSheet().
    • onDismissRequest: Acción a ejecutar cuando se oculte la hoja. En nuestro caso se delega a onMenuDismiss para cambiar el estado de visibilidad
    • sheetState: Pasamos el estado construido previamente
  5. Creamos un menú de opciones
    • options: Las opciones que mostraremos en pantalla
    • hideSheet: Lambda que ocultará la hoja cuando se seleccione un ítem del menú. Usamos la función hide() para cumplir este cometido y finalizamos cambiando el estado de visibilidad con onMenuDismiss.

Los eventos asociados al menú los materialicemos en la clase ModalEvents:

data class ModalEvents(
    val onMenuDismiss: () -> Unit,
    val onShareClick: () -> Unit,
    val onGetLinkClick: () -> Unit,
    val onDeleteClick: () -> Unit
) {
    companion object {
        val NoActions = ModalEvents({}, {}, {}, {})
    }
}

2. Crear Menú Para Sheet

El menú contiene tres opciones como vimos al inicio. Debido a que el parámetro de contenido de ModalBottomSheet() es una lambda con parámetro ColumnScope, solo queda producir los tres elementos para que sean ubicados verticalmente.

Esto nos lleva a crear un archivo nuevo con el nombre de MenuOptions.kt, donde añadiremos los siguientes componentes:

  • El estado para las opciones (icono, etiqueta y acción de click)
  • Componente para el conjunto de opciones
  • Componente para opción individual
  • La lista de las tres opciones

Aplicando:

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Link
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import com.develou.bottomsheets.ui.viewmodel.ModalEvents

data class ImageMenuOptionState(
    val icon: ImageVector,
    val label: String,
    val onClick: () -> Unit
)

@Composable
internal fun Options(
    options: List<ImageMenuOptionState>,
    hideSheet: () -> Unit
) {
    options.forEach { option ->
        Option(state = option, hideSheet = hideSheet)
    }
}

@Composable
private fun Option(state: ImageMenuOptionState, hideSheet: () -> Unit) {
    ListItem(
        modifier = Modifier.clickable {
            state.onClick()
            hideSheet()
        },
        leadingContent = {
            Icon(
                imageVector = state.icon,
                contentDescription = state.label
            )
        },
        headlineContent = {
            Text(state.label)
        }
    )
}


@Composable
fun options(state: ModalEvents): List<ImageMenuOptionState> {
    return listOf(
        ImageMenuOptionState(
            icon = Icons.Outlined.Share,
            label = "Compartir",
            onClick = state.onShareClick
        ),
        ImageMenuOptionState(
            icon = Icons.Outlined.Link,
            label = "Obtener link",
            onClick = state.onGetLinkClick
        ),
        ImageMenuOptionState(
            icon = Icons.Outlined.Delete,
            label = "Eliminar",
            onClick = state.onDeleteClick
        )
    )
}

@Preview
@Composable
private fun Preview() {
    Column{
        Options(options = options(state = ModalEvents.NoActions)) { }
    }
}

Si abres el panel de previsualización verás el menú con las opciones:

3. Mostrar Modal Bottom Sheet

Finalizamos yendo al componente principal para invocar a ModalBottomSheet():

@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun StandardBottomSheet(mainState: MainState, events: MainEvents) {
    //..

    BottomSheetScaffold(
        //..
    ) { padding ->
        //..

        val setModalAction: (String) -> Unit = events.onLastActionChange

        ModalBottomSheet(
            isVisible = mainState.isModalSheetVisible,
            events = ModalEvents(
                onMenuDismiss = { events.onShowModalSheetClick(false) },
                onShareClick = { setModalAction("Compartir") },
                onGetLinkClick = { setModalAction("Obtener link") },
                onDeleteClick = { setModalAction("Eliminar") }
            )
        )
    }
}

Fíjate en que isVisible es determinado por la propiedad MainState.isModalSheetVisible.

Y en el caso de los eventos de las opciones, declaramos previamente la lambda setModalAction, la cual invoca a MainEvents.onLastActionChange.

De esta forma cambiamos el valor del estado de la última acción seleccionada con el nombre de cada opción.

Si ejecutas la App y presionas el segundo botón de acción en la Top App Bar verás la aparición de la Modal Bottom Sheet:

Modal Bottom Sheet 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!