Comprendre les gestes

Il est important de comprendre plusieurs termes et concepts lorsque vous travaillez sur la gestion des gestes dans une application. Cette page explique les termes "pointeurs", "événements de pointeur" et "gestes", et présente les différents niveaux d'abstraction pour les gestes. Il examine également plus en détail la consommation et la propagation des événements.

Définitions

Pour comprendre les différents concepts de cette page, vous devez comprendre une partie de la terminologie utilisée :

  • Pointeur : objet physique que vous pouvez utiliser pour interagir avec votre application. Pour les appareils mobiles, le pointeur le plus courant est votre doigt qui interagit avec l'écran tactile. Vous pouvez également utiliser un stylet à la place de votre doigt. Sur les grands écrans, vous pouvez utiliser une souris ou un pavé tactile pour interagir indirectement avec l'écran. Un périphérique d'entrée doit pouvoir "pointer" vers une coordonnée pour être considéré comme un pointeur. Par exemple, un clavier ne peut pas être considéré comme un pointeur. Dans Compose, le type de pointeur est inclus dans les modifications de pointeur à l'aide de PointerType.
  • Événement de pointeur : décrit une interaction de bas niveau d'un ou de plusieurs pointeurs avec l'application à un moment donné. Toute interaction avec un pointeur, comme poser un doigt sur l'écran ou faire glisser une souris, déclenche un événement. Dans Compose, toutes les informations pertinentes pour un tel événement sont contenues dans la classe PointerEvent.
  • Geste : séquence d'événements de pointeur pouvant être interprétée comme une seule action. Par exemple, un geste d'appui peut être considéré comme une séquence d'un événement d'appui suivi d'un événement de relâchement. De nombreux gestes sont utilisés par de nombreuses applications, comme appuyer, faire glisser ou transformer, mais vous pouvez également créer votre propre geste personnalisé si nécessaire.

Différents niveaux d'abstraction

Jetpack Compose fournit différents niveaux d'abstraction pour gérer les gestes. Au niveau supérieur se trouve la compatibilité des composants. Les composables tels que Button incluent automatiquement la prise en charge des gestes. Pour ajouter la prise en charge des gestes aux composants personnalisés, vous pouvez ajouter des modificateurs de gestes tels que clickable à des composables arbitraires. Enfin, si vous avez besoin d'un geste personnalisé, vous pouvez utiliser le modificateur pointerInput.

En règle générale, privilégiez le niveau d'abstraction le plus élevé offrant la fonctionnalité dont vous avez besoin. De cette façon, vous bénéficiez des bonnes pratiques incluses dans la couche. Par exemple, Button contient plus d'informations sémantiques, utilisées pour l'accessibilité, que clickable, qui contient plus d'informations qu'une implémentation pointerInput brute.

Compatibilité des composants

De nombreux composants prêts à l'emploi dans Compose incluent une sorte de gestion interne des gestes. Par exemple, un LazyColumn répond aux gestes de déplacement en faisant défiler son contenu, un Button affiche une ondulation lorsque vous appuyez dessus, et le composant SwipeToDismiss contient une logique de balayage pour ignorer un élément. Ce type de gestion des gestes fonctionne automatiquement.

Outre la gestion interne des gestes, de nombreux composants nécessitent également que l'appelant gère le geste. Par exemple, un Button détecte automatiquement les appuis et déclenche un événement de clic. Vous transmettez un lambda onClick à Button pour réagir au geste. De même, vous ajoutez un lambda onValueChange à un Slider pour réagir lorsque l'utilisateur fait glisser la poignée du curseur.

Lorsque cela est adapté à votre cas d'utilisation, privilégiez les gestes inclus dans les composants, car ils incluent une compatibilité prête à l'emploi pour la sélection et l'accessibilité, et ont été testés de manière approfondie. Par exemple, un Button est marqué d'une manière spéciale afin que les services d'accessibilité le décrivent correctement comme un bouton, et non comme un élément cliquable quelconque :

// Talkback: "Click me!, Button, double tap to activate"
Button(onClick = { /* TODO */ }) { Text("Click me!") }
// Talkback: "Click me!, double tap to activate"
Box(Modifier.clickable { /* TODO */ }) { Text("Click me!") }

