Bottom App Bar En Android

En este tutorial aprenderás sobre el uso del componente Bottom App Bar en Jetpack Compose con el objetivo de crear una barra inferior que provea acceso a navegación y acciones principales en las pantallas de tu app Android.

Anatomía de Bottom Bar

La imagen anterior muestra la apariencia de una Bottom App Bar y la ubicación de sus elementos de composición, donde:

  1. Es el contenedor
  2. Un Floating Action Button
  3. Un icono de acción
  4. El ícono de acción que despliega un menú por si existen más de cuatro iconos de acción

Conociendo lo anterior, estudiemos la función componible que Jetpack Compose nos provee para construir la Bottom App Bar.

Puedes revisar el código final del ejemplo que veremos en este tutorial, revisando el siguiente repositorio:


Crear BottomAppBar En Compose

Lo primero será crear un nuevo archivo Kotlin llamado MainScreen.kt para construir la interfaz donde usaremos la Bottom App Bar.

Iniciemos creando una barra inferior con cuatro botones de acción y un FAB en su esquina derecha:

@Preview
@Composable
private fun MainBottomBar() {
    BottomAppBar(
        actions = {
            IconButton(onClick = { }) {
                Icon(
                    imageVector = Icons.Outlined.MoreVert,
                    contentDescription = "Ver más"
                )
            }
            IconButton(onClick = { }) {
                Icon(
                    imageVector = Icons.Outlined.Delete,
                    contentDescription = "Eliminar"
                )
            }
            IconButton(onClick = { }) {
                Icon(
                    imageVector = Icons.Outlined.LocationOn,
                    contentDescription = "Localizar"
                )
            }
            IconButton(onClick = { }) {
                Icon(
                    imageVector = Icons.Default.Search,
                    contentDescription = "Buscar"
                )
            }
        },
        floatingActionButton = {
            FloatingActionButton(onClick = { }) {
                Icon(
                    imageVector = Icons.Default.Add,
                    contentDescription = "Agregar"
                )
            }
        }
    )
}

Si previsualizas la función componible, verás como resultado la siguiente imagen:

¿Cómo funciona el código anterior?

Es simplemente la invocación de la función BottomAppBar() de Material Design 3 de Compose. Esta recibe los siguientes parámetros:

  • actions: Lambda componible que recibe la declaración de los iconos de acción de la barra. Su recibidor es de tipo Row, por lo que cada elemento que añadas se acomoda horizontalmente
  • floatingActionButton: Función componible opcional para añadir un FAB en la parte derecha de la barra

Los dos anteriores bastan para un funcionamiento completo, pero si deseas más personalización, podrás encontrar otros parámetros disponibles:

  • containerColor: Valor de tipo Color para determinar el color del background
  • contentColor: Color para el contenido de la barra (texto e iconos)
  • tonalElevation: Valor en Dp para determinar la oscuridad resultante al proyectar una capa translucida sobre la barra
  • contentPadding: Espacio aplicado al contenido de la barra
  • windowInsets: Distancia a los bordes de la ventana que serán respetadas a la hora de dibujar la bottom app bar

Cambiar Background

Comprobemos el uso de containerColor para modificar el color del ejemplo anterior por un tono de azul claro.

@Preview
@Composable
private fun BottomBarPreview() {
    BottomAppBar(
        ...,
        containerColor = Color(0xffbbdefb)
    )
}

El resultado se verá inmediatamente en la previsualización:

Cambiar Color De Contenido

Ahora veamos cómo se ven afectados los iconos de acciones al momento de pasar un tono rojo en el parámetro contentColor.

@Preview
@Composable
private fun BottomBarPreview() {
    BottomAppBar(
        ...,
        contentColor = Color(0xffb71c1c)
    )
}

El resultado será el cambio de los cuatro iconos a rojo, al igual que sus efectos Ripple:


Crear Componente Reusable

En el caso de que tu proyecto Android necesite múltiples Bottom App Bars, la invocación de la función componible se volverá tediosa y ocupará espacio que dificultará entender el propósito del layout en creación.

Para evitar esto y reutilizar de forma sencilla la barra inferior, aplicamos la practica State Hoisting con el fin de aislar el estado del componente, permitiéndonos simplificar la invocación.

¿Cómo lo conseguimos?

Presta atención.

1. Crear Estado Para Acciones

