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;DR — react-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.
onDeletebranche 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 —
activeOffsetXet 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.