Skip to Content

Chapitre 10.5 — Micro-interactions : la couche finale

Où on en est : timer animé, gestes — reste la poussière d’or : les entrées/sorties, les enchaînements de question, l’écran nouveau record. Les détails qui, cumulés, font le « premium ».

⏱️ TL;DR — Les Layout Animations de Reanimated (entering/exiting/layout) couvrent 80 % des micro-interactions en UNE prop : l’explication qui apparaît (FadeInDown), la question suivante qui glisse (SlideIn/Out), les lignes d’historique qui se réordonnent (LinearTransition). Pour le récital final — l’écran record — on compose : stagger des éléments, compteur de score qui « roule », et un burst de confettis maison en 40 lignes.

🎯 Objectifs

  • Systématiser entering/exiting/layout sur les bons moments de Drill.
  • Enchaîner les questions avec une transition directionnelle propre.
  • Construire l’écran record : stagger, score qui compte, confettis.
  • Poser la charte animation de l’app (durées, distances — la cohérence).

Layout Animations : le rappel qui devient système

import Animated, { FadeInDown, FadeOut, LinearTransition } from 'react-native-reanimated' // L'explication après une réponse (ch. 6.3) — UNE prop : {phase.name === 'answered' && ( <Animated.View entering={FadeInDown.duration(220).springify()} exiting={FadeOut.duration(120)}> <ExplanationCard /> </Animated.View> )} // Les lignes d'historique qui se réordonnent après suppression : <Animated.View layout={LinearTransition.springify()}> <SwipeableSessionRow /> </Animated.View>

Le catalogue utile : FadeIn/Out(Down/Up), SlideInRight/OutLeft, ZoomIn/Out — chacun accepte .duration(), .delay(), .springify(). Et layout={…} anime les repositionnements (quand un frère apparaît/disparaît) — le polish gratuit des listes.

L’enchaînement des questions

Le moment le plus fréquent du jeu mérite une transition directionnelle : la question sortante glisse à gauche, l’entrante arrive de droite. L’astuce : une key qui change force le remontage, et entering/exiting font le reste :

// app/quiz.tsx — le bloc question <Animated.View key={question.id} // ← nouvelle question = nouveau composant entering={SlideInRight.duration(260).springify().damping(18)} exiting={SlideOutLeft.duration(180)} className="flex-1 justify-center" > <AppText variant="heading">{question.prompt}</AppText> </Animated.View>

Pattern à retenir : key + entering/exiting = transition entre contenus — le poor man’s navigator, parfait pour les carrousels d’états internes (sans navigation réelle).

L’écran nouveau record — le récital

Trois techniques composées, dans l’ordre d’apparition :

1. Le stagger d’entrée — chaque élément arrive avec un délai croissant :

<Animated.View entering={FadeInDown.delay(0)}>{/* « 🏆 Nouveau record ! » */}</Animated.View> <Animated.View entering={FadeInDown.delay(120)}>{/* le score */}</Animated.View> <Animated.View entering={FadeInDown.delay(240)}>{/* les stats */}</Animated.View> <Animated.View entering={FadeInDown.delay(360)}>{/* les boutons */}</Animated.View>

2. Le score qui roule — de 0 au score final, sur le thread JS cette fois (c’est un texte, 4-5 rafraîchissements suffisent… non : ~30 pour l’effet — un state animé par timing JS est acceptable ICI car l’écran est statique) :

