Badges En Android

Las Badges o insignias, son componentes visuales ubicados en iconos o ítems de navegación, con el fin de comunicar notificaciones, contadores o información sobre el estado.

Estas se superponen en los iconos en un contenedor con forma redondeada como se muestra en la siguiente imagen.

Badges

En este tutorial verás como emplear las funciones Badge() y BadgeBox() de Jetpack Compose para crear insignias en tus aplicaciones Android.

Puedes ver el código completo en el siguiente repositorio de GitHub:


Badges En Android Studio

Antes de comenzar a practicar con las badges, crea un proyecto nuevo de Android Studio de tipo Compose. Nómbralo «Badges» y usa el paquete que prefieras (en mi caso es com.develou.badges).

Luego añade un archivo nuevo llamado BadgesScreen.kt. Este tendrá una función BadgesScreen(), la cual representa la pantalla principal que veremos a lo largo del tutorial. Por el momento está en blanco.

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

@Composable
fun BadgesScreen() {

}

@Preview
@Composable
fun Preview() {
    BadgesTheme {
        BadgesScreen()
    }
}

En seguida modifica la actividad principal que viene por defecto para que invoque a BadgesScreen() 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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BadgesTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    BadgesScreen() // <= El cambio del que hablamos
                }
            }
        }
    }
}

Crear Badge En Compose

Con lo anterior completado, ahora si podemos empezar.

Para crear una Badge usaremos la función Badge(), la cual genera la forma estándar de globo rojo en la interfaz.

Por ejemplo:

Creemos en BadgedScreen() una insignia indicando que hay diez notificaciones nuevas:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BadgesScreen() {
    Badge {
        Text("10")
    }
}

La previsualización mostrará el siguiente resultado:

Badge en Android

Observemos los parámetros de Badge():

  • containerColor: color aplicado al fondo de la insignia
  • contentColor: color del contenido de la insignia
  • content: contenido que será renderizado en al interior de la insignia

Como vemos, es posible cambiar el background y el color del texto de la badge. Sin embargo, en los lineamientos del Material Design 3 se nos recomienda evitar cambiar estos colores por cuestiones de accesibilidad. Por lo que no entraremos en detalle en estas características.


Badge Con Icono

Por si sola la badge no muestra su potencial, necesitamos que acompañe a otro elemento para asociar un cambio de contenido u estado al usuario.

El caso más común es añadir la insignia a un icono de navegación. Para ello usamos la función BadgedBox(), que sobrepone el contenido de la badge al otro elemento.

Por ejemplo:

Incrustemos la badge anterior en un icono fotos:

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Photo
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BadgesScreen() {
    BadgedBox( // (1)
        badge = { // (2)
            Badge(Modifier.offset(x = (-8).dp, y = 8.dp)) { // (3)
                Text(
                    text = "10"
                )
            }
        }) {
        Icon(// (4)
            imageVector = Icons.Default.Photo,
            contentDescription = "Fotos"
        )
    }
}

@Preview
@Composable
fun Preview() {
    BadgesTheme {
        Box(// (5)
            modifier = Modifier.size(64.dp),
            contentAlignment = Alignment.Center
        ) {
            BadgesScreen()
        }
    }
}

Del código anterior:

  1. Invocamos a BadgedBox()
  2. El parámetro badge recibe al componente Badge() anterior
  3. Aplicamos un desplazamiento interno de 8 puntos para buscar una apariencia fiel a la guía de M3
  4. El último parámetro content representa al componente en que superpondremos la insignia, en este caso un icono.
  5. Añadimos una Box() a la previsualización para poder ver la badge sobre el icono

Este código creará la siguiente insignia en pantalla:

BadgedBox en Compose

Estado De Badge

Aunque el componente Badge es sencillo en construcción, es posible entregar su estado interno al control del padre contenedor.

Esto se debe a que las insignias se renderizan con respecto a estas características:

  • Visibilidad: Oculta o visible
  • Tamaño: Small o large
  • Texto

A partir de los requisitos anterior, podemos crear las siguientes clases.

Small Badge

En el caso de las badges pequeñas, haremos lo siguiente:

1. Crea un nuevo paquete dentro de ui llamado components

2. En su interior añade nuevo archivo llamado SmallBadgedIcon.kt y añade la clase SmallBadgeIconState con una propiedad para visibilidad, icono y descripción:

