Gérer les interactions des utilisateurs

Les composants de l'interface utilisateur fournissent des informations à l'utilisateur de l'appareil en fonction de la manière dont ils répondent aux interactions de l'utilisateur. Chaque composant a sa propre façon de répondre aux interactions, ce qui permet à l'utilisateur de savoir ce que font ses interactions. Par exemple, si un utilisateur touche un bouton sur l'écran tactile d'un appareil, celui-ci est susceptible de changer d'une manière ou d'une autre, par exemple en ajoutant une couleur de surbrillance. Ce changement indique à l'utilisateur qu'il a appuyé sur le bouton. Si l'utilisateur ne souhaite pas effectuer cette action, il saura qu'il doit faire glisser son doigt du bouton avant de le libérer. Sinon, le bouton s'active.

Figure 1. Les boutons qui apparaissent toujours activés, sans ondulations de pression
Figure 2. Boutons avec des ondulations qui reflètent leur état d'activation en conséquence.

La documentation sur les gestes de Compose explique comment les composants Compose gèrent les événements de pointeur de bas niveau, tels que les mouvements de pointeur et les clics. Compose fait abstraction de ces événements de bas niveau en interactions de niveau supérieur sans la moindre configuration. Par exemple, une série d'événements de pointeur peut s'accumuler en appuyant de manière prolongée sur un bouton. Comprendre ces abstractions de niveau supérieur peut vous aider à personnaliser la manière dont votre UI répond à l'utilisateur. Par exemple, vous pouvez personnaliser l'apparence d'un composant lorsque l'utilisateur interagit avec lui ou simplement tenir à jour un journal de ces actions. Ce document fournit les informations dont vous avez besoin pour modifier les éléments d'UI standards ou concevoir les vôtres.

Interactions

Dans de nombreux cas, vous n'avez pas besoin de savoir comment votre composant Compose interprète les interactions de l'utilisateur. Par exemple, Button s'appuie sur Modifier.clickable pour déterminer si l'utilisateur a cliqué sur le bouton. Si vous ajoutez un bouton standard à votre application, vous pouvez définir le code onClick du bouton et Modifier.clickable exécutera ce code le cas échéant. Vous n'avez donc pas besoin de savoir si l'utilisateur a appuyé sur l'écran ou a sélectionné le bouton avec un clavier, car Modifier.clickable détermine que l'utilisateur a effectué un clic et répond en exécutant votre code onClick.

Toutefois, si vous souhaitez personnaliser la réponse de votre composant d'UI au comportement des utilisateurs, vous devrez vous intéresser davantage à ce qui se passe en arrière-plan. Cette section traite de ce sujet.

Lorsqu'un utilisateur interagit avec un composant d'UI, le système représente son comportement en générant un certain nombre d'événements Interaction. Par exemple, si un utilisateur touche un bouton, celui-ci génère l'interaction PressInteraction.Press. Si l'utilisateur lève le doigt à partir du bouton, il génère l'interaction PressInteraction.Release, qui informe le bouton que le clic est terminé. En revanche, si l'utilisateur fait glisser son doigt à l'extérieur du bouton, puis le lève, le bouton génère l'interaction PressInteraction.Cancel pour indiquer que l'appui sur le bouton a été annulé.

Ces interactions sont non catégoriques. Autrement dit, ces événements d'interaction de bas niveau n'ont pas pour but d'interpréter la signification des actions de l'utilisateur ni leur ordre. Par ailleurs, ils n'interprètent pas les actions de l'utilisateur pouvant être prioritaires par rapport à d'autres actions.

Ces interactions sont généralement regroupées par paires, et présentent un début et une fin. La deuxième interaction contient une référence à la première. Par exemple, si un utilisateur appuie sur un bouton puis le relâche, cela génère les interactions PressInteraction.Press et PressInteraction.Release. Release présente une propriété press identifiant l'interaction PressInteraction.Press initiale.

Vous pouvez voir les interactions d'un composant particulier en observant son InteractionSource. InteractionSource est basé sur les flux Kotlin, ce qui vous permet de collecter les interactions à partir de celui-ci de la même manière que vous le feriez avec un autre flux. Pour en savoir plus sur cette décision de conception, consultez l'article de blog Interactions illuminantes.

État de l'interaction

