Comprendre les gestes

Plusieurs termes et concepts sont importants à comprendre 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 approfondit également la consommation et la propagation des événements.

Définitions

Pour comprendre les différents concepts de cette page, vous devez connaître 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 consiste à interagir avec l'écran tactile avec votre doigt. Vous pouvez aussi utiliser un stylet pour remplacer votre doigt. Pour 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" 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 le pointeur, comme placer 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és comme une action unique. Par exemple, un geste d'appui peut être considéré comme une séquence d'un événement "vers le bas" suivi d'un événement "vers le haut". Certains gestes courants 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 la gestion des gestes. La compatibilité des composants se trouve au niveau supérieur. 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 geste 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, basez-vous sur le niveau d'abstraction le plus élevé qui offre les fonctionnalités 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.

Prise en charge des composants

De nombreux composants prêts à l'emploi de 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 permettant d'ignorer un élément. Ce type de gestion des gestes fonctionne automatiquement.

En plus de 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. 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 correspond à votre cas d'utilisation, privilégiez les gestes inclus dans les composants, car ils offrent une compatibilité prête à l'emploi pour la mise au point et l'accessibilité, et ils sont bien testés. Par exemple, un élément Button est marqué d'une manière spéciale afin que les services d'accessibilité la décrivent correctement en tant que bouton, plutôt qu'en tant qu'élément cliquable:

// 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 geste à n'importe quel composable arbitraire pour que le composable écoute des gestes. Par exemple, vous pouvez laisser un Box générique gérer les gestes d'appui en le définissant sur 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 geste prêts à l'emploi à la gestion des gestes personnalisés. 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 également des informations sémantiques, des indications visuelles sur les interactions, le survol, 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.

Ajout d'un geste personnalisé à des composables arbitraires avec le modificateur pointerInput.

Tous les gestes ne sont pas mis en œuvre avec un modificateur de geste prêt à l'emploi. Par exemple, vous ne pouvez pas utiliser de modificateur pour réagir à un déplacement après un appui prolongé, un clic en maintenant la touche Ctrl ou un appui avec trois doigts. À la place, vous pouvez é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 de 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 événements appropriés sont consignés.
  • awaitPointerEventScope crée un champ d'application de coroutine qui peut être utilisé pour attendre des événements de pointeur.
  • awaitPointerEvent suspend la coroutine jusqu'à ce qu'un événement de pointeur suivant se produise.

Bien que l'écoute d'é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 tous les gestes

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. Le AwaitPointerEventScope fournit des méthodes d'écoute:

Il s'agit de détecteurs de niveau supérieur. Vous ne pouvez donc pas ajouter plusieurs détecteurs dans un même modificateur pointerInput. L'extrait de code suivant ne détecte que les appuis, pas les déplacements:

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 traverse chaque événement brut. La méthode awaitEachGesture redémarre le bloc qui le contient une fois que tous les pointeurs ont été levés, indiquant ainsi 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 souhaiterez presque toujours utiliser awaitEachGesture, sauf si vous répondez à des événements de pointeur sans identifier de gestes. hoverable en est un exemple. Il ne répond pas aux événements de pointeur vers le bas ou vers le haut. Il a juste besoin de savoir quand un pointeur entre dans ses limites ou en sort.

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

Il existe un ensemble de méthodes permettant d'identifier les parties courantes des gestes:

Appliquer des calculs pour les événements multitouch

Lorsqu'un utilisateur effectue un geste multipoint à l'aide de plusieurs pointeurs, il est complexe de comprendre la transformation requise en fonction des valeurs brutes. Si le modificateur transformable ou les méthodes detectTransformGestures ne vous offrent pas suffisamment de contrôle 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.

