Skip to Content
React Native10. Animations & gestes10.3 Le timer circulaire ⭐

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 strokeDashoffset est piloté par Reanimated via useAnimatedProps (le useAnimatedStyle des props SVG). UNE animation withTiming(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 % via interpolateColor.

🎯 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-svg

Le 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 ; useAnimatedProps est 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 recale progress.value sur le temps RÉEL restant avant de relancer. La vérité reste endsAt (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 :

ÉtageMécanismeFréquenceRôle
VéritéendsAt vs Date.now()à la demandetimeout, scoring, arrière-plan
Affichage secondesuseCountdown (interval 250 ms)4 Hzle chiffre au centre
AnneauwithTiming sur le thread UI60-120 Hzla 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.log en 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 / totalMs AVANT 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-redash

Verdict 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.