Vous pouvez étendre les fonctionnalités intégrées de vos composants en suivant également les interactions vous-même. Par exemple, vous pouvez souhaiter qu'un bouton change de couleur lorsque l'utilisateur appuie dessus. Le moyen le plus simple de suivre les interactions consiste à observer l'état d'interaction approprié. InteractionSource propose un certain nombre de méthodes qui révèlent divers statuts d'interaction en tant qu'état. Par exemple, si vous souhaitez savoir si un utilisateur a appuyé sur un bouton, vous pouvez appeler sa méthode InteractionSource.collectIsPressedAsState() :

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(
    onClick = { /* do something */ },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Outre collectIsPressedAsState(), Compose fournit également collectIsFocusedAsState(), collectIsDraggedAsState() et collectIsHoveredAsState(). Il s'agit de méthodes pratiques, qui s'appuient sur des API InteractionSource de niveau inférieur. Dans certains cas, vous pouvez utiliser directement ces fonctions de niveau inférieur.

Par exemple, supposons que vous ayez besoin de savoir si l'utilisateur appuie sur un bouton et également s'il est déplacé. Si vous utilisez à la fois collectIsPressedAsState() et collectIsDraggedAsState(), Compose effectue beaucoup de tâches en double. Il n'est donc pas garanti que toutes les interactions soient exécutées dans le bon ordre. Pour de telles situations, vous pouvez travailler directement avec InteractionSource. Pour en savoir plus sur le suivi des interactions avec InteractionSource, consultez Utiliser InteractionSource.

La section suivante explique comment consommer et émettre des interactions avec InteractionSource et MutableInteractionSource, respectivement.

Utiliser et émettre Interaction

InteractionSource représente un flux en lecture seule de Interactions. Il n'est pas possible d'émettre un Interaction pour une InteractionSource. Pour émettre des Interaction, vous devez utiliser un MutableInteractionSource, qui s'étend à partir de InteractionSource.

Les modificateurs et les composants peuvent consommer, émettre ou consommer et émettre des Interactions. Les sections suivantes décrivent comment consommer et émettre des interactions à partir de modificateurs et de composants.

Exemple de modificateur d'utilisation

Pour un modificateur qui trace une bordure pour l'état sélectionné, il vous suffit d'observer Interactions. Vous pouvez donc accepter un élément InteractionSource:

fun Modifier.focusBorder(interactionSource: InteractionSource): Modifier {
    // ...
}

La signature de la fonction indique clairement que ce modificateur est un consommateur. Il peut consommer des Interaction, mais ne peut pas les émettre.

Générer un exemple de modificateur

Pour un modificateur qui gère les événements de pointage tels que Modifier.hoverable, vous devez émettre Interactions et accepter un MutableInteractionSource comme paramètre:

fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier {
    // ...
}

Ce modificateur est un producteur. Il peut utiliser le MutableInteractionSource fourni pour émettre HoverInteractions lorsque l'utilisateur pointe sur celui-ci ou s'il ne passe pas le curseur dessus.

Créer des composants qui consomment et produisent

Les composants de haut niveau tels qu'un Button Material agissent à la fois en tant que producteurs et consommateurs. Ils gèrent les événements d'entrée et de sélection, ainsi que leur apparence en réponse à ces événements, par exemple pour afficher une ondulation ou animer leur élévation. Par conséquent, ils exposent directement MutableInteractionSource en tant que paramètre, ce qui vous permet de fournir votre propre instance mémorisée:

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,

    // exposes MutableInteractionSource as a parameter
    interactionSource: MutableInteractionSource? = null,

    elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) { /* content() */ }

Cela permet de hisser le MutableInteractionSource du composant et d'observer tous les Interaction produits par celui-ci. Vous pouvez l'utiliser pour contrôler l'apparence de ce composant ou de tout autre composant de votre interface utilisateur.

Si vous créez vos propres composants interactifs de haut niveau, nous vous recommandons d'exposer MutableInteractionSource en tant que paramètre de cette manière. En plus de suivre les bonnes pratiques de hissage d'état, cela facilite également la lecture et le contrôle de l'état visuel d'un composant de la même manière que tout autre type d'état (tel que l'état activé) peut être lu et contrôlé.

Compose suit une approche architecturale multicouche. Par conséquent, les composants Material de haut niveau sont construits sur des blocs de construction de base qui produisent les Interaction dont ils ont besoin pour contrôler les ondulations et d'autres effets visuels. La bibliothèque Foundation fournit des modificateurs d'interaction de haut niveau tels que Modifier.hoverable, Modifier.focusable et Modifier.draggable.