Lo primero es definir el estado del componente en una clase de datos. Necesitamos que contenga como propiedades el estado para la lista de acciones y el estado del FAB.

Con estado me refiero a los valores necesarios para tratar al elemento:

  • Icono,
  • Descripción
  • Lambda ejecutada al hacerle clic

A partir de lo anterior, creamos la clase Action con esta definición:

/**
 * Representa el estado de las acciones en la Bottom App Bar
 */
class Action private constructor(
    val icon: Any,
    val description: String,
    val action: () -> Unit
) {
    companion object {
        fun vectorAction(
            imageVector: ImageVector,
            description: String,
            action: () -> Unit
        ): Action {
            return Action(imageVector, description, action)
        }

        fun painterAction(
            painter: Painter,
            description: String,
            action: () -> Unit
        ): Action {
            return Action(painter, description, action)
        }
    }
}

Como ves, los métodos vectorAction() y painterAction() permiten construir instancias de la clase Action según el tipo del icono.

2. Crear Estado Para Bottom Bar

Lo siguiente es representar el estado general añadiendo una nueva clase de datos llamada BottomBarState.

/**
 * Representa el estado de una Bottom Bar
 */
data class BottomBarState(
    val actions: List<Action>,
    val floatingAction: Action? = null
)

Donde actions es el estado para las acciones principales y floatingAction es el estado para el FAB.

3. Crear Componente Base

Por último, finalizamos creando el componente que servirá como plantilla de creación. Las tareas que debe realizar esta nueva función componible son:

  • Tomar la lista de acciones y construir un IconButton() por cada una
  • Si la lista tiene más de cuatro acciones, entonces se añade una acción Más, la cual despliega un menú con las menos relevantes
  • Toma la acción del FAB y la aplica sobre la barra
  • Lanzamos un error si la cantidad de acciones es menor a dos

Dicho lo anterior, materialicemos las instrucciones en un nuevo archivo llamado BaseBottomBar.kt:

@Composable
fun BaseBottomBar(state: BottomBarState) {
    BottomAppBar(
        actions = {
            Actions(state.actions)
        },
        floatingActionButton = {
            FAB(state.floatingAction)
        }
    )
}

La función Actions() es la encargada de la creación de los botones de acción:

@Composable
private fun Actions(actions: List<Action>) {
    if (actions.size < 2)
        error("Una Bottom Bar requiere mínimo dos acciones")

    if (actions.size > 4) {
        ActionButtonsWithOverflow(actions)
    } else {
        ActionButtons(actions)
    }
}

Cuando la cantidad de acciones es mayor a cuatro, entonces invocamos a ActionButtonsWithOverflow(). La cual crea el botón de «ver más», los botones de acción y al menú de oveflow:

@Composable
private fun ActionButtonsWithOverflow(actions: List<Action>) {
    val (expanded, setExpanded) = remember {
        mutableStateOf(false)
    }

    OverflowAction {
        setExpanded(true)
    }

    ActionButtons(actions.take(3))

    OverflowMenu(
        expanded = expanded,
        onExpandedChange = setExpanded,
        actions = actions.takeLast(actions.size - 3),
    )
}

Fíjate que al invocar ActionButtons() pasamos solo los primeros tres elementos con take(3). Y en OverflowMenu() pasamos las últimas acciones con takeLast(actions.size-3).

Por otro lado, ActionButtons() es solo un bucle forEach() que genera elementos IconButton a través de la función Action(). La cual también es usada para fabricar a OverflowAction():

@Composable
private fun OverflowAction(onClick: () -> Unit) {
    Action(
        action = Action.vectorAction(
            imageVector = Icons.Default.MoreVert,
            description = "Ver más",
            action = onClick
        )
    )
}

@Composable
private fun ActionButtons(actions: List<Action>) {
    actions.forEach { action ->
        Action(action)
    }
}

@Composable
private fun Action(action: Action) {
    IconButton(onClick = action.action) {
        when (action.icon) {
            is ImageVector -> Icon(
                imageVector = action.icon,
                contentDescription = action.description
            )

            is Painter -> Icon(
                painter = action.icon,
                contentDescription = action.description
            )
        }
    }
}

4. Usar BaseBottomBar

Con la función preparada podemos recrear el ejemplo inicial pasando una instancia de la nueva clase BottomBarState:

