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;DR —
router.push/replace/back/dismisspour 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 écouteurBackHandler(ou l’optionbeforeRemovede 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.js —
router.push/replace/back: mêmes noms quenext/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 links — drill://quiz?theme=php :
ouvrir l’app directement au bon endroit.