Pour créer un composant qui répond aux événements de pointage, vous pouvez simplement utiliser Modifier.hoverable et transmettre un MutableInteractionSource en tant que paramètre. Chaque fois que l'utilisateur pointe sur le composant, il émet des HoverInteraction. Vous pouvez l'utiliser pour modifier l'apparence du composant.

// This InteractionSource will emit hover interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Pour également rendre ce composant sélectionnable, vous pouvez ajouter Modifier.focusable et transmettre le même MutableInteractionSource en tant que paramètre. Désormais, HoverInteraction.Enter/Exit et FocusInteraction.Focus/Unfocus sont émis via le même MutableInteractionSource, et vous pouvez personnaliser l'apparence des deux types d'interaction au même endroit:

// This InteractionSource will emit hover and focus interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource)
        .focusable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Modifier.clickable est une abstraction de niveau encore plus élevée que hoverable et focusable. Pour qu'un composant soit cliquable, il est implicitement sur lequel il est possible de passer le curseur, et les composants sur lesquels l'utilisateur peut cliquer doivent également être sélectionnables. Vous pouvez utiliser Modifier.clickable pour créer un composant qui gère les interactions de survol, de sélection et d'appui, sans avoir à combiner des API de niveau inférieur. Si vous souhaitez également rendre votre composant cliquable, vous pouvez remplacer hoverable et focusable par un clickable:

// This InteractionSource will emit hover, focus, and press interactions
val interactionSource = remember { MutableInteractionSource() }
Box(
    Modifier
        .size(100.dp)
        .clickable(
            onClick = {},
            interactionSource = interactionSource,

            // Also show a ripple effect
            indication = ripple()
        ),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Travailler avec InteractionSource

Si vous avez besoin d'informations générales sur les interactions avec un composant, vous pouvez utiliser des API de flux standards pour l'InteractionSource de ce composant. Par exemple, supposons que vous souhaitiez conserver une liste des interactions d'appui et de glissement d'une InteractionSource. Ce code effectue la moitié de la tâche, en ajoutant les nouvelles pressions à mesure qu'elles se produisent :

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
        }
    }
}

Toutefois, en plus d'ajouter les nouvelles interactions, vous devez également supprimer les interactions lorsqu'elles se terminent (par exemple, lorsque l'utilisateur lève le doigt du composant). Cette opération est facile à effectuer, car les interactions de fin contiennent toujours une référence à l'interaction de début associée. Ce code montre comment supprimer les interactions terminées :

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is PressInteraction.Release -> {
                interactions.remove(interaction.press)
            }
            is PressInteraction.Cancel -> {
                interactions.remove(interaction.press)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
            is DragInteraction.Stop -> {
                interactions.remove(interaction.start)
            }
            is DragInteraction.Cancel -> {
                interactions.remove(interaction.start)
            }
        }
    }
}

Si vous souhaitez savoir si l'utilisateur appuie sur le composant ou le fait glisser, il vous suffit de vérifier si interactions est vide :

val isPressedOrDragged = interactions.isNotEmpty()

Si vous souhaitez savoir quelle était la dernière interaction, il vous suffit de consulter le dernier élément de la liste. Par exemple, voici comment l'implémentation de l'ondulation Compose détermine la superposition d'état appropriée à utiliser pour l'interaction la plus récente:

val lastInteraction = when (interactions.lastOrNull()) {
    is DragInteraction.Start -> "Dragged"
    is PressInteraction.Press -> "Pressed"
    else -> "No state"
}

Étant donné que tous les Interaction suivent la même structure, il n'y a pas beaucoup de différence dans le code lorsque vous travaillez avec différents types d'interactions utilisateur. Le modèle global est le même.

Notez que les exemples précédents de cette section représentent le Flow des interactions avec State. Cela permet d'observer facilement les valeurs mises à jour, car la lecture de la valeur d'état entraîne automatiquement des recompositions. Cependant, la composition est une pré-image par lot. Cela signifie que si l'état change, puis revient dans le même frame, les composants qui observent l'état ne voient pas le changement.