function RollingScore({ target }: { target: number }) { const [display, setDisplay] = useState(0) useEffect(() => { const start = Date.now() const D = 900 const id = setInterval(() => { const t = Math.min(1, (Date.now() - start) / D) const eased = 1 - Math.pow(1 - t, 3) // easeOutCubic setDisplay(Math.round(target * eased)) if (t >= 1) clearInterval(id) }, 1000 / 30) return () => clearInterval(id) }, [target]) return <AppText variant="title" className="text-5xl">{display} pts</AppText> }

(L’alternative thread-UI pure — useDerivedValue + ReText — reste la référence si l’écran devait s’animer par ailleurs ; ici le JS est au repos, 30 Hz passent sans effort. Choisir en conscience, c’est le chapitre 10.3, exercice 3.)

3. Les confettis maison — 40 lignes, zéro lib :

// components/ConfettiBurst.tsx const COLORS = ['#58c4dc', '#fbbf24', '#22c55e', '#ef4444', '#a78bfa'] const PIECES = 24 function Piece({ index }: { index: number }) { const progress = useSharedValue(0) const angle = (index / PIECES) * 2 * Math.PI + (index % 3) * 0.2 const distance = 120 + (index % 5) * 30 const color = COLORS[index % COLORS.length] useEffect(() => { progress.value = withDelay(index * 12, withTiming(1, { duration: 700, easing: Easing.out(Easing.quad) })) }, []) const style = useAnimatedStyle(() => ({ opacity: 1 - progress.value, transform: [ { translateX: Math.cos(angle) * distance * progress.value }, { translateY: Math.sin(angle) * distance * progress.value + 60 * progress.value ** 2 }, // gravité { rotate: `${progress.value * 540}deg` }, ], })) return ( <Animated.View style={[{ position: 'absolute', width: 8, height: 12, borderRadius: 2, backgroundColor: color }, style]} /> ) } export function ConfettiBurst() { return ( <View pointerEvents="none" className="absolute inset-0 items-center justify-center"> {Array.from({ length: PIECES }, (_, i) => <Piece key={i} index={i} />)} </View> ) }

24 pièces, chacune : direction radiale + gravité (le ** 2), rotation, fondu — toutes sur le thread UI. pointerEvents="none" : la fête ne bloque pas les boutons. Déclenché par isNewRecord (ch. 8.3), avec feedback.newRecord() (ch. 9.3) — trois parties du cours dans une seule seconde d’écran.

La charte animation de Drill

La cohérence bat la créativité — on fige les constantes :

RôleDuréeCourbe
Feedback tactile (pop, press)spring damping 15-18
Apparitions (cartes, explication)200-260 msFadeInDown springifié
Sorties120-180 ms (toujours PLUS COURT)FadeOut/SlideOut
Transitions de contenu260 msSlideIn/Out directionnel
Célébrations700-900 mseaseOut + stagger 120 ms

La règle d’or qui les résume : sortir plus vite qu’entrer (l’utilisateur attend la suite), et une seule célébration par écran (le record a des confettis ; une bonne réponse a un haptic — hiérarchie).

⚠️ Piège — Le test ultime des micro-interactions : activez « Supprimer les animations » dans les paramètres d’accessibilité Android, et vérifiez que Drill reste 100 % utilisable (useReducedMotion de Reanimated permet d’adapter — les Layout Animations le respectent déjà largement). Une app qui a BESOIN de ses animations pour être comprise a un problème de design, pas d’animation.

✏️ Exercices

1. Appliquez la charte : auditez les écrans existants de Drill et posez entering/exiting/ layout partout où la charte le prescrit (liste : explication, cartes de stats, lignes d’historique, toast d’annulation, badge streak).

✅ Solution

Explication : FadeInDown 220/FadeOut 120 (fait). Stats de l’historique : FadeInDown staggeré (delay 0/80/160). Lignes d’historique : layout={LinearTransition} (réordonnancement post- suppression). Toast : SlideInDown/SlideOutDown 200/150. Badge streak (≥ 2) : ZoomIn springifié — il « pop » quand la série monte. Total : ~10 props, zéro shared value — c’est la promesse des Layout Animations.

2. Le RollingScore doit s’accompagner d’un tick haptic tous les ~20 % de progression. Implémentez SANS casser l’easing.

✅ Solution

Dans le setInterval, un useRef lastBucket : const bucket = Math.floor(eased * 5) ; si bucket > lastBucket.currentHaptics.selectionAsync() + mise à jour du ref. L’easing fait naturellement accélérer puis ralentir les ticks — le crescendo perçu vient gratuitement de la courbe. (Et le tout respecte hapticsEnabled via feedback/selection wrappé.)

3. Votre ConfettiBurst rejoue à chaque re-render de l’écran si isNewRecord reste true. Reproduisez le bug, expliquez, corrigez.

✅ Solution

Le useEffect de chaque Piece relance au REMONTAGE — or un re-render parent ne remonte pas les Pieces (même key)… sauf si le parent conditionne {isNewRecord && <ConfettiBurst />} après un état qui bascule (ex. modal). Le fix robuste : tirer une fois const [burstKey] = useState(() => Date.now()) — non : plus simple, monter ConfettiBurst UNE fois avec key stable et ne jamais le re-conditionner ; ou le déclencher impérativement (state burst: number, key={burst}, incrémenté à l’arrivée sur l’écran). La leçon générale : une célébration est un ÉVÉNEMENT (déclenchement explicite), pas un état (rendu conditionnel) — modéliser les one-shots par des keys/compteurs, pas des booléens.

🧠 Quiz

1. Quelles sont les trois props de Layout Animations et leurs rôles ?

Réponse

entering (au montage), exiting (au démontage — le composant est gardé le temps de l’animation), layout (au repositionnement quand l’entourage change).

2. Comment anime-t-on une transition entre deux questions ?

Réponse

key={question.id} force le remontage → entering={SlideInRight} + exiting={SlideOutLeft} font la transition directionnelle. Le pattern key + entering/exiting.

3. Pourquoi les sorties sont-elles plus courtes que les entrées ?

Réponse

L’utilisateur attend la SUITE : ce qui part ne doit pas retenir ce qui arrive. Charte Drill : sorties 120-180 ms, entrées 200-260 ms.

4. Comment les confettis obtiennent-ils leur trajectoire « physique » ?

Réponse

Une seule valeur progress 0→1 par pièce, et le worklet dérive tout : direction radiale (cos/sin × distance), gravité en progress², rotation, fondu — la physique est dans les maths du style, pas dans l’animation.

5. Quel réglage système doit-on tester pour l’accessibilité des animations ?

Réponse

« Supprimer les animations » (reduced motion) : l’app doit rester pleinement utilisable — useReducedMotion permet d’adapter, et une app dépendante de ses animations a un problème de design.


👉 Partie suivante : Partie 11 — Development builds — on sort d’Expo Go : votre propre app de développement, la porte vers les builds et la distribution.