Skip to Content

Chapitre 10.4 — Gestes : le doigt pilote l’interface

Où on en est : les animations réagissent aux ÉTATS. Le niveau au-dessus : réagir au DOIGT, en continu — swipe, drag, avec la physique du lâcher. L’outil : Gesture Handler + Reanimated, le duo inséparable.

⏱️ TL;DRreact-native-gesture-handler capte les gestes CÔTÉ NATIF ; ses callbacks sont des worklets → le doigt pilote des shared values à 120 Hz sans toucher le thread JS. L’API moderne : Gesture.Pan() + GestureDetector. Le pattern complet sur LE cas mobile canonique : swipe-to-delete d’une session de l’historique — suivi du doigt, seuil, snap avec spring, suppression.

🎯 Objectifs

  • Configurer GestureHandlerRootView (déjà fait ? vérifier) et l’API Gesture.
  • Écrire un Pan : onBegin/onChange/onEnd, le pattern « offset mémorisé ».
  • Implémenter le swipe-to-delete complet de l’historique (seuil, snap, délétion).
  • Savoir composer les gestes entre eux et avec le scroll d’une liste.

Pourquoi Pressable ne suffit plus

Pressable comprend le tap et le long press — des gestes discrets (ils arrivent ou pas). Un swipe est continu : chaque millimètre du doigt doit déplacer la vue, à la frame près. Faire ça avec les événements JS (onTouchMove) = latence et saccades garanties (le pont, le thread…). Gesture Handler règle le problème à la racine : la reconnaissance ET le suivi vivent côté natif, et grâce à Reanimated, VOTRE logique de suivi aussi (worklets).

Vérification d’install (le template Expo Router l’inclut) : le layout racine doit être enveloppé — sinon rien ne marche silencieusement :

// app/_layout.tsx import { GestureHandlerRootView } from 'react-native-gesture-handler' <GestureHandlerRootView style={{ flex: 1 }}> {/* providers + Stack */} </GestureHandlerRootView>

Le Pan de base — le pattern des trois temps

import { Gesture, GestureDetector } from 'react-native-gesture-handler' const offset = useSharedValue(0) // la position COURANTE const start = useSharedValue(0) // la position au DÉBUT du geste const pan = Gesture.Pan() .onBegin(() => { start.value = offset.value // mémoriser d'où on part }) .onChange(e => { offset.value = start.value + e.translationX // suivre le doigt }) .onEnd(e => { offset.value = withSpring(0, { velocity: e.velocityX }) // relâcher : physique }) const style = useAnimatedStyle(() => ({ transform: [{ translateX: offset.value }] })) <GestureDetector gesture={pan}> <Animated.View style={style}>…</Animated.View> </GestureDetector>

Les trois temps universels : mémoriser (onBegin), suivre (onChange — translationX est relatif au début du geste, d’où le start mémorisé), décider (onEnd — avec velocityX injectée dans le spring : le lâcher hérite de l’élan du doigt, LE détail qui fait « vrai »). Tous ces callbacks sont des worklets automatiquement — thread UI de bout en bout.

Le swipe-to-delete de l’historique

Le cas complet, avec seuil de décision et action destructive :

// components/SwipeableSessionRow.tsx const DELETE_THRESHOLD = -80 export function SwipeableSessionRow({ session, onDelete }: Props) { const offset = useSharedValue(0) const start = useSharedValue(0) const pan = Gesture.Pan() .activeOffsetX([-10, 10]) // ← ne s'active qu'après 10 px HORIZONTAUX .onBegin(() => { start.value = offset.value }) .onChange(e => { offset.value = Math.min(0, start.value + e.translationX) // gauche seulement }) .onEnd(() => { if (offset.value < DELETE_THRESHOLD) { offset.value = withTiming(-400, { duration: 180 }, f => { if (f) runOnJS(onDelete)(session.id) // ch. 10.2 : le pont vers React }) } else { offset.value = withSpring(0) // pas assez loin : retour } }) const rowStyle = useAnimatedStyle(() => ({ transform: [{ translateX: offset.value }] })) const binStyle = useAnimatedStyle(() => ({ opacity: Math.min(1, -offset.value / -DELETE_THRESHOLD), // la corbeille se révèle })) return ( <View> <Animated.View className="absolute inset-y-0 right-4 justify-center" style={binStyle}> <AppText className="text-2xl">🗑️</AppText> </Animated.View> <GestureDetector gesture={pan}> <Animated.View style={rowStyle}> <SessionRow session={session} /> </Animated.View> </GestureDetector> </View> ) }