Cela est important pour les interactions, car celles-ci peuvent régulièrement commencer et se terminer dans le même frame. Par exemple, si vous utilisez l'exemple précédent avec Button:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(onClick = { /* do something */ }, interactionSource = interactionSource) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Si une pression commence et se termine dans le même cadre, le texte ne s'affichera jamais comme "Pressed!". Dans la plupart des cas, ce n'est pas un problème. Afficher un effet visuel pendant une si courte durée entraînera un scintillement et ne sera pas très visible pour l'utilisateur. Dans certains cas (par exemple, pour afficher un effet d'ondulation ou une animation similaire), vous pouvez afficher l'effet pendant au moins une durée minimale, plutôt que de vous arrêter immédiatement si vous n'appuyez plus sur le bouton. Pour ce faire, vous pouvez démarrer et arrêter directement les animations à partir du lambda, au lieu d'écrire dans un état. Vous trouverez un exemple de ce modèle dans la section Créer une Indication avancée avec une bordure animée.

Exemple: Composant de compilation avec gestion des interactions personnalisées

Pour voir comment créer des composants avec une réponse personnalisée à une entrée, voici un exemple de bouton modifié. Dans ce cas, supposons que vous souhaitiez qu'un bouton change d'apparence lorsque l'utilisateur appuie dessus :

Animation d&#39;un bouton qui ajoute dynamiquement une icône de panier d&#39;épicerie lorsque l&#39;utilisateur clique dessus
Figure 3. Un bouton qui ajoute une icône de façon dynamique lorsqu'un utilisateur clique dessus.

Pour ce faire, créez un composable personnalisé basé sur Button et demandez-lui d'utiliser un paramètre icon supplémentaire pour dessiner l'icône (dans ce cas, un panier). Vous appelez collectIsPressedAsState() pour savoir si l'utilisateur pointe le bouton. Le cas échéant, ajoutez l'icône. Voici ce à quoi ressemble le code :

@Composable
fun PressIconButton(
    onClick: () -> Unit,
    icon: @Composable () -> Unit,
    text: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    interactionSource: MutableInteractionSource? = null
) {
    val isPressed = interactionSource?.collectIsPressedAsState()?.value ?: false

    Button(
        onClick = onClick,
        modifier = modifier,
        interactionSource = interactionSource
    ) {
        AnimatedVisibility(visible = isPressed) {
            if (isPressed) {
                Row {
                    icon()
                    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
                }
            }
        }
        text()
    }
}

Et voici ce à quoi ressemble ce nouveau composable :

PressIconButton(
    onClick = {},
    icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) },
    text = { Text("Add to cart") }
)

Comme ce nouveau PressIconButton est basé sur le Button Material existant, il réagit de manière habituelle aux interactions des utilisateurs. Lorsque l'utilisateur appuie sur le bouton, son opacité change légèrement, tout comme un Button Material ordinaire.

Créer et appliquer un effet personnalisé réutilisable avec Indication

Dans les sections précédentes, vous avez appris à modifier une partie d'un composant en réponse à différentes valeurs Interaction, par exemple afficher une icône lorsque l'utilisateur appuie dessus. Cette même approche peut être utilisée pour modifier la valeur des paramètres que vous fournissez à un composant ou le contenu affiché à l'intérieur d'un composant, mais cela ne s'applique qu'au niveau de chaque composant. Souvent, une application ou un système de conception dispose d'un système générique pour les effets visuels avec état, un effet qui doit être appliqué à tous les composants de manière cohérente.

Si vous créez ce type de système de conception, il peut être difficile de personnaliser un composant et de réutiliser cette personnalisation pour d'autres composants pour les raisons suivantes:

  • Chaque composant du système de conception nécessite le même code récurrent
  • Il est facile d'oublier d'appliquer cet effet aux composants nouvellement créés et aux composants cliquables personnalisés
  • Il peut être difficile de combiner l'effet personnalisé avec d'autres effets.

