Skip to Content

Chapitre 5.4 — Modals & navigation programmatique

Où on en est : les écrans se lient par des Link. Mais un jeu navigue tout seul : fin du timer → écran résultat, « Rejouer » → nouvelle partie. Place à l’impératif : router.

⏱️ TL;DRrouter.push/replace/back/dismiss pour naviguer depuis le code. La subtilité qui fait les bons flux : replace (l’écran résultat REMPLACE le quiz dans la pile → back ne rejoue pas la partie). Les modals (presentation: 'modal') pour la confirmation d’abandon. On câble le flux de session complet de Drill — définitif.

🎯 Objectifs

  • Naviguer depuis la logique (fin de partie, confirmation) avec le router.
  • Choisir entre push, replace, back, dismissTo — et savoir pourquoi.
  • Créer un modal de confirmation d’abandon.
  • Câbler le flux de session complet : tabs → quiz → résultat → rejouer/retour.

L’API router

import { router } from 'expo-router' router.push('/quiz') // empile router.replace('/quiz/results') // REMPLACE l'écran courant dans la pile router.back() // dépile (= geste retour) router.dismissTo('/(tabs)') // dépile jusqu'à la route donnée

(useRouter() existe aussi — même API en hook ; l’import direct fonctionne partout, y compris hors composants : parfait pour la logique de jeu en Partie 6.)

Le flux d’une session — et pourquoi replace

Déroulons une partie :

Le moment clé : la partie se termine. Si on faisait push('/quiz/results'), la pile serait tabs → quiz → results — et le geste back depuis le résultat ramènerait dans la partie finie : état absurde, double comptage possible. Avec replace, la pile devient tabs → results : back = retour aux tabs, naturel.

Même logique pour « Rejouer » : replace('/quiz') — le résultat ne doit pas s’accumuler sous la nouvelle partie.

// app/quiz.tsx — extrait : la fin de partie (le vrai state arrive en Partie 6) function onSessionEnd(summary: SessionSummary) { router.replace({ pathname: '/quiz/results', params: { score: summary.score, best: summary.streakBest, theme: summary.theme }, }) }

La règle générale : push pour approfondir (détail, étape suivante d’un contenu), replace pour tourner la page (fin d’étape, login réussi, onboarding fini). Demandez-vous toujours : « que doit faire le geste back ici ? » — la réponse choisit la méthode.

Le modal de confirmation d’abandon

On a désactivé le geste back du quiz (gestureEnabled: false, chapitre 5.2). Il faut donc un bouton ✕ — avec confirmation (une partie chronométrée perdue par mégarde = rage). Route modale :

app/ ├── quiz.tsx └── quiz/ ├── results.tsx └── confirm-quit.tsx → le modal
// app/_layout.tsx — déclarer la présentation modale <Stack.Screen name="quiz/confirm-quit" options={{ presentation: 'modal', headerShown: false, animation: 'fade' }} />
// app/quiz/confirm-quit.tsx export default function ConfirmQuitModal() { return ( <View className="flex-1 items-center justify-center bg-black/60 p-6"> <Card className="w-full gap-4"> <AppText variant="heading">Abandonner la partie ?</AppText> <AppText variant="caption">Le score de cette session sera perdu.</AppText> <Button label="Abandonner" onPress={() => router.dismissTo('/(tabs)')} /> <Button label="Continuer à jouer" variant="ghost" onPress={() => router.back()} /> </Card> </View> ) }

Anatomie :

  • presentation: 'modal' : présentation par-dessus, animation dédiée.
  • « Continuer » = back() (ferme LE modal, la partie est toujours dessous, intacte — la pile fait le travail de « pause » gratuitement).
  • « Abandonner » = dismissTo('/(tabs)') : dépile modal ET quiz d’un coup, retour maison.

Pour les confirmations triviales, l’alternative légère existe : Alert.alert('Titre', 'msg', [{text, onPress}…]) — la boîte de dialogue native, zéro route. On l’utilise pour les cas secondaires (ex. « Réinitialiser les réglages ? ») ; le modal routé, lui, est stylable et testable — le bon choix pour les moments importants du produit.

