Cuando escribí mi última entrada sobre Jetpack Compose, afirmé que Compose carece de algunos componentes básicos, en mi opinión, y uno de ellos es la tooltip.

En aquel momento, no existía una composable integrada para mostrar tooltips y circularon varias soluciones alternativas en línea. El problema con esas soluciones era que una vez que se lanzó nuevas versiones de Jetpack Compose, esas soluciones podrían fallar. Así que no era ideal y la comunidad se quedó esperando que en algún momento del futuro se agregara soporte para tooltips.

Me alegra decir que desde la versión 1.1.0 de Compose Material 3, now tenemos soporte integrado para tooltips. 👏

Aunque esto en sí es excelente, más de un año ha pasado desde que se lanzó esa versión. Y con las versiones subsiguientes, la API relacionada con los tooltips ha cambiado drásticamente también.

Si te fijas en el registro de cambios, verás cómo las API públicas e internas han cambiado. Así que teng en cuenta que, cuando leas esta entrada, las cosas pueden haber continuado cambiando, ya que todo lo relacionado con las tooltips está aún marcado con la anotación ExperimentalMaterial3Api::class.

❗️ La versión de Material 3 utilizada en este artículo es la 1.2.1, que se lanzó el 6 de marzo de 2024

Tipos de tooltips

Ahora tenemos soporte para dos tipos diferentes de tooltips:

  1. Tooltip simple

  2. Tooltip de medios ricos

Información simple de tooltip

Puedes usar la primera variedad para proporcionar información sobre un botón de ícono que de otra manera no sería clara. Por ejemplo, puedes usar un tooltip simple para indicarle a un usuario qué representa el botón de ícono.

Para agregar un tooltip a tu aplicación, utilizas la composable TooltipBox. Esta composable toma varios argumentos:

fun TooltipBox(
    positionProvider: PopupPositionProvider,
    tooltip: @Composable TooltipScope.() -> Unit,
    state: TooltipState,
    modifier: Modifier = Modifier,
    focusable: Boolean = true,
    enableUserInput: Boolean = true,
    content: @Composable () -> Unit,
)

Algunos de estos deberían resultar familiares si has usado composables antes. Destacaré los que tienen un caso de uso específico aquí:

  • positionProvider – De tipo PopupPositionProvider, y se utiliza para calcular la posición del tooltip.

  • tooltip – Aquí es donde puedes diseñar la UI de cómo se verá el tooltip.

  • state – Este es el estado asociado con una instancia específica de Tooltip. Expose métodos como mostrar/ocultar el tooltip y cuando estás instanciando una instancia de una, puedes declarar si el tooltip debe ser persistente o no (es decir, si debe mantenerse en la pantalla hasta que el usuario realice una acción de clic fuera del tooltip).

  • contenido – Este es el UI que la tooltip mostrará arriba/debajo.

Aquí hay un ejemplo de la instanciación de un BasicTooltipBox con todos los argumentos relevantes rellenos:

@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun BasicTooltip() {
    val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider()
    val tooltipState = rememberBasicTooltipState(isPersistent = false)

    BasicTooltipBox(positionProvider = tooltipPosition,
        tooltip =  { Text("Hello World") } ,
        state = tooltipState) {
        IconButton(onClick = { }) {
            Icon(imageVector = Icons.Filled.Favorite, 
                 contentDescription = "Your icon's description")
        }
    }
}

Jetpack Compose tiene una clase integrada llamada TooltipDefaults. Puedes usar esta clase para ayudarte a instanciar argumentos que componen una TooltipBox. Por ejemplo, podrías usar TooltipDefaults.rememberPlainTooltipPositionProvider para posicionar correctamente la tooltip en relación con el elemento de anclaje.

Rich Tooltip

Un tooltip de medios ricos ocupa más espacio que un tooltip simple y se puede usar para proporcionar más contexto sobre la funcionalidad de un botón de icono. Cuando se muestra la tooltip, puedes agregar botones y enlaces a él para proporcionar una explicación adicional o definiciones.