@Preview
@Composable
private fun MainBottomBar() {
    BaseBottomBar(
        state = BottomBarState(
            actions = listOf(
                Action.vectorAction(
                    imageVector = Icons.Outlined.Search,
                    description = "Buscar",
                    action = {}),
                Action.vectorAction(
                    imageVector = Icons.Outlined.LocationOn,
                    description = "Localizar",
                    action = {}),
                Action.vectorAction(
                    imageVector = Icons.Outlined.Delete,
                    description = "Eliminar",
                    action = {}),
                Action.vectorAction(
                    imageVector = Icons.Outlined.Settings,
                    description = "Ajustes",
                    action = {}),
                Action.vectorAction(
                    imageVector = Icons.Outlined.Info,
                    description = "Ayuda",
                    action = {})
            ),
            floatingAction = Action.vectorAction(
                imageVector = Icons.Default.Add,
                description = "Añadir",
                action = {})
        )
    )
}

Sin embargo, sería más interesante si combinamos la barra inferior en un layout con contenido y ejecutamos acciones al clickear cada botón. Así que pasemos a ver como combinarla con el Scaffold.


BottomAppBar Y Scaffold

Considerando que ya disponemos de una Bottom Bar base, solo queda integrarla con el resto del contenido del layout para que sea posicionada en la parte inferior.

Al igual que para la TopAppBar, el layout Scaffold provee un espacio para que incrustemos nuestra barra inferior. Se trata del parámetro bottomBar de tipo función componible.

Por ejemplo:

Crea una nueva función llamada HomeScreen() en HomeScreen.kt y añade un Scaffold de la siguiente manera:

@Composable
fun MainScreen(modifier: Modifier = Modifier) {

    Scaffold(
        bottomBar = {
            BottomBarPreview()
        }
    ) {
        Box(
            Modifier
                .padding(it)
                .fillMaxSize()
        ) {

        }
    }
}

Luego vincula a HomeScreen() con la actividad principal a través de setContent():

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BottomAppBarTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    MainScreen() // <= Nuestro ejemplo
                }
            }
        }
    }
}

Al ejecutar el aplicativo Android verás el siguiente resultado:

Scaffold Con Bottom Bar

Pero para completar la bottom bar de nuestra pantalla principal necesitamos pasarle las acciones del usuario para cada botón.

Por razones de simplicidad será una sola función: Mostrar una Snackbar con la descripción de la acción tocada.

Para ello agregamos a la función MainBottomBar() un parámetro tipo función llamado userActions y lo invocamos en cada propiedad Action.action:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainBottomBar(userActions: (String) -> Unit) {
    BaseBottomBar(
        state = BottomBarState(
            actions = listOf(
                Action.vectorAction(
                    imageVector = Icons.Outlined.Search,
                    description = "Buscar",
                    action = { userActions("Buscar") }),
                Action.vectorAction(
                    imageVector = Icons.Outlined.LocationOn,
                    description = "Localizar",
                    action = { userActions("Localizar") }),
                Action.vectorAction(
                    imageVector = Icons.Outlined.Delete,
                    description = "Eliminar",
                    action = { userActions("Eliminar") }),
                Action.vectorAction(
                    imageVector = Icons.Outlined.Settings,
                    description = "Ajustes",
                    action = { userActions("Ajustes") }),
                Action.vectorAction(
                    imageVector = Icons.Outlined.Info,
                    description = "Ayuda",
                    action = { userActions("Ayuda") })
            ),
            floatingAction = Action.vectorAction(
                imageVector = Icons.Default.Add,
                description = "Añadir",
                action = { userActions("Añadir") })
        )
    )
}

Ahora vamos a MainScreen() y añadimos un SnackbarHost para gestionar el estado de las Snackbars. La idea es ejecutar el método de lanzamiento en la lambda de MainBottomBar():

@Composable
fun MainScreen(modifier: Modifier = Modifier) {
    val snackbarHostState = remember { SnackbarHostState() } // (1)
    val scope = rememberCoroutineScope() // (1)

    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) }, // (2)
        bottomBar = {
            MainBottomBar(
                userActions = { actionClicked -> // (3)
                    scope.launch { // (4)
                        snackbarHostState.showSnackbar(message = actionClicked)
                    }
                }
            )
        }
    ) {
        Box(
            Modifier
                .padding(it)
                .fillMaxSize()
        ) {

        }
    }
}