Envoi d'événements et tests de positionnement

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

  • Les événements de pointeur sont envoyés à une hiérarchie modulable. Dès qu'un nouveau pointeur déclenche son premier événement de pointeur, le système commence à tester les composants "éligibles". Un composable est considéré comme éligible lorsqu'il possède des fonctionnalités de gestion des entrées de pointeur. Le test de positionnement s'exécute du haut vers le bas de l'arborescence de l'interface utilisateur. Un composable est un "appel" lorsque l'événement de pointeur s'est produit dans les limites de ce composable. Ce processus se traduit par une chaîne de composables dont les tests de positionnement ont donné un résultat positif.
  • Par défaut, lorsqu'il existe plusieurs composables éligibles au même niveau de l'arborescence, seul le composable ayant le z-index le plus élevé est "hit". Par exemple, lorsque vous ajoutez deux composables Button qui se chevauchent à un Box, seul celui dessiné en haut reçoit des événements de pointeur. Vous pouvez théoriquement ignorer ce comportement en créant votre propre implémentation de PointerInputModifierNode et en définissant sharePointerInputWithSiblings sur "true".
  • Les autres événements pour le même pointeur sont envoyés à cette même chaîne de composables et sont diffusés en fonction de la logique de propagation des événements. Le système n'effectue plus de test de positionnement pour ce pointeur. Cela signifie que chaque composable de la chaîne reçoit tous les événements pour ce pointeur, même s'ils se produisent en dehors des limites de ce composable. Les composables qui ne font pas partie de la chaîne ne reçoivent jamais d'événements de pointeur, même lorsque celui-ci se trouve en dehors de leurs limites.

Les événements de pointage, déclenchés par un survol avec la souris ou un stylet, font exception aux règles définies ici. Les événements de pointage de la souris sont envoyés à tous les composables auxquels ils s'appliquent. Ainsi, lorsqu'un utilisateur pointe un pointeur depuis les limites d'un composable vers le suivant, au lieu d'envoyer les événements au premier composable, les événements sont envoyés au nouveau composable.

Consommation d'événements

Lorsqu'un gestionnaire de gestes est attribué à plusieurs composables, ces gestionnaires ne doivent pas entrer en conflit. Par exemple, jetons un coup d'œil à l'interface utilisateur suivante:

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

Lorsqu'un utilisateur appuie sur le bouton des favoris, le lambda onClick du bouton gère ce geste. Lorsqu'un utilisateur appuie sur une autre partie de l'élément de la 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 geste courants incluent ce comportement de consommation, mais 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'interrompt pas sa propagation aux 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é utilisé 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 des événements

Comme indiqué précédemment, les modifications du pointeur sont transmises à chaque composable auquel il s'applique. 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 correspond à ListItem, la deuxième couche contient une image, une colonne et un bouton, et la colonne se divise en deux éléments textuels. "ListItem" et "Button" sont mis en surbrillance.

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

  • Dans la carte initiale, l'événement circule du haut de l'arborescence de l'interface utilisateur vers le bas. Ce flux permet à un parent d'intercepter un événement avant que l'enfant ne puisse le consommer. Par exemple, les info-bulles doivent intercepter une pression prolongée au lieu de la transmettre à leurs enfants. Dans notre exemple, ListItem reçoit l'événement avant Button.
  • Dans la carte principale, l'événement circule depuis les nœuds feuilles de l'arborescence d'UI jusqu'à la racine de cette arborescence. Cette phase est l'endroit où vous utilisez normalement les gestes. Il s'agit de la passe par défaut lors de l'écoute d'événements. La gestion des gestes dans cette passe signifie que les nœuds feuilles sont prioritaires 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.
  • Dans la passe finale, l'événement est transmis une fois de plus du haut de l'arborescence de l'interface utilisateur vers les 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'une pression devient un déplacement 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 identique est renvoyé par chacun de ces appels de méthode d'attente, bien que les données concernant 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 le pincement ou un clic long) ou des gestes de niveau inférieur (comme déplacer le curseur d'une certaine quantité de pixels):

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

Consultez la documentation performTouchInput pour obtenir d'autres exemples.

En savoir plus

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