⚠️ Piège — Le back matériel Android (bouton/geste système) ignore gestureEnabled (qui ne gère que le swipe iOS-like). Pour l’intercepter dans le quiz, un écouteur BackHandler (ou l’option beforeRemove de la screen) doit rediriger vers le modal de confirmation. On l’ajoutera en Partie 6 avec la logique de partie — retenez : sur Android, le back matériel est un événement à gérer, pas seulement un geste à désactiver.

💡 Pour un dev Next.jsrouter.push/replace/back : mêmes noms que next/navigation, mêmes intuitions. Les nouveautés mobiles : la pile est visible (l’utilisateur la ressent physiquement), dismissTo (dépiler N écrans), et les présentations (modal) — le web n’a pas de « manière d’arriver » d’un écran.

✏️ Exercices

1. Câblez le flux minimal complet avec des écrans factices : accueil → quiz (bouton « Terminer la partie » simulant la fin) → résultat (affiche le score des params) → « Rejouer » et « Retour à l’accueil ». Testez chaque geste back.

✅ Solution

Quiz : router.replace({ pathname: '/quiz/results', params: { score: 870 } }). Résultat : lit score (validation string→number !), « Rejouer » = router.replace('/quiz'), « Retour » = router.dismissTo('/(tabs)'). Vérifs back : depuis résultat → tabs (pas le quiz) ; depuis quiz relancé → tabs (le résultat n’est plus dans la pile). Si un back « rejoue » quelque chose : un push traîne quelque part.

2. Le bouton ✕ du quiz doit ouvrir le modal. Mais si le joueur n’a pas encore répondu à la première question, quitter directement sans confirmation. Implémentez la logique.

✅ Solution

function onQuitPress(hasAnswered: boolean) { if (hasAnswered) router.push('/quiz/confirm-quit') else router.back() // rien à perdre : sortie directe }

Le modal se push (il doit s’empiler au-dessus). La condition métier vit dans l’écran quiz — le modal reste bête. UX : ne demandez confirmation que quand il y a quelque chose à confirmer.

3. Pourquoi dismissTo('/(tabs)') plutôt que replace('/(tabs)') pour l’abandon ? Testez les deux et observez l’animation et la pile.

✅ Solution

dismissTo dépile jusqu’aux tabs : animation de fermeture cohérente (les écrans s’en vont), et la pile retrouve son état d’origine — les tabs n’ont jamais bougé. replace remplacerait le MODAL par une nouvelle instance des tabs au-dessus de l’ancienne pile : pile fantôme, animations étranges. Pour « revenir à la maison », on dépile — on ne rempile pas la maison.

🧠 Quiz

1. push vs replace : la question à se poser ?

Réponse

« Que doit faire le geste back depuis l’écran suivant ? » S’il doit revenir ici → push. S’il ne doit JAMAIS revenir ici (étape terminée) → replace.

2. Pourquoi l’écran résultat remplace-t-il le quiz ?

Réponse

Pour que back depuis le résultat ne ramène pas dans une partie terminée : la pile devient tabs → results. Une étape close sort de la pile.

3. Comment déclarer une route en présentation modale ?

Réponse

<Stack.Screen name="…" options={{ presentation: 'modal' }} /> dans le _layout du Stack parent (+ animation au choix).

4. Que fait router.dismissTo ?

Réponse

Il dépile les écrans jusqu’à atteindre la route donnée — la sortie propre d’un flux multi-écrans (modal + jeu d’un coup).

5. gestureEnabled: false suffit-il à bloquer la sortie du quiz sur Android ?

Réponse

Non : le back matériel Android reste actif — il faut l’intercepter (BackHandler / beforeRemove) pour rediriger vers la confirmation. Geste iOS ≠ bouton système Android.


👉 Chapitre suivant : 5.5 — Deep linksdrill://quiz?theme=php : ouvrir l’app directement au bon endroit.