En el código anterior aplicamos las siguiente modificaciones:

  1. Creamos el estado para el snack bar host y el scope de la corrutina que mostrará las snackbars
  2. Vinculamos al Scaffold con el host
  3. Pasamos a MainBottomBar() una lambda cuyo parámetro es el nombre de la acción clickeada
  4. Invocamos al método showSnackbar() para mostrar una snackbar con el nombre del elemento tocado

Como resultado al invocar la App podrás ver las Snackbars apareciendo al hacer click en cada action button:


Bottom App Bar Colapsable

Si deseas que la Bottom App Bar se oculte y muestre cada que haya un gesto de scroll vertical, entonces usa la firma que contiene al parámetro scrollBehavior (aún experimental).

Esto requiere que modifiquemos la firma de BaseBottomBar() y le pasemos el nuevo parámetro:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BaseBottomBar(
    state: BottomBarState,
    scrollBehavior: BottomAppBarScrollBehavior? = null // <=
) {
    BottomAppBar(
        actions = {
            Actions(state.actions)
        },
        floatingActionButton = {
            FAB(state.floatingAction)
        },
        scrollBehavior = scrollBehavior // <=
    )
}

Ahora, veamos cómo integrar este cambio en MainScreen():

  1. Declaras una instancia del tipo BottomAppBarScrollBehavior a través del método constructor BottomAppBarDefaults.exitAlwasScrollBehavior()
  2. Relaciona el comportamiento previamente declarado con el el Scaffold a través del modificador nestedScroll() y la propiedad nestedScrollConnection
  3. Pasas el comportamiento al parámetro scrollBehavior en MainBottomBar()
  4. Mueves el FAB de la Bottom Bar al parámetro floatingActionButton del Scaffold
  5. Usas el valor FabPosition.EndOverlay en floatingActionButtonPosition del Scaffold para reemplazar el FAB que no aplicaremos en la bottom app bar

Aplicando todos los pasos anteriores tendrás:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(modifier: Modifier = Modifier) {
    val scrollBehavior = BottomAppBarDefaults.exitAlwaysScrollBehavior() // (1)
    val snackbarHostState = remember { SnackbarHostState() }
    val scope = rememberCoroutineScope()

    val userActions: (String) -> Unit = { actionClicked ->
        scope.launch {
            snackbarHostState.showSnackbar(message = actionClicked)
        }
    }
    Scaffold(
        modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) // (2),
        snackbarHost = { SnackbarHost(snackbarHostState) },
        bottomBar = {
            MainBottomBar(
                userActions = userActions,
                scrollBehavior = scrollBehavior // (3)
            )
        },
        floatingActionButton = { // (4)
            FAB(
                floatingAction = Action.vectorAction(
                    imageVector = Icons.Default.Add,
                    description = "Añadir",
                    action = { userActions("Añadir") })
            )
        },
        floatingActionButtonPosition = FabPosition.EndOverlay // (5)
    ) {
        Box(
            Modifier
                .padding(it)
                .fillMaxSize()
        ) {

        }
    }
}

Si ejecutas la App no habrá movimiento de la barra debido a que no está ocurriendo una cadena de eventos de scroll. Claramente, esto es por la inexistencia de un contenido scrolleable.

Así que creemos una función componible que construya una lista genérica llamada ExampleList(), para cumplir el objetivo:

@Composable
private fun ExampleList(padding: PaddingValues) {
    LazyColumn(
        contentPadding = padding,
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(20) {
            ExampleItem()
        }
    }
}

@Composable
private fun ExampleItem() {
    ListItem(
        headlineContent = {
            Text("Encabezado")
        },
        supportingContent = {
            Text("Texto de apoyo")
        },
        leadingContent = {
            Box(
                modifier = Modifier
                    .size(48.dp)
                    .background(
                        color = Color.LightGray,
                        shape = CircleShape
                    )
            )
        },
        trailingContent = {
            Icon(
                imageVector = Icons.Outlined.Info,
                contentDescription = null
            )
        }
    )
}

Y luego la invocamos en el parámetro content del Scaffold:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(modifier: Modifier = Modifier) {
    ...
    Scaffold(
        ...
    ) {
        ExampleList(padding = it)
    }
}

Ahora sí, ejecuta el aplicativo Android y comienza a scrollear verticalmente la lista. Verás como la barra entra y sale de la escena:

Ú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!