Article original : How to Use Tooltips in Jetpack Compose

Lorsque j'ai écrit mon dernier article sur Jetpack Compose, j'y affirmais que Jetpack Compose manquait de certains composants de base (à mon avis), et l'un d'eux est le tooltip (info-bulle).

À l'époque, il n'y avait pas de composable intégré pour afficher des tooltips et plusieurs solutions alternatives circulaient en ligne. Le problème avec ces solutions était qu'une fois que Jetpack Compose publiait de nouvelles versions, ces solutions pouvaient cesser de fonctionner. Ce n'était donc pas idéal et la communauté espérait qu'à l'avenir, le support des tooltips serait ajouté.

Je suis heureux de dire que depuis la version 1.1.0 de Compose Material 3, nous disposons désormais d'un support intégré pour les tooltips. 👏

Bien que cela soit excellent en soi, plus d'un an s'est écoulé depuis la sortie de cette version. Et avec les versions suivantes, l'API liée aux tooltips a également radicalement changé.

Si vous parcourez le journal des modifications (changelog), vous verrez comment les API publiques et internes ont évolué. Gardez donc à l'esprit qu'au moment où vous lisez cet article, les choses ont pu continuer à changer car tout ce qui concerne les Tooltips est toujours marqué par l'annotation ExperimentalMaterial3Api::class.

❗️ La version de Material 3 utilisée pour cet article est la 1.2.1, publiée le 6 mars 2024.

Types de Tooltips

Nous avons désormais en charge deux types différents de tooltips :

  1. Tooltip simple (Plain tooltip)

  2. Tooltip multimédia riche (Rich media tooltip)

Tooltip simple

Vous pouvez utiliser le premier type pour fournir des informations sur un bouton d'icône qui ne seraient pas claires autrement. Par exemple, vous pouvez utiliser un tooltip simple pour indiquer à un utilisateur ce que représente le bouton d'icône.

Exemple de tooltip de base

Pour ajouter un tooltip à votre application, vous utilisez le composable TooltipBox. Ce composable prend plusieurs arguments :

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

Certains d'entre eux devraient vous être familiers si vous avez déjà utilisé des Composables. Je vais souligner ceux qui ont un cas d'utilisation spécifique ici :

  • positionProvider - De type PopupPositionProvider, il est utilisé pour calculer la position du tooltip.

  • tooltip - C'est ici que vous pouvez concevoir l'interface utilisateur (UI) de l'apparence du tooltip.

  • state - Ceci contient l'état associé à une instance spécifique de Tooltip. Il expose des méthodes comme l'affichage/la fermeture du tooltip et, lors de l'instanciation d'une instance, vous pouvez déclarer si le tooltip doit être persistant ou non (c'est-à-dire s'il doit rester affiché à l'écran jusqu'à ce que l'utilisateur effectue un clic en dehors du tooltip).

  • content - C'est l'UI au-dessus ou en dessous de laquelle le tooltip s'affichera.

Voici un exemple d'instanciation d'une BasicTooltipBox avec tous les arguments pertinents remplis :

@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 = "Description de votre icône")
        }
    }
}

Un tooltip de base

Jetpack Compose possède une classe intégrée appelée TooltipDefaults. Vous pouvez utiliser cette classe pour vous aider à instancier les arguments qui composent une TooltipBox. Par exemple, vous pourriez utiliser TooltipDefaults.rememberPlainTooltipPositionProvider pour positionner correctement le tooltip par rapport à l'élément d'ancrage.

Tooltip riche

Un tooltip multimédia riche prend plus d'espace qu'un tooltip simple et peut être utilisé pour fournir plus de contexte sur la fonctionnalité d'un bouton d'icône. Lorsque le tooltip est affiché, vous pouvez y ajouter des boutons et des liens pour fournir des explications ou des définitions supplémentaires.

Il s'instancie de manière similaire à un tooltip simple, à l'intérieur d'une TooltipBox, mais vous utilisez le composable RichTooltip.

