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 il s'activera.

Figure 1. Boutons qui apparaissent toujours activés, sans effet d'onde au toucher.
Figure 2. Boutons avec des ondulations à la pression qui reflètent leur état activé 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 Illuminating Interactions.

É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 décrit comment consommer et émettre des interactions avec InteractionSource et MutableInteractionSource, respectivement.

Utiliser et émettre des Interaction

InteractionSource représente un flux Interactions en lecture seule. Il n'est pas possible d'émettre un Interaction dans un 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 Interactions. Les sections suivantes décrivent comment consommer et émettre des interactions à partir de modificateurs et de composants.

Exemple de modificateur consommateur

Pour un modificateur qui dessine une bordure pour l'état de sélection, vous n'avez besoin d'observer que Interactions. Vous pouvez donc accepter un 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 en émettre.

Exemple de modificateur de production

Pour un modificateur qui gère les événements de survol comme Modifier.hoverable, vous devez émettre Interactions et accepter un MutableInteractionSource comme paramètre à la place :

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

Ce modificateur est un producteur. Il peut utiliser le MutableInteractionSource fourni pour émettre HoverInteractions lorsqu'il est pointé ou non.

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 de saisie et de sélection, et modifient également leur apparence en réponse à ces événements, par exemple en affichant une ondulation ou en animant leur élévation. Par conséquent, ils exposent directement MutableInteractionSource en tant que paramètre, afin que vous puissiez 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 hors du composant et d'observer tous les Interaction produits par le composant. Vous pouvez l'utiliser pour contrôler l'apparence de ce composant ou de tout autre composant de votre UI.

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 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 en couches. Les composants Material de haut niveau sont donc basés sur des blocs de construction fondamentaux qui produisent les Interaction dont ils ont besoin pour contrôler les ondulations et 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 survol, vous pouvez simplement utiliser Modifier.hoverable et transmettre un MutableInteractionSource en tant que paramètre. Chaque fois que le composant est survolé, il émet des HoverInteraction, que vous pouvez 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 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. 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 un niveau d'abstraction encore plus élevé que hoverable et focusable. Pour qu'un composant soit cliquable, il est implicitement hoverable, et les composants cliquables doivent également être focusables. 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()

Pour savoir quelle a été 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 général est le même.

Notez que les exemples précédents de cette section représentent le Flow des interactions à l'aide de State. Cela permet d'observer facilement les valeurs mises à jour, car la lecture de la valeur d'état entraînera automatiquement des recompositions. Toutefois, la composition est traitée par lots avant chaque frame. Cela signifie que si l'état change, puis revient à son état initial dans le même frame, les composants qui observent l'état ne verront pas le changement.

C'est important pour les interactions, car elles peuvent régulièrement commencer et se terminer dans le même frame. Par exemple, en utilisant 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 frame, le texte "Pressed!" (Appuyé !) ne s'affichera jamais. Dans la plupart des cas, cela ne pose pas de problème. L'affichage d'un effet visuel pendant une durée aussi courte entraîne un scintillement et n'est pas très perceptible par l'utilisateur. Dans certains cas, comme pour afficher un effet d'onde ou une animation similaire, vous pouvez souhaiter afficher l'effet pendant au moins une durée minimale, au lieu de l'arrêter immédiatement si le bouton n'est plus enfoncé. Pour ce faire, vous pouvez démarrer et arrêter directement les animations à partir du lambda de collecte, au lieu d'écrire dans un état. Vous trouverez un exemple de ce modèle dans la section Créer un Indication avancé avec une bordure animée.

Exemple : Créer un composant avec une gestion des interactions personnalisée

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 lorsqu&#39;un utilisateur clique dessus
Figure 3. Bouton qui ajoute dynamiquement une icône 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érents Interaction, par exemple en affichant une icône lorsqu'un bouton est enfoncé. 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'à chaque composant. Souvent, une application ou un système de conception disposent d'un système générique pour les effets visuels avec état, c'est-à-dire 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 a besoin du même code passe-partout.
  • Il est facile d'oublier d'appliquer cet effet aux composants nouvellement créés et aux composants personnalisés cliquables.
  • Il peut être difficile de combiner l'effet personnalisé avec d'autres effets.

Pour éviter ces problèmes et mettre facilement à l'échelle un composant personnalisé dans votre système, vous pouvez utiliser Indication. Indication représente un effet visuel réutilisable qui peut être appliqué à des 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 des effets visuels pour un composant. Pour les implémentations plus simples qui ne changent pas d'un composant à l'autre, il peut s'agir d'un singleton (objet) et être réutilisé dans toute l'application.

    Ces instances peuvent être avec ou sans état. Comme ils sont créés par composant, ils peuvent récupérer des valeurs à partir d'un CompositionLocal pour modifier leur apparence ou leur comportement à l'intérieur d'un composant spécifique, comme avec tout 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 les cas simples, vous pouvez simplement utiliser Modifier.clickable sans avoir besoin de Modifier.indication.

Remplacer l'effet par un Indication

Cette section explique comment remplacer un effet de mise à l'échelle manuelle appliqué à un bouton spécifique par une indication équivalente réutilisable dans plusieurs composants.

Le code suivant crée un bouton qui se réduit lorsqu'on appuie dessus :

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 de mise à l'échelle de l'extrait ci-dessus en Indication, procédez comme suit :

  1. Créez le Modifier.Node chargé d'appliquer l'effet de mise à l'échelle. Lorsqu'il est associé, le nœud observe la source d'interaction, comme dans les exemples précédents. La seule différence est 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 de mise à l'échelle à l'aide des mêmes commandes de dessin que celles utilisées avec n'importe quelle autre API graphique dans Compose.

    L'appel de drawContent() disponible à partir du récepteur ContentDrawScope dessinera le composant réel auquel le Indication doit être appliqué. Il vous suffit donc d'appeler cette fonction dans une transformation de mise à l'échelle. Assurez-vous que vos implémentations Indication appellent toujours drawContent() à un moment donné. Sinon, le composant auquel vous appliquez 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'y a pas de paramètres pour configurer l'indication, l'usine 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 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é. Un bouton peut ressembler à ceci :

    @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 comme suit :

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 de panier qui rétrécit lorsqu&#39;on appuie dessus
Figure 4. Bouton créé avec un Indication personnalisé.
Indication

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

Indication ne se limite pas aux effets de transformation, comme la mise à l'échelle d'un composant. Étant donné que IndicationNodeFactory renvoie un Modifier.Node, vous pouvez dessiner n'importe quel type d'effet au-dessus ou en dessous du contenu, comme avec d'autres API de dessin. Par exemple, vous pouvez dessiner une bordure animée autour du composant et une superposition au-dessus du composant lorsqu'il est appuyé :

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

L'implémentation Indication 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 conceptuellement la même, 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 au-dessus du 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 réside dans le fait qu'il existe désormais une durée minimale pour l'animation avec la fonction animateToResting(). Ainsi, même si l'utilisateur relâche immédiatement le doigt, l'animation de pression se poursuit. Il existe également une gestion des pressions rapides multiples au début de animateToPressed : si une pression se produit pendant une animation de pression ou de repos existante, l'animation précédente est annulée et l'animation de pression recommence depuis le début. Pour prendre en charge plusieurs effets simultanés (comme les ondulations, où une nouvelle animation d'ondulation se dessine au-dessus des autres), vous pouvez suivre les animations dans une liste, au lieu d'annuler les animations existantes et d'en démarrer de nouvelles.