Se instancia de manera similar a una tooltip simple, dentro de una TooltipBox, pero utilizas el composable RichTooltip.

TooltipBox(positionProvider = tooltipPosition,
        tooltip = {
                  RichTooltip(
                      title = { Text("RichTooltip") },
                      caretSize = caretSize,
                      action = {
                          TextButton(onClick = {
                              scope.launch {
                                  tooltipState.dismiss()
                                  tooltipState.onDispose()
                              }
                          }) {
                              Text("Dismiss")
                          }
                      }
                  ) {
                        Text("This is where a description would go.")
                  }
        },
        state = tooltipState) {
        IconButton(onClick = {
            /* Evento de clic del botón de icono */
        }) {
            Icon(imageVector = tooltipIcon,
                contentDescription = "Your icon's description",
                tint = iconColor)
        }
    }

Algunas cosas que debes notar sobre una tooltip Rica:

  1. Una tooltip Rica tiene soporte para un caret.

  2. Puede agregar una acción (es decir, un botón) a la información emergente para dar a los usuarios la opción de obtener más información.

  3. Puede agregar lógica para descartar la información emergente.

Casos de borde

Cuando decide marcar su estado de información emergente como persistente, significa que una vez que el usuario interactúa con la interfaz de usuario que muestra su información emergente, ésta permanecerá visible hasta que el usuario presione en cualquier otra parte de la pantalla.

Si vio el ejemplo de una información emergente enriquecida de arriba, puede que haya notado que hemos agregado un botón para descartar la información emergente una vez que se hace clic.

Hay un problema que ocurre una vez que el usuario presiona ese botón. Puesto que la acción de descartar se realiza en la información emergente, si un usuario quiere realizar otro largo pulsado en el elemento de interfaz que invoca esta información emergente, no se mostrará de nuevo. Esto significa que el estado de la información emergente es persistente en su descarte. Entonces, ¿cómo podemos resolver esto?

Para “reiniciar” el estado de la información emergente, tenemos que llamar al método onDispose que se expone a través del estado de la información emergente. Una vez que hacemos eso, se restablece el estado de la información emergente y se mostrará de nuevo cuando el usuario realice un largo pulsado en el elemento de interfaz.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RichTooltip() {
    val tooltipPosition = TooltipDefaults.rememberRichTooltipPositionProvider()
    val tooltipState = rememberTooltipState(isPersistent = true)
    val scope = rememberCoroutineScope()

    TooltipBox(positionProvider = tooltipPosition,
        tooltip = {
                  RichTooltip(
                      title = { Text("RichTooltip") },
                      caretSize = TooltipDefaults.caretSize,
                      action = {
                          TextButton(onClick = {
                              scope.launch {
                                  tooltipState.dismiss()
                                  tooltipState.onDispose()  /// <---- AQUÍ
                              }
                          }) {
                              Text("Dismiss")
                          }
                      }
                  ) {

                  }
        },
        state = tooltipState) {
        IconButton(onClick = {  }) {
            Icon(imageVector = Icons.Filled.Call, contentDescription = "Your icon's description")
        }
    }
}

Otro escenario en el que el estado de la tooltip no se restablece es si, en lugar de llamarnos nosotros para el método de descarte por acción del usuario, el usuario hace clic fuera de la tooltip, lo que la descarta. Esto llama el método de descarte detrás de escenas y el estado de la tooltip se establece en descartado. Al mantener presionado el elemento de interfaz de usuario para ver nuestra tooltip de nuevo, no se produce nada.

Nuestra lógica que llama al método onDispose de la tooltip no se activa, ¿cómo podemos restablecer el estado de la tooltip?

Actualmente, no he podido averiguar esto. Puede estar relacionado con el MutatorMutex de la tooltip. Quizás con las próximas versiones haya una API para esto. Noté que si hay otras tooltips en la pantalla y se presionan, esto restablece la tooltip previamente presionada.

Si desea ver el código aquí presentado, puede ir al repositorio de GitHub

Si desea ver tooltips en una aplicación, puede revisarlo aquí.

Referencias