Pour en savoir plus sur l'accessibilité dans Compose, consultez Accessibilité dans Compose.

Ajouter des gestes spécifiques à des composables arbitraires avec des modificateurs

Vous pouvez appliquer des modificateurs de gestes à n'importe quel composable pour qu'il écoute les gestes. Par exemple, vous pouvez laisser un Box générique gérer les gestes d'appui en le rendant clickable, ou laisser un Column gérer le défilement vertical en appliquant verticalScroll.

Il existe de nombreux modificateurs pour gérer différents types de gestes :

En règle générale, privilégiez les modificateurs de gestes prêts à l'emploi plutôt que la gestion personnalisée des gestes. Les modificateurs ajoutent des fonctionnalités en plus de la gestion pure des événements de pointeur. Par exemple, le modificateur clickable ajoute non seulement la détection des pressions et des appuis, mais aussi des informations sémantiques, des indications visuelles sur les interactions, le pointage, la sélection et la compatibilité avec le clavier. Vous pouvez consulter le code source de clickable pour voir comment la fonctionnalité est ajoutée.

Ajoutez un geste personnalisé à des composables arbitraires avec le modificateur pointerInput.

Tous les gestes ne sont pas implémentés avec un modificateur de geste prêt à l'emploi. Par exemple, vous ne pouvez pas utiliser de modificateur pour réagir à un glissement après un appui prolongé, un clic avec la touche Ctrl ou un appui à trois doigts. Vous pouvez plutôt écrire votre propre gestionnaire de gestes pour identifier ces gestes personnalisés. Vous pouvez créer un gestionnaire de gestes avec le modificateur pointerInput, qui vous donne accès aux événements de pointeur bruts.

Le code suivant écoute les événements de pointeur bruts :

@Composable
private fun LogPointerEvents(filter: PointerEventType? = null) {
    var log by remember { mutableStateOf("") }
    Column {
        Text(log)
        Box(
            Modifier
                .size(100.dp)
                .background(Color.Red)
                .pointerInput(filter) {
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent()
                            // handle pointer event
                            if (filter == null || event.type == filter) {
                                log = "${event.type}, ${event.changes.first().position}"
                            }
                        }
                    }
                }
        )
    }
}

Si vous décomposez cet extrait, les principaux composants sont les suivants :

  • Le modificateur pointerInput. Vous lui transmettez une ou plusieurs clés. Lorsque la valeur de l'une de ces clés change, le lambda du contenu du modificateur est réexécuté. L'exemple transmet un filtre facultatif au composable. Si la valeur de ce filtre change, le gestionnaire d'événements de pointeur doit être réexécuté pour s'assurer que les bons événements sont enregistrés.
  • awaitPointerEventScope crée un champ d'application de coroutine qui peut être utilisé pour attendre les événements de pointeur.
  • awaitPointerEvent suspend la coroutine jusqu'à ce qu'un événement de pointeur suivant se produise.

Bien que l'écoute des événements d'entrée bruts soit puissante, il est également complexe d'écrire un geste personnalisé basé sur ces données brutes. Pour simplifier la création de gestes personnalisés, de nombreuses méthodes utilitaires sont disponibles.

Détecter les gestes complets

Au lieu de gérer les événements de pointeur bruts, vous pouvez écouter des gestes spécifiques et y répondre de manière appropriée. AwaitPointerEventScope fournit des méthodes pour écouter :

Il s'agit de détecteurs de premier niveau. Vous ne pouvez donc pas ajouter plusieurs détecteurs dans un seul modificateur pointerInput. L'extrait de code suivant ne détecte que les gestes d'appui, et non les gestes de déplacement :

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
                // Never reached
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