Pour éviter ces problèmes et faire évoluer facilement un composant personnalisé sur l'ensemble de votre système, vous pouvez utiliser Indication. Indication représente un effet visuel réutilisable qui peut être appliqué à tous les composants d'une application ou d'un système de conception. Indication est divisé en deux parties:

  • IndicationNodeFactory: fabrique qui crée des instances Modifier.Node qui affichent les effets visuels d'un composant. Pour des implémentations plus simples qui ne changent pas d'un composant à l'autre, il peut s'agir d'un singleton (objet) qui peut être réutilisé dans l'ensemble de l'application.

    Ces instances peuvent être avec ou sans état. Étant donné qu'ils sont créés par composant, ils peuvent récupérer les valeurs d'un CompositionLocal pour modifier leur affichage ou leur comportement dans un composant particulier, comme avec n'importe quel autre Modifier.Node.

  • Modifier.indication : modificateur qui dessine Indication pour un composant. Modifier.clickable et d'autres modificateurs d'interaction de haut niveau acceptent directement un paramètre d'indication. Ils émettent donc non seulement des Interaction, mais peuvent également dessiner des effets visuels pour les Interaction qu'ils émettent. Ainsi, pour des cas simples, vous pouvez simplement utiliser Modifier.clickable sans avoir besoin de Modifier.indication.

Remplacer l'effet par une Indication

Cette section explique comment remplacer un effet d'échelle manuelle appliqué à un bouton spécifique par un équivalent indicatif pouvant être réutilisé sur plusieurs composants.

Le code suivant crée un bouton qui rétrécit en cas d'appui:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(targetValue = if (isPressed) 0.9f else 1f, label = "scale")

