Chapitre 10.3 — ⭐ Le timer circulaire de Drill
Où on en est : les fondations Reanimated sont posées. On construit LA pièce visuelle de Drill : l’anneau de temps qui se vide autour du compteur de secondes — SVG animé sur le thread UI, synchronisé avec la machine d’état.
⏱️ TL;DR — Un cercle react-native-svg dont le
strokeDashoffsetest piloté par Reanimated viauseAnimatedProps(le useAnimatedStyle des props SVG). UNE animationwithTiming(0, { duration: totalMs, easing: linear })lancée au début de chaque question — pas de tick : l’horloge visuelle vit côté UI, l’horloge de jeu (endsAt, ch. 6.3) reste la vérité. La couleur passe au rouge sous 25 % viainterpolateColor.
🎯 Objectifs
- Dessiner un anneau de progression en SVG (le pattern strokeDasharray/offset).
- Animer une prop SVG avec useAnimatedProps + createAnimatedComponent.
- Synchroniser l’animation avec la phase de jeu (une vérité : endsAt).
- Interpoler la couleur selon le temps restant.
L’anneau SVG — la géométrie en 30 secondes
npx expo install react-native-svgLe truc classique : un cercle dont on ne dessine qu’une fraction du contour.
strokeDasharray = circonférence (un seul tiret long comme le tour complet) ;
strokeDashoffset décale ce tiret — offset 0 = anneau plein, offset = circonférence = anneau
vide. Animer l’offset = vider l’anneau.
// components/TimerRing.tsx
import { View } from 'react-native'
import Svg, { Circle } from 'react-native-svg'
import Animated, {
useSharedValue, useAnimatedProps, withTiming, Easing, interpolateColor,
} from 'react-native-reanimated'
import { useEffect } from 'react'
const AnimatedCircle = Animated.createAnimatedComponent(Circle)
const SIZE = 120
const STROKE = 10
const R = (SIZE - STROKE) / 2
const CIRCUMFERENCE = 2 * Math.PI * R
type Props = {
endsAt: number | null // la vérité de la machine d'état (phase playing)
totalMs: number
children?: React.ReactNode // le compteur de secondes, au centre
}
export function TimerRing({ endsAt, totalMs, children }: Props) {
const progress = useSharedValue(1) // 1 = plein, 0 = vide
useEffect(() => {
if (endsAt === null) return
const remaining = Math.max(0, endsAt - Date.now())
progress.value = remaining / totalMs // resynchro (retour d'arrière-plan !)
progress.value = withTiming(0, { duration: remaining, easing: Easing.linear })
}, [endsAt, totalMs])
const animatedProps = useAnimatedProps(() => ({
strokeDashoffset: CIRCUMFERENCE * (1 - progress.value),
stroke: interpolateColor(
progress.value,
[0, 0.25, 0.5, 1],
['#ef4444', '#ef4444', '#fbbf24', '#58c4dc'] // rouge ← ambre ← cyan
),
}))
return (
<View style={{ width: SIZE, height: SIZE, alignItems: 'center', justifyContent: 'center' }}>
<Svg width={SIZE} height={SIZE} style={{ position: 'absolute' }}>
{/* la piste (fond) */}
<Circle
cx={SIZE / 2} cy={SIZE / 2} r={R}
stroke="#1e3a45" strokeWidth={STROKE} fill="none"
/>
{/* l'anneau animé */}
<AnimatedCircle
cx={SIZE / 2} cy={SIZE / 2} r={R}
strokeWidth={STROKE} fill="none"
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
animatedProps={animatedProps}
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`} // départ à midi
/>
</Svg>
{children}
</View>
)
}Les points de compréhension :
createAnimatedComponent(Circle): rend n’importe quel composant animable ;useAnimatedPropsest le jumeau d’useAnimatedStyle pour les props non-style (SVG, TextInput…).- UNE animation, pas de ticks :
withTiming(0, { duration: remaining })couvre toute la question — le thread UI interpole chaque frame tout seul. Comparez au useCountdown (250 ms, affichage des secondes) : deux fréquences, deux mécanismes, chacun au bon étage. - La resynchro : au changement d’
endsAt(nouvelle question) OU au re-render après arrière-plan, on recaleprogress.valuesur le temps RÉEL restant avant de relancer. La vérité resteendsAt(ch. 6.3) — l’anneau n’est qu’une projection. interpolateColor: la couleur est calculée dans le worklet — cyan → ambre à 50 % → rouge sous 25 %. Aucun state React, aucune frame ratée.
L’assemblage dans l’écran de quiz
// app/quiz.tsx — le header remplacé
const phase = useSession(s => s.phase)
const totalSeconds = useSession(s => s.totalSeconds)
const secondsLeft = useCountdown() // ch. 6.3 — inchangé, pour l'affichage ET le timeout
<View className="items-center">
<TimerRing
endsAt={phase.name === 'playing' ? phase.endsAt : null}
totalMs={totalSeconds * 1000}
>
<AppText variant="title">{secondsLeft}</AppText>
</TimerRing>
</View>Architecture finale du temps, à trois étages — chacun à sa fréquence :
| Étage | Mécanisme | Fréquence | Rôle |
|---|---|---|---|
| Vérité | endsAt vs Date.now() | à la demande | timeout, scoring, arrière-plan |
| Affichage secondes | useCountdown (interval 250 ms) | 4 Hz | le chiffre au centre |
| Anneau | withTiming sur le thread UI | 60-120 Hz | la fluidité perçue |
📱 Essaie sur ton téléphone — Lancez une partie : l’anneau se vide continûment, passe à l’ambre puis au rouge, et le chiffre décompte. Puis le test qui prouve tout : pendant une question, faites défiler l’historique en arrière-plan mental… ou mieux, mettez un
console.logen boucle dans un bouton : l’anneau reste PARFAITEMENT fluide (thread UI), même si le chiffre (thread JS) peut lui hoqueter. La démonstration physique du chapitre 10.1.
⚠️ Piège — Après un passage en arrière-plan, l’animation withTiming a « continué » côté UI (ou été gelée selon l’état du process) — dans le doute, la resynchro du useEffect recale tout : c’est le
progress.value = remaining / totalMsAVANT le withTiming qui rend l’anneau honnête. Sans lui, anneau et horloge divergent après chaque interruption — le genre de bug qu’on ne voit qu’en usage réel, jamais en démo.
✏️ Exercices
1. Ajoutez le « pulse » des dernières secondes : sous 5 s, l’anneau entier (le conteneur) pulse doucement (scale 1 ↔ 1,04). Attention à ne le déclencher qu’UNE fois.
✅ Solution
const pulse = useSharedValue(1)
const pulsing = useRef(false)
useEffect(() => {
if (secondsLeft <= 5 && phase.name === 'playing' && !pulsing.current) {
pulsing.current = true
pulse.value = withRepeat(withTiming(1.04, { duration: 300 }), -1, true)
}
if (phase.name !== 'playing' && pulsing.current) {
pulsing.current = false
cancelAnimation(pulse) // ← arrêter proprement une répétition infinie
pulse.value = withTiming(1)
}
}, [secondsLeft, phase.name])Deux apprentissages : le garde-fou useRef (relancer withRepeat à chaque render sous 5 s =
animation qui bégaie), et cancelAnimation — l’API d’arrêt qu’on oublie toujours.
2. Le TimerRing doit être réutilisable pour le Pomodoro de P19 (25 min, pas 20 s). Qu’y a-t-il à changer ? (Indice : presque rien — expliquez pourquoi.)
✅ Solution
Rien dans le composant : endsAt/totalMs sont déjà génériques — 25 min = totalMs plus
grand, withTiming s’en moque (une interpolation est une interpolation). À adapter côté
consommateur : les paliers de couleur (25 % de 25 min = 6 min de rouge, peut-être trop —
passer les stops en prop). Un composant paramétré par des données, pas des usages : la
définition du réutilisable.
3. Remplacez le chiffre useCountdown par un ReText piloté par une derived value (zéro re-render React par seconde). Est-ce que ça vaut le coup dans Drill ? Tranchez.
✅ Solution
const seconds = useDerivedValue(() =>
`${Math.max(0, Math.ceil((progress.value * totalMs) / 1000))}`
)
<ReText text={seconds} style={…} /> // react-native-redashVerdict honnête : NON pour Drill — 4 re-renders/s d’un petit Text sont invisibles au profiler, et useCountdown doit tourner de toute façon (il déclenche le timeout). La version ReText est le bon outil quand l’affichage suit un GESTE à 120 Hz (prix qui suit un slider). Savoir ne PAS optimiser est la moitié de la compétence perf.
🧠 Quiz
1. Comment dessine-t-on une fraction d’anneau en SVG ?
Réponse
strokeDasharray = circonférence (un tiret = tout le tour), et strokeDashoffset décale ce
tiret : 0 = plein, circonférence = vide. Animer l’offset vide l’anneau. (+ rotate(-90) pour
démarrer à midi.)
2. useAnimatedProps vs useAnimatedStyle ?
Réponse
Même mécanisme (worklet ré-exécuté sur le thread UI), cible différente : style RN pour l’un, props de composant (SVG, TextInput…) pour l’autre — avec createAnimatedComponent pour rendre la cible animable.
3. Pourquoi une seule animation withTiming plutôt qu’un tick par seconde ?
Réponse
Le thread UI interpole chaque frame tout seul (60-120 Hz) pour la durée totale — fluidité parfaite, zéro trafic JS. Les ticks restent au useCountdown pour le chiffre et le timeout (4 Hz suffisent).
4. Que garantit la ligne progress.value = remaining / totalMs avant le withTiming ?
Réponse
La resynchronisation sur le temps réel (endsAt) : après un arrière-plan ou au changement de question, l’anneau repart de la position honnête — la machine d’état reste la seule vérité.
5. Où la couleur de l’anneau est-elle calculée, et avec quoi ?
Réponse
Dans le worklet useAnimatedProps, via interpolateColor(progress, stops, couleurs) — cyan →
ambre → rouge sans jamais toucher au thread JS.
👉 Chapitre suivant : 10.4 — Gestes — le doigt qui pilote l’interface, frame par frame.