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ôle | Durée | Courbe |
|---|---|---|
| Feedback tactile (pop, press) | spring damping 15-18 | — |
| Apparitions (cartes, explication) | 200-260 ms | FadeInDown springifié |
| Sorties | 120-180 ms (toujours PLUS COURT) | FadeOut/SlideOut |
| Transitions de contenu | 260 ms | SlideIn/Out directionnel |
| Célébrations | 700-900 ms | easeOut + 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 (
useReducedMotionde 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.current → Haptics.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.