Button(
    modifier = Modifier.scale(scale),
    onClick = { },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Pour convertir l'effet d'échelle de l'extrait ci-dessus en Indication, procédez comme suit:

  1. Créez le Modifier.Node responsable de l'application de l'effet d'échelle. Une fois associé, le nœud observe la source de l'interaction, comme dans les exemples précédents. La seule différence réside dans le fait qu'il lance directement les animations au lieu de convertir les interactions entrantes en état.

    Le nœud doit implémenter DrawModifierNode pour pouvoir remplacer ContentDrawScope#draw() et afficher un effet d'échelle à l'aide des mêmes commandes de dessin qu'avec toute autre API graphique dans Compose.

    L'appel de drawContent() disponible à partir du récepteur ContentDrawScope dessine le composant réel auquel le Indication doit être appliqué. Il vous suffit donc d'appeler cette fonction dans une transformation d'échelle. Assurez-vous que vos implémentations Indication appellent toujours drawContent() à un moment donné. Sinon, le composant auquel vous appliquez la Indication ne sera pas dessiné.

    private class ScaleNode(private val interactionSource: InteractionSource) :
        Modifier.Node(), DrawModifierNode {
    
        var currentPressPosition: Offset = Offset.Zero
        val animatedScalePercent = Animatable(1f)
    
        private suspend fun animateToPressed(pressPosition: Offset) {
            currentPressPosition = pressPosition
            animatedScalePercent.animateTo(0.9f, spring())
        }
    
        private suspend fun animateToResting() {
            animatedScalePercent.animateTo(1f, spring())
        }
    
        override fun onAttach() {
            coroutineScope.launch {
                interactionSource.interactions.collectLatest { interaction ->
                    when (interaction) {
                        is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                        is PressInteraction.Release -> animateToResting()
                        is PressInteraction.Cancel -> animateToResting()
                    }
                }
            }
        }
    
        override fun ContentDrawScope.draw() {
            scale(
                scale = animatedScalePercent.value,
                pivot = currentPressPosition
            ) {
                this@draw.drawContent()
            }
        }
    }

  2. Créez le IndicationNodeFactory. Sa seule responsabilité est de créer une instance de nœud pour une source d'interaction fournie. Comme il n'existe aucun paramètre permettant de configurer l'indication, la fabrique peut être un objet:

    object ScaleIndication : IndicationNodeFactory {
        override fun create(interactionSource: InteractionSource): DelegatableNode {
            return ScaleNode(interactionSource)
        }
    
        override fun equals(other: Any?): Boolean = other === ScaleIndication
        override fun hashCode() = 100
    }

  3. Modifier.clickable utilise Modifier.indication en interne. Par conséquent, pour créer un composant cliquable avec ScaleIndication, il vous suffit de fournir le Indication en tant que paramètre à clickable:

    Box(
        modifier = Modifier
            .size(100.dp)
            .clickable(
                onClick = {},
                indication = ScaleIndication,
                interactionSource = null
            )
            .background(Color.Blue),
        contentAlignment = Alignment.Center
    ) {
        Text("Hello!", color = Color.White)
    }

    Cela permet également de créer facilement des composants réutilisables de haut niveau à l'aide d'un Indication personnalisé. Voici un exemple de bouton:

    @Composable
    fun ScaleButton(
        onClick: () -> Unit,
        modifier: Modifier = Modifier,
        enabled: Boolean = true,
        interactionSource: MutableInteractionSource? = null,
        shape: Shape = CircleShape,
        content: @Composable RowScope.() -> Unit
    ) {
        Row(
            modifier = modifier
                .defaultMinSize(minWidth = 76.dp, minHeight = 48.dp)
                .clickable(
                    enabled = enabled,
                    indication = ScaleIndication,
                    interactionSource = interactionSource,
                    onClick = onClick
                )
                .border(width = 2.dp, color = Color.Blue, shape = shape)
                .padding(horizontal = 16.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically,
            content = content
        )
    }

Vous pouvez ensuite utiliser le bouton de la manière suivante:

ScaleButton(onClick = {}) {
    Icon(Icons.Filled.ShoppingCart, "")
    Spacer(Modifier.padding(10.dp))
    Text(text = "Add to cart!")
}

Animation d&#39;un bouton avec une icône représentant un chariot de courses qui devient plus petite lorsqu&#39;on appuie dessus
Figure 4. Bouton créé avec un Indication personnalisé.

Créer un élément Indication avancé avec une bordure animée

Indication ne se limite pas aux effets de transformation, tels que le scaling d'un composant. Comme IndicationNodeFactory renvoie un Modifier.Node, vous pouvez dessiner n'importe quel type d'effet au-dessus ou en dessous du contenu, comme avec les autres API de dessin. Par exemple, vous pouvez dessiner une bordure animée autour du composant et une superposition au-dessus du composant lorsque l'utilisateur appuie dessus:

Bouton avec un effet arc-en-ciel sophistiqué lorsque l&#39;on appuie dessus
Figure 5 : Effet de bordure animé dessiné avec Indication

L'implémentation de Indication ici est très semblable à l'exemple précédent. Elle crée simplement un nœud avec certains paramètres. Étant donné que la bordure animée dépend de la forme et de la bordure du composant pour lequel Indication est utilisé, l'implémentation de Indication nécessite également que la forme et la largeur de la bordure soient fournies en tant que paramètres:

data class NeonIndication(private val shape: Shape, private val borderWidth: Dp) : IndicationNodeFactory {

    override fun create(interactionSource: InteractionSource): DelegatableNode {
        return NeonNode(
            shape,
            // Double the border size for a stronger press effect
            borderWidth * 2,
            interactionSource
        )
    }
}

L'implémentation de Modifier.Node est également la même conceptuellement, même si le code de dessin est plus compliqué. Comme précédemment, il observe InteractionSource lorsqu'il est associé, lance des animations et implémente DrawModifierNode pour dessiner l'effet sur le contenu:

private class NeonNode(
    private val shape: Shape,
    private val borderWidth: Dp,
    private val interactionSource: InteractionSource
) : Modifier.Node(), DrawModifierNode {
    var currentPressPosition: Offset = Offset.Zero
    val animatedProgress = Animatable(0f)
    val animatedPressAlpha = Animatable(1f)

    var pressedAnimation: Job? = null
    var restingAnimation: Job? = null

    private suspend fun animateToPressed(pressPosition: Offset) {
        // Finish any existing animations, in case of a new press while we are still showing
        // an animation for a previous one
        restingAnimation?.cancel()
        pressedAnimation?.cancel()
        pressedAnimation = coroutineScope.launch {
            currentPressPosition = pressPosition
            animatedPressAlpha.snapTo(1f)
            animatedProgress.snapTo(0f)
            animatedProgress.animateTo(1f, tween(450))
        }
    }

    private fun animateToResting() {
        restingAnimation = coroutineScope.launch {
            // Wait for the existing press animation to finish if it is still ongoing
            pressedAnimation?.join()
            animatedPressAlpha.animateTo(0f, tween(250))
            animatedProgress.snapTo(0f)
        }
    }

    override fun onAttach() {
        coroutineScope.launch {
            interactionSource.interactions.collect { interaction ->
                when (interaction) {
                    is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                    is PressInteraction.Release -> animateToResting()
                    is PressInteraction.Cancel -> animateToResting()
                }
            }
        }
    }

    override fun ContentDrawScope.draw() {
        val (startPosition, endPosition) = calculateGradientStartAndEndFromPressPosition(
            currentPressPosition, size
        )
        val brush = animateBrush(
            startPosition = startPosition,
            endPosition = endPosition,
            progress = animatedProgress.value
        )
        val alpha = animatedPressAlpha.value

        drawContent()

        val outline = shape.createOutline(size, layoutDirection, this)
        // Draw overlay on top of content
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha * 0.1f
        )
        // Draw border on top of overlay
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha,
            style = Stroke(width = borderWidth.toPx())
        )
    }

    /**
     * Calculates a gradient start / end where start is the point on the bounding rectangle of
     * size [size] that intercepts with the line drawn from the center to [pressPosition],
     * and end is the intercept on the opposite end of that line.
     */
    private fun calculateGradientStartAndEndFromPressPosition(
        pressPosition: Offset,
        size: Size
    ): Pair<Offset, Offset> {
        // Convert to offset from the center
        val offset = pressPosition - size.center
        // y = mx + c, c is 0, so just test for x and y to see where the intercept is
        val gradient = offset.y / offset.x
        // We are starting from the center, so halve the width and height - convert the sign
        // to match the offset
        val width = (size.width / 2f) * sign(offset.x)
        val height = (size.height / 2f) * sign(offset.y)
        val x = height / gradient
        val y = gradient * width

        // Figure out which intercept lies within bounds
        val intercept = if (abs(y) <= abs(height)) {
            Offset(width, y)
        } else {
            Offset(x, height)
        }

        // Convert back to offsets from 0,0
        val start = intercept + size.center
        val end = Offset(size.width - start.x, size.height - start.y)
        return start to end
    }

    private fun animateBrush(
        startPosition: Offset,
        endPosition: Offset,
        progress: Float
    ): Brush {
        if (progress == 0f) return TransparentBrush

        // This is *expensive* - we are doing a lot of allocations on each animation frame. To
        // recreate a similar effect in a performant way, it would be better to create one large
        // gradient and translate it on each frame, instead of creating a whole new gradient
        // and shader. The current approach will be janky!
        val colorStops = buildList {
            when {
                progress < 1 / 6f -> {
                    val adjustedProgress = progress * 6f
                    add(0f to Blue)
                    add(adjustedProgress to Color.Transparent)
                }
                progress < 2 / 6f -> {
                    val adjustedProgress = (progress - 1 / 6f) * 6f
                    add(0f to Purple)
                    add(adjustedProgress * MaxBlueStop to Blue)
                    add(adjustedProgress to Blue)
                    add(1f to Color.Transparent)
                }
                progress < 3 / 6f -> {
                    val adjustedProgress = (progress - 2 / 6f) * 6f
                    add(0f to Pink)
                    add(adjustedProgress * MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 4 / 6f -> {
                    val adjustedProgress = (progress - 3 / 6f) * 6f
                    add(0f to Orange)
                    add(adjustedProgress * MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 5 / 6f -> {
                    val adjustedProgress = (progress - 4 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                else -> {
                    val adjustedProgress = (progress - 5 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxYellowStop to Yellow)
                    add(MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
            }
        }

        return linearGradient(
            colorStops = colorStops.toTypedArray(),
            start = startPosition,
            end = endPosition
        )
    }

    companion object {
        val TransparentBrush = SolidColor(Color.Transparent)
        val Blue = Color(0xFF30C0D8)
        val Purple = Color(0xFF7848A8)
        val Pink = Color(0xFFF03078)
        val Orange = Color(0xFFF07800)
        val Yellow = Color(0xFFF0D800)
        const val MaxYellowStop = 0.16f
        const val MaxOrangeStop = 0.33f
        const val MaxPinkStop = 0.5f
        const val MaxPurpleStop = 0.67f
        const val MaxBlueStop = 0.83f
    }
}

La principale différence ici est qu'il existe désormais une durée minimale pour l'animation avec la fonction animateToResting(). Par conséquent, même si l'appui est relâché immédiatement, l'animation d'appui se poursuit. Il est également possible de gérer plusieurs appuis rapides au début de animateToPressed. Si une pression se produit pendant une animation d'appui existante ou au repos, l'animation précédente est annulée et l'animation d'appui reprend depuis le début. Pour prendre en charge plusieurs effets simultanés (par exemple, avec les ondulations, où une nouvelle animation d'ondulations se superposera aux autres ondulations), vous pouvez suivre les animations dans une liste au lieu d'annuler des animations existantes et d'en démarrer de nouvelles.