En interne, la méthode detectTapGestures bloque la coroutine, et le deuxième détecteur n'est jamais atteint. Si vous devez ajouter plusieurs écouteurs de gestes à un composable, utilisez plutôt des instances de modificateur pointerInput distinctes :

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
            }
            .pointerInput(Unit) {
                // These drag events will correctly be triggered
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

Gérer les événements par geste

Par définition, les gestes commencent par un événement de pointeur vers le bas. Vous pouvez utiliser la méthode d'assistance awaitEachGesture au lieu de la boucle while(true) qui passe par chaque événement brut. La méthode awaitEachGesture redémarre le bloc conteneur lorsque tous les pointeurs ont été levés, ce qui indique que le geste est terminé :

@Composable
private fun SimpleClickable(onClick: () -> Unit) {
    Box(
        Modifier
            .size(100.dp)
            .pointerInput(onClick) {
                awaitEachGesture {
                    awaitFirstDown().also { it.consume() }
                    val up = waitForUpOrCancellation()
                    if (up != null) {
                        up.consume()
                        onClick()
                    }
                }
            }
    )
}

En pratique, vous devez presque toujours utiliser awaitEachGesture, sauf si vous répondez à des événements de pointeur sans identifier de gestes. C'est le cas de hoverable, qui ne répond pas aux événements de pression ou de relâchement du pointeur, mais qui a simplement besoin de savoir quand un pointeur entre dans ses limites ou en sort.

Attendre un événement ou un sous-geste spécifique

Un ensemble de méthodes permet d'identifier les parties communes des gestes :

Appliquer des calculs pour les événements multitouch

Lorsqu'un utilisateur effectue un geste à plusieurs doigts à l'aide de plusieurs pointeurs, il est difficile de comprendre la transformation requise en fonction des valeurs brutes. Si le modificateur transformable ou les méthodes detectTransformGestures ne fournissent pas un contrôle suffisamment précis pour votre cas d'utilisation, vous pouvez écouter les événements bruts et leur appliquer des calculs. Ces méthodes d'assistance sont calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation et calculateZoom.

Distribution d'événements et test de positionnement

Tous les événements de pointeur ne sont pas envoyés à tous les modificateurs pointerInput. La distribution des événements fonctionne comme suit :

  • Les événements de pointeur sont envoyés à une hiérarchie de composables. Dès qu'un nouveau pointeur déclenche son premier événement de pointeur, le système commence à tester les composables "éligibles". Un composable est considéré comme éligible lorsqu'il dispose de fonctionnalités de gestion des entrées de pointeur. Le test de positionnement s'effectue de haut en bas de l'arborescence de l'UI. Un composable est "touché" lorsque l'événement de pointeur se produit dans les limites de ce composable. Ce processus génère une chaîne de composables qui réussissent le test de positionnement.
  • Par défaut, lorsqu'il existe plusieurs composables éligibles au même niveau de l'arborescence, seul le composable avec l'index Z le plus élevé est "atteint". Par exemple, lorsque vous ajoutez deux composables Button qui se chevauchent à un Box, seul celui dessiné au-dessus reçoit les événements de pointeur. En théorie, vous pouvez remplacer ce comportement en créant votre propre implémentation PointerInputModifierNode et en définissant sharePointerInputWithSiblings sur "true".
  • Les événements suivants pour le même pointeur sont envoyés à cette même chaîne de composables et circulent selon la logique de propagation des événements. Le système n'effectue plus de tests de positionnement pour ce pointeur. Cela signifie que chaque composable de la chaîne reçoit tous les événements de ce pointeur, même s'ils se produisent en dehors des limites de ce composable. Les composables qui ne se trouvent pas dans la chaîne ne reçoivent jamais d'événements de pointeur, même lorsque le pointeur se trouve dans leurs limites.

Les événements de pointage, déclenchés par le pointage d'une souris ou d'un stylet, constituent une exception aux règles définies ici. Les événements de pointage sont envoyés à tous les composables qu'ils touchent. Ainsi, lorsqu'un utilisateur déplace un pointeur d'un composable à un autre, les événements sont envoyés au nouveau composable au lieu d'être envoyés au premier.

Consommation d'événements

Lorsque plusieurs composables sont associés à un gestionnaire de gestes, ces gestionnaires ne doivent pas entrer en conflit. Par exemple, examinez cette UI :

Élément de liste avec une image, une colonne avec deux textes et un bouton.

Lorsqu'un utilisateur appuie sur le bouton de favori, le lambda onClick du bouton gère ce geste. Lorsqu'un utilisateur appuie sur une autre partie de l'élément de liste, ListItem gère ce geste et accède à l'article. En termes d'entrée de pointeur, le bouton doit consommer cet événement afin que son parent sache qu'il ne doit plus y réagir. Les gestes inclus dans les composants prêts à l'emploi et les modificateurs de gestes courants incluent ce comportement de consommation. Toutefois, si vous écrivez votre propre geste personnalisé, vous devez consommer les événements manuellement. Pour ce faire, utilisez la méthode PointerInputChange.consume :

Modifier.pointerInput(Unit) {

    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            // consume all changes
            event.changes.forEach { it.consume() }
        }
    }
}