TooltipBox(positionProvider = tooltipPosition,
        tooltip = {
                  RichTooltip(
                      title = { Text("RichTooltip") },
                      caretSize = caretSize,
                      action = {
                          TextButton(onClick = {
                              scope.launch {
                                  tooltipState.dismiss()
                                  tooltipState.onDispose()
                              }
                          }) {
                              Text("Fermer")
                          }
                      }
                  ) {
                        Text("C'est ici qu'irait une description.")
                  }
        },
        state = tooltipState) {
        IconButton(onClick = {
            /* Événement de clic du bouton d'icône */
        }) {
            Icon(imageVector = tooltipIcon,
                contentDescription = "Description de votre icône",
                tint = iconColor)
        }
    }

Quelques points à noter concernant un tooltip riche :

  1. Un tooltip riche prend en charge un caret (pointe).

  2. Vous pouvez ajouter une action (c'est-à-dire un bouton) au tooltip pour donner aux utilisateurs la possibilité de trouver plus d'informations.

  3. Vous pouvez ajouter une logique pour fermer le tooltip.

Tooltip riche sans caret

Tooltip riche avec un caret

Cas particuliers

Lorsque vous choisissez de marquer votre état de tooltip comme persistant, cela signifie qu'une fois que l'utilisateur interagit avec l'UI qui affiche votre tooltip, il restera visible jusqu'à ce que l'utilisateur appuie n'importe où ailleurs sur l'écran.

Si vous avez regardé l'exemple d'un tooltip riche ci-dessus, vous avez peut-être remarqué que nous avons ajouté un bouton pour fermer le tooltip une fois qu'il est cliqué.

Il y a un problème qui survient une fois qu'un utilisateur appuie sur ce bouton. Étant donné que l'action de fermeture est effectuée sur le tooltip, si un utilisateur souhaite effectuer un autre appui long sur l'élément d'interface qui invoque ce tooltip, le tooltip ne s'affichera plus. Cela signifie que l'état du tooltip reste sur l'état "fermé". Alors, comment résoudre cela ?

Le deuxième appui long ne déclenche pas le tooltip

Afin de « réinitialiser » l'état du tooltip, nous devons appeler la méthode onDispose qui est exposée via l'état du tooltip. Une fois que nous faisons cela, l'état du tooltip est réinitialisé et le tooltip s'affichera à nouveau lorsque l'utilisateur effectuera un appui long sur l'élément d'interface.

@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()  /// <---- ICI
                              }
                          }) {
                              Text("Fermer")
                          }
                      }
                  ) {

                  }
        },
        state = tooltipState) {
        IconButton(onClick = {  }) {
            Icon(imageVector = Icons.Filled.Call, contentDescription = "Description de votre icône")
        }
    }
}

onDispose résout le problème

Un autre scénario où l'état du tooltip ne se réinitialise pas est si, au lieu d'appeler nous-mêmes la méthode de fermeture suite à une action de l'utilisateur, celui-ci clique en dehors du tooltip, provoquant sa fermeture. Cela appelle la méthode dismiss en coulisses et l'état du tooltip est défini sur fermé. Un appui long sur l'élément d'interface pour revoir notre tooltip ne donnera rien.

Le tooltip ne s'affiche plus

Notre logique qui appelle la méthode onDispose du tooltip n'est pas déclenchée, alors comment pouvons-nous réinitialiser l'état du tooltip ?

Actuellement, je n'ai pas réussi à trouver de solution à cela. Cela pourrait être lié au MutatorMutex du tooltip. Peut-être qu'avec les prochaines versions, il y aura une API pour cela. J'ai remarqué que si d'autres tooltips sont présents à l'écran et qu'ils sont pressés, cela réinitialise le tooltip précédemment cliqué.

25a81994-a508-4c71-8424-c45370a7999d

Si vous souhaitez voir le code présenté ici, vous pouvez consulter ce dépôt GitHub

Si vous souhaitez voir des tooltips dans une application concrète, vous pouvez la consulter ici.

Références