Les décisions de design encodées :

  • activeOffsetX([-10, 10]) : le geste ne « capture » qu’après 10 px horizontaux — en dessous, c’est le SCROLL de la FlatList qui gagne. LA ligne qui fait cohabiter swipe et liste (sans elle : liste inscrollable ou swipe impossible).
  • Seuil + deux issues : au lâcher, soit on dépasse le seuil (sortie animée puis runOnJS(onDelete)), soit spring retour. Jamais de suppression « pendant » le geste.
  • La corbeille se révèle proportionnellement — le feedback continu qui annonce la conséquence avant qu’elle soit irréversible.
  • onDelete branche la chaîne existante : mutation → SQLite → invalidation (ch. 8.3). Le geste est une COUCHE, la logique n’a pas bougé.

Composer les gestes

L’API compose proprement quand plusieurs gestes cohabitent :

Gesture.Simultaneous(pan, pinch) // les deux en même temps (cartes, images) Gesture.Race(pan, longPress) // le premier activé gagne Gesture.Exclusive(doubleTap, tap) // priorité au premier (double-tap AVANT tap)

Drill n’en a pas besoin — mais le zoom-pan d’une image (Notes P19) ou le double-tap-like, si. Retenez que ça existe et où c’est documenté.

⚠️ Piège — Un swipe-to-delete SANS confirmation doit être annulable : le pattern mobile standard est le toast « Session supprimée — Annuler » (5 s). Implémentation saine : suppression optimiste de l’UI, délétion SQLite différée (ou ré-insertion au tap Annuler). Une action destructive au geste + aucune récupération = la recette des avis 1 étoile.

🍏 Côté iOS — Tout ce chapitre est cross-platform tel quel. Seule nuance : le geste back iOS (swipe depuis le bord gauche) peut entrer en conflit avec un Pan plein écran — activeOffsetX et les zones de déclenchement règlent ça. Sur Android, le back géstuel système vit sur les bords : mêmes précautions.

✏️ Exercices

1. Branchez SwipeableSessionRow dans l’historique avec le toast d’annulation (suppression optimiste + restauration).

✅ Solution

État local pendingDelete: SessionRecord | null : onDelete retire la ligne de l’affichage (filtre local ou invalidation retardée), montre un toast maison 5 s (View absolute en bas + Layout Animation). « Annuler » → ré-affiche (rien n’a touché la base). Timeout écoulé → deleteSession(db, id) + invalidation réelle. La base n’est touchée qu’à la CONFIRMATION implicite — l’optimisme est dans l’UI, pas dans les données.

2. Ajoutez une résistance : au-delà du seuil, le doigt « tire » de moins en moins (la ligne suit à 30 % seulement). Pourquoi cette friction améliore-t-elle le geste ?

✅ Solution

.onChange(e => { const raw = start.value + e.translationX offset.value = raw > DELETE_THRESHOLD ? Math.min(0, raw) : DELETE_THRESHOLD + (raw - DELETE_THRESHOLD) * 0.3 })

La friction communique physiquement « tu as dépassé le point de décision » — le seuil se SENT sans être regardé. Les meilleurs gestes s’expliquent par le toucher, pas par la doc.

3. Pourquoi Math.min(0, …) dans onChange, et que se passerait-il sans ? Testez.

✅ Solution

Il interdit le swipe vers la DROITE (offset > 0) : sans lui, la ligne se déplace à droite en révélant… rien (pas de corbeille de ce côté), puis snap retour — un geste possible sans signification = de la confusion gratuite. Contraindre l’espace du geste à ce qui a du sens fait partie du design du geste.

🧠 Quiz

1. Pourquoi Gesture Handler plutôt que les événements onTouch* de React ?

Réponse

Reconnaissance et suivi côté natif, callbacks en worklets (thread UI) : le doigt pilote les shared values à 120 Hz sans passer par le thread JS — zéro latence, zéro saccade.

2. Récitez le pattern des trois temps d’un Pan.

Réponse

onBegin : mémoriser la position de départ ; onChange : suivre (start + translation) ; onEnd : décider (seuil → action ou spring retour, avec la vélocité du doigt).

3. Que règle activeOffsetX([-10, 10]) ?

Réponse

Le conflit geste/scroll : le Pan ne capture qu’après 10 px de déplacement horizontal — en deçà, la FlatList scrolle normalement. La ligne de cohabitation.

4. Pourquoi injecter e.velocityX dans le spring du lâcher ?

Réponse

Le retour hérite de l’élan du doigt : un lâcher rapide part vite puis s’amortit, un lâcher lent revient doucement — la continuité physique qui fait « vrai ».

5. Quel filet exige toute action destructive au geste ?

Réponse

L’annulation : toast « Supprimé — Annuler » avec suppression optimiste de l’UI et écriture différée (ou restauration). Un swipe irréversible est un bug d’UX.


👉 Chapitre suivant : 10.5 — Micro-interactions — la dernière couche : entrées, sorties, et l’écran nouveau record.