La consommation d'un événement n'arrête pas sa propagation à d'autres composables. Un composable doit ignorer explicitement les événements consommés. Lorsque vous écrivez des gestes personnalisés, vous devez vérifier si un événement a déjà été consommé par un autre élément :

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.any { it.isConsumed }) {
                // A pointer is consumed by another gesture handler
            } else {
                // Handle unconsumed event
            }
        }
    }
}

Propagation d'événements

Comme indiqué précédemment, les modifications du pointeur sont transmises à chaque composable qu'il touche. Mais s'il existe plusieurs composables de ce type, dans quel ordre les événements se propagent-ils ? Si vous prenez l'exemple de la section précédente, cette UI se traduit par l'arborescence d'UI suivante, où seuls ListItem et Button répondent aux événements de pointeur :

Structure arborescente. La couche supérieure est ListItem, la deuxième couche contient Image, Column et Button, et Column se divise en deux Texts. ListItem et Button sont mis en surbrillance.

Les événements de pointeur passent par chacun de ces composables trois fois, au cours de trois "passes" :

  • Dans la passe initiale, l'événement est transmis du haut de l'arborescence de l'UI vers le bas. Ce flux permet à un parent d'intercepter un événement avant que l'enfant ne puisse l'utiliser. Par exemple, les info-bulles doivent intercepter un appui de manière prolongée au lieu de le transmettre à leurs enfants. Dans notre exemple, ListItem reçoit l'événement avant Button.
  • Dans la passe principale, l'événement circule des nœuds feuilles de l'arborescence de l'UI vers la racine de l'arborescence de l'UI. Cette phase est celle où vous consommez normalement les gestes. Il s'agit de la passe par défaut lors de l'écoute des événements. La gestion des gestes dans cette passe signifie que les nœuds feuilles ont priorité sur leurs parents, ce qui est le comportement le plus logique pour la plupart des gestes. Dans notre exemple, Button reçoit l'événement avant ListItem.
  • Lors de la dernière passe, l'événement passe une fois de plus du haut de l'arborescence de l'interface utilisateur aux nœuds feuilles. Ce flux permet aux éléments situés plus haut dans la pile de répondre à la consommation d'événements par leur parent. Par exemple, un bouton supprime son indication d'ondulation lorsqu'un appui se transforme en un glissement de son parent déroulant.

Visuellement, le flux d'événements peut être représenté comme suit :

Une fois qu'une modification d'entrée est consommée, ces informations sont transmises à partir de ce point dans le flux :

Dans le code, vous pouvez spécifier la carte qui vous intéresse :

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial)
        val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default
        val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final)
    }
}

Dans cet extrait de code, le même événement est renvoyé par chacun de ces appels de méthode await, bien que les données sur la consommation aient pu changer.

Tester les gestes

Dans vos méthodes de test, vous pouvez envoyer manuellement des événements de pointeur à l'aide de la méthode performTouchInput. Cela vous permet d'effectuer des gestes complets de niveau supérieur (comme pincer ou appuyer de manière prolongée) ou des gestes de niveau inférieur (comme déplacer le curseur d'un certain nombre de pixels) :

composeTestRule.onNodeWithTag("MyList").performTouchInput {
    swipeUp()
    swipeDown()
    click()
}

Pour obtenir d'autres exemples, consultez la documentation sur performTouchInput.

En savoir plus

Pour en savoir plus sur les gestes dans Jetpack Compose, consultez les ressources suivantes :