data class SmallBadgedIconState(
    val isVisible: Boolean,
    val icon: ImageVector,
    val description: String
)

3. Luego crea una función @Composable con el mismo nombre del archivo, SmallBadgedIcon(). Su parámetro será del tipo SmallBadgeState:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SmallBadgedIcon(state: SmallBadgedIconState) {
    BadgedBox(
        badge = {
            if (state.isVisible) {
                Badge(modifier = Modifier.offset(x = (-3).dp, y = 3.dp))
            }
        }) {
        Icon(
            imageVector = state.icon,
            contentDescription = state.description
        )
    }
}

Como se puede apreciar, tomamos las propiedades del estado y las asignamos a las funciones componibles usadas.

4. Finaliza creando una función Preview() para observar la diferencia al usar true o false en la visibilidad:

@Composable
@Preview
private fun Preview() {
    Row(
        modifier = Modifier
            .size(64.dp)
            .padding(4.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.SpaceBetween
    ) {

        SmallBadgedIcon(
            state = SmallBadgedIconState(
                isVisible = true,
                icon = Icons.Default.Folder,
                description = "Folders"
            )
        )
        SmallBadgedIcon(
            state = SmallBadgedIconState(
                isVisible = false,
                icon = Icons.Default.Folder,
                description = "Folders"
            )
        )
    }
}

La imagen resultante será:

Small badge Material Design 3

Large Badge

En el caso de la large badge, haremos lo siguiente:

1. Crea un nuevo archivo Kotlin llamado LargeBadgedIcon.kt y añade una clase de datos llamada LargeBadgedIconState con parámetros para texto, icono, descripción y visibilidad:

data class LargeBadgeState(
    val number: String,
    val icon: ImageVector,
    val description: String
) {
    init {
        require(number.length <= 4)
    }

    val isVisible = number.isNotBlank()
}

Las badges grandes no pueden tener contadores con más de cuatro caracteres, por lo que especificamos dicha invariante en init().

2. Ahora crea la función LargeBadgedIcon(), añade un parámetro para el estado y asigna las propiedades a las funciones componibles:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LargeBadgedIcon(state: LargeBadgeState) {
    BadgedBox(
        badge = {
            if (state.isVisible) {
                Badge(Modifier.offset(x = (-8).dp, y = 8.dp)) {
                    Text(
                        text = state.number,
                        modifier = Modifier.semantics {
                            contentDescription = "${state.number} notificaciones nuevas"
                        }
                    )
                }
            }
        }) {
        Icon(
            imageVector = state.icon,
            contentDescription = state.description
        )
    }
}

3. Finaliza previsualizando una serie de ejemplos para probar el modelo que acabamos de crear:

@Preview
@Composable
private fun Preview() {
    Box(
        modifier = Modifier.size(64.dp),
        contentAlignment = Alignment.Center
    ) {
        LargeBadgedIcon(
            LargeBadgeState(
                number = "9+",
                icon = Icons.Default.LibraryBooks,
                description = ""
            )
        )
    }
}

El resultado será:

Large Badge en Material Design 3

Badges En Navigation Bar

Uno de los usos más populares de las bagdes es sobre los iconos de navegación en la Navigation Bar (todo).

Con el fin de ilustrar su uso, repliquemos la barra de navegación de la documentación de las Badges, donde existen cuatro destinos:

  • Mails (large)
  • Chat (large)
  • Rooms (small)
  • Meet (large)

Codifiquemos:

1. Crea un nuevo archivo en components llamado MainNavigationBar.kt y añade en su interior una clase de datos para el estado de los ítems de navegación:

data class NavigationBarIconState(
    val label: String,
    val icon: ImageVector,
    val badgeType: BadgeType
)

sealed class BadgeType {
    data object None : BadgeType()
    data class Small(val active: Boolean) : BadgeType()
    data class Large(val counter: String) : BadgeType()
}

Los iconos de navegación están representados por NavigationBarIconState. Esta tiene propiedades para la etiqueta, el icono y el tipo de badge que usa.

BadgeType es una sealed class que confina los tipos existentes: None, Small y Large.

2. Ve a BadgesScreen.kt y crea una nueva clase de estado para la pantalla principal. Esta contendrá los textos de las notificaciones que usamos en las badges:

data class MainState(
    val emailsNotifications: String,
    val chatNotifications: String,
    val roomsIsActive: Boolean,
    val meetNotifications: String
) {
    companion object {
        val Initial = MainState(
            emailsNotifications = "",
            chatNotifications = "",
            roomsIsActive = false,
            meetNotifications = ""
        )
    }
}

3. Acto seguido, creamos la función MainNavigationBar(), la cual recibe el estado principal, una lambda de click en ítem de navegación y el índice del ítem seleccionado. En su interior definimos el estado de los cuatro ítem de navegación y luego invocamos a NavigationBar():

@Composable
fun MainNavigationBar(
    state: MainState,
    onItemClick: (Int) -> Unit,
    selectedItem: Int
) {
    val items = listOf(
        NavigationBarIconState(
            label = "Emails",
            icon = Icons.Default.Email,
            badgeType = BadgeType.Large(state.emailsNotifications)
        ),
        NavigationBarIconState(
            label = "Chat",
            icon = Icons.Default.ChatBubble,
            badgeType = BadgeType.Large(state.chatNotifications)
        ),
        NavigationBarIconState(
            label = "Rooms",
            icon = Icons.Default.Groups,
            badgeType = BadgeType.Small(state.roomsIsActive)
        ),
        NavigationBarIconState(
            label = "Meet",
            icon = Icons.Default.Videocam,
            badgeType = BadgeType.Large(state.meetNotifications)
        )
    )
    
    NavigationBar {
        NavigationIcons(items, selectedItem, onItemClick)
    }
}

Donde NavigationIcons() es una función compuesta de la siguiente forma:

@Composable
private fun RowScope.NavigationIcons( // (1)
    items: List<NavigationBarIconState>,
    selectedItem: Int,
    onItemClick: (Int) -> Unit
) {
    items.forEachIndexed { index, item ->
        NavigationItem(
            item = item,
            isSelected = index == selectedItem,
            onItemClick = { onItemClick(index) }
        )
    }
}

@Composable
private fun RowScope.NavigationItem(// (2)
    item: NavigationBarIconState,
    isSelected: Boolean,
    onItemClick: () -> Unit
) {
    NavigationBarItem(
        icon = {
            ItemIcon(item)
        },
        selected = isSelected,
        onClick = onItemClick,
        label = {
            Text(item.label)
        }
    )
}

@Composable
private fun ItemIcon(// (3)
    item: NavigationBarIconState,
) {
    when (item.badgeType) {
        BadgeType.None -> Icon(
            imageVector = item.icon,
            contentDescription = item.label
        )

        is BadgeType.Small -> SmallBadgedIcon(
            state = SmallBadgedIconState(
                isVisible = item.badgeType.active,
                icon = item.icon,
                description = item.label
            )
        )

        is BadgeType.Large -> LargeBadgedIcon(
            LargeBadgeState(
                number = item.badgeType.counter,
                icon = item.icon,
                description = item.label
            )
        )
    }
}

Las responsabilidades de cada nivel de código son:

  1. NavigationIcons(): Representa el bucle que itera para crear ítems de la barra de navegación
  2. Navigationitem(): Invoca a NavigationBarItem y vincula los estados
  3. ItemIcon(): Invoca las funciones de badges que creamos anteriormente según el tipo de badge para crear el icono de navegación

4. Añade una previsualización de la barra de navegación:

@Preview
@Composable
private fun Preview() {
    MainNavigationBar(
        state = MainState(
            emailsNotifications = "999+",
            chatNotifications = "10",
            roomsIsActive = true,
            meetNotifications = "3"
        ),
        onItemClick = {},
        selectedItem = 0
    )
}

Verás que nuestra barra tiene el siguiente aspecto:

Navigation Bar con Badges

5. Abre MainScreen.kt y añade un Scaffold en BadgesScreen(). Invoca a MainNavigationBar() en su parámetro bottomBar.

private const val INITIAL_SELECTION = 0

@Composable
fun BadgesScreen(viewModel: MainViewModel = viewModel()) { // (1)
    val state by viewModel.state.collectAsState() // (2)
    var selectedItem by remember { mutableIntStateOf(INITIAL_SELECTION) } // (3)

    Scaffold(
        bottomBar = {
            MainNavigationBar(// (4)
                state = state,
                onItemClick = { index -> selectedItem = index },
                selectedItem = selectedItem
            )
        },
    ) { padding ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(padding),
            contentAlignment = Alignment.Center
        ) {
            BadgesContent(selectedItem, state) // (5)
        }
    }
}

@Composable
private fun BadgesContent(selectedItem: Int, state: MainState) {
    val content = when (selectedItem) {
        0 -> "Correos sin leer: ${state.emailsNotifications}"
        1 -> "Mensajes sin leer: ${state.chatNotifications}"
        2 -> "¿Grupos activos?: ${if (state.roomsIsActive) "Si" else "No"}"
        3 -> "Videollamadas perdidas: ${state.meetNotifications}"
        else -> "..."
    }
    Text(content)
}

¿De qué va el código anterior?:

  1. El parámetro de tipo MainViewModel es el componente donde generaremos la actualización del estado de las notificaciones. Más adelante veremos su creación
  2. Recolectamos continuamente los valores del estado del view model para recomponer la UI
  3. El estado selectedItem sostiene el índice del icono de la navigation bar que está actualmente seleccionado
  4. Invocamos a nuestro componente MainNavigationBar(). Como ves pasamos el valor actual del estado en el view model; de segundo pasamos una lambda que actualiza el estado de selección actual cuando hay click en el ítem de la barra; y el tercer parámetro es el valor actual del ítem seleccionado
  5. Como contenido del Scaffold tenemos a la función BadgesContent() que muestra un texto centrado con el mensaje del valor actual de las notificaciones según el ítem seleccionado.

6. Crea el nuevo paquete ui/viewmodel y añade la clase MainViewModel:

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlin.random.Random

class MainViewModel : ViewModel() {
    val state = flow {
        var emails = 0
        var chat = 0
        var rooms: Boolean
        var meet = 0

        while (true) {
            delayBeforeChangeNotifications()

            emails += randomIncrement()
            chat += randomIncrement()
            rooms = Random.nextBoolean()
            meet += randomIncrement()

            emit(
                MainState(
                    emailsNotifications = emails.notificationFormat(3),
                    chatNotifications = chat.notificationFormat(2),
                    roomsIsActive = rooms,
                    meetNotifications = meet.notificationFormat(1)
                )
            )
        }
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = MainState.Initial
    )

    private suspend fun delayBeforeChangeNotifications() {
        val between1And3Seconds = Random.nextLong(1_000, 3_000)
        delay(between1And3Seconds)
    }

    private fun randomIncrement(): Int {
        return Random.nextInt(1, 5)
    }
}

El viewmodel solo posee a la propiedad state cuyo tipo es StateFlow. Si prestas atención, lo que hace es crear un flujo que incrementa infinitamente los contadores de las tres badges del ejemplo.

Por supuesto en cada iteración del bucle while añadimos un retraso aleatorio entre 1 y 3 segundos. E incrementamos aleatoriamente las notificaciones entre 1 y 5.

7. La clase MainViewModel usa una función de extensión llamada notificationFormat(), la cual añade el símbolo «+» cuando el valor entero excede su límite.

Su definición la añadiremos en el nuevo archivo NotificationFormatter.kt:

fun Int.notificationFormat(maximum: Int): String {
    restrain(this, maximum)
    return when (maximum) {
        1 -> truncate(this, 9)
        2 -> truncate(this, 99)
        3 -> truncate(this, 999)
        else -> error("Imposible")
    }
}

private fun restrain(value: Int, maximum: Int) {
    require(value > 0)
    require(maximum in 1..3)
}

private fun truncate(value: Int, limit: Int): String {
    val maximumIndicator = "+"

    return if (value > limit)
        "$limit" + (maximumIndicator)
    else
        value.toString()
}

Como se observa en el código, su implementación se basa en llamar a truncate() para determinar si el valor actual excedió los límites 9, 99 y 999. De esa forma se trunca el string y se añade el símbolo más como señal de este suceso.

8. Finalmente, corre la aplicación y podrás ver el resultado final de las bagdes en la Navigation Bar:

Badges en Android

Recuerda que puedes ver el código completo de este aplicativo en el repositorio de GitHub.

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