SwiftUI NavigationStack : questions d'entretien sur les patterns de navigation 2026
Préparez vos entretiens iOS avec les questions essentielles sur NavigationStack, NavigationPath et les patterns de navigation SwiftUI modernes.

La navigation représente un pilier fondamental de toute application iOS. Depuis iOS 16, NavigationStack remplace NavigationView et offre un contrôle programmatique complet sur la pile de navigation. Les recruteurs testent régulièrement la maîtrise de ces concepts lors des entretiens techniques.
Chaque question reproduit le format d'un entretien technique réel, avec une réponse détaillée et du code fonctionnel. Les concepts progressent du fondamental vers l'avancé.
Les fondamentaux de NavigationStack
Question 1 : Quelle différence entre NavigationView et NavigationStack ?
NavigationView (deprecated depuis iOS 16) créait une navigation implicite basée sur les NavigationLink imbriqués. NavigationStack introduit une approche déclarative avec une pile de navigation explicite et modifiable programmatiquement.
// ❌ Ancien pattern avec NavigationView (deprecated)
struct OldNavigation: View {
var body: some View {
NavigationView {
NavigationLink("Détails", destination: DetailView())
}
}
}
// ✅ Nouveau pattern avec NavigationStack
struct ModernNavigation: View {
// La pile de navigation est explicite et contrôlable
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List {
// NavigationLink avec valeur typée
NavigationLink("Utilisateur 1", value: User(id: 1, name: "Alice"))
NavigationLink("Utilisateur 2", value: User(id: 2, name: "Bob"))
}
// Destination définie par type de valeur
.navigationDestination(for: User.self) { user in
UserDetailView(user: user)
}
}
}
}L'avantage majeur réside dans la séparation entre la déclaration du lien et sa destination, permettant une navigation centralisée et testable.
Question 2 : Comment fonctionne NavigationPath ?
NavigationPath est un conteneur type-erased qui stocke les valeurs de navigation. Il permet de manipuler la pile sans connaître les types exacts des écrans, tout en préservant la type-safety à la compilation.
struct ContentView: View {
// NavigationPath peut contenir différents types Hashable
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
VStack(spacing: 20) {
Button("Voir profil utilisateur") {
// Ajoute un User à la pile
path.append(User(id: 1, name: "Alice"))
}
Button("Voir paramètres") {
// Ajoute un enum Settings à la pile
path.append(SettingsRoute.notifications)
}
Button("Retour à la racine") {
// Vide entièrement la pile
path.removeLast(path.count)
}
}
.navigationDestination(for: User.self) { user in
UserDetailView(user: user)
}
.navigationDestination(for: SettingsRoute.self) { route in
SettingsView(route: route)
}
}
}
}
// Les types doivent être Hashable
struct User: Hashable {
let id: Int
let name: String
}
enum SettingsRoute: Hashable {
case notifications
case privacy
case account
}NavigationPath utilise le type erasure en interne mais vérifie les types à la compilation via les navigationDestination. Une valeur sans destination correspondante provoque une erreur silencieuse au runtime.
Question 3 : Comment implémenter une navigation programmatique complète ?
La navigation programmatique permet de contrôler la pile depuis n'importe quel point du code, sans interaction utilisateur directe. C'est essentiel pour les deep links, les redirections post-authentification ou les flows multi-étapes.
// Router centralisé pour gérer la navigation
@Observable
class NavigationRouter {
var path = NavigationPath()
// Navigation vers un écran spécifique
func navigateTo(_ destination: AppRoute) {
path.append(destination)
}
// Retour d'un niveau
func goBack() {
guard !path.isEmpty else { return }
path.removeLast()
}
// Retour à la racine
func popToRoot() {
path.removeLast(path.count)
}
// Navigation vers une pile complète (deep link)
func navigateToPath(_ routes: [AppRoute]) {
popToRoot()
for route in routes {
path.append(route)
}
}
}
// Enum définissant toutes les routes de l'app
enum AppRoute: Hashable {
case userList
case userDetail(userId: Int)
case userEdit(userId: Int)
case settings
case settingsDetail(SettingsSection)
}
enum SettingsSection: String, Hashable {
case notifications, privacy, account
}
// Utilisation dans la vue principale
struct MainView: View {
@State private var router = NavigationRouter()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
destinationView(for: route)
}
}
.environment(router)
}
@ViewBuilder
private func destinationView(for route: AppRoute) -> some View {
switch route {
case .userList:
UserListView()
case .userDetail(let userId):
UserDetailView(userId: userId)
case .userEdit(let userId):
UserEditView(userId: userId)
case .settings:
SettingsView()
case .settingsDetail(let section):
SettingsDetailView(section: section)
}
}
}Ce pattern centralise toute la logique de navigation, facilitant les tests unitaires et la maintenance.
Patterns de navigation avancés
Question 4 : Comment implémenter les deep links avec NavigationStack ?
Les deep links permettent d'ouvrir l'application directement sur un écran spécifique depuis une URL externe. Avec NavigationStack, la pile peut être reconstituée programmatiquement.
@Observable
class DeepLinkHandler {
var router: NavigationRouter
init(router: NavigationRouter) {
self.router = router
}
// Parse une URL et navigue vers la destination
func handle(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let host = components.host else {
return
}
// Construit la pile de navigation selon l'URL
let routes = parseRoutes(host: host, path: components.path, queryItems: components.queryItems)
router.navigateToPath(routes)
}
private func parseRoutes(host: String, path: String, queryItems: [URLQueryItem]?) -> [AppRoute] {
// myapp://users/42/edit → [.userList, .userDetail(42), .userEdit(42)]
switch host {
case "users":
return parseUserPath(path)
case "settings":
return parseSettingsPath(path)
default:
return []
}
}
private func parseUserPath(_ path: String) -> [AppRoute] {
let segments = path.split(separator: "/").map(String.init)
var routes: [AppRoute] = [.userList]
if let userIdString = segments.first, let userId = Int(userIdString) {
routes.append(.userDetail(userId: userId))
// /users/42/edit
if segments.count > 1 && segments[1] == "edit" {
routes.append(.userEdit(userId: userId))
}
}
return routes
}
private func parseSettingsPath(_ path: String) -> [AppRoute] {
var routes: [AppRoute] = [.settings]
let segments = path.split(separator: "/").map(String.init)
if let sectionString = segments.first,
let section = SettingsSection(rawValue: sectionString) {
routes.append(.settingsDetail(section))
}
return routes
}
}
// Dans l'App principale
@main
struct MyApp: App {
@State private var router = NavigationRouter()
@State private var deepLinkHandler: DeepLinkHandler?
var body: some Scene {
WindowGroup {
MainView()
.environment(router)
.onOpenURL { url in
// Gère les deep links
deepLinkHandler?.handle(url: url)
}
.onAppear {
deepLinkHandler = DeepLinkHandler(router: router)
}
}
}
}Question 5 : Comment persister et restaurer l'état de navigation ?
La persistance de l'état de navigation permet de restaurer la position de l'utilisateur après un redémarrage de l'application. NavigationPath supporte Codable pour la sérialisation.
// Extension pour rendre NavigationPath persistable
extension NavigationPath {
// Encode le path en Data
func encoded() -> Data? {
guard let representation = self.codable else { return nil }
return try? JSONEncoder().encode(representation)
}
// Décode depuis Data
static func decoded(from data: Data) -> NavigationPath? {
guard let representation = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data
) else {
return nil
}
return NavigationPath(representation)
}
}
// Router avec persistance
@Observable
class PersistentNavigationRouter {
var path: NavigationPath {
didSet {
saveState()
}
}
private let storageKey = "navigation_path"
init() {
// Restaure l'état au démarrage
if let data = UserDefaults.standard.data(forKey: storageKey),
let restored = NavigationPath.decoded(from: data) {
self.path = restored
} else {
self.path = NavigationPath()
}
}
private func saveState() {
if let data = path.encoded() {
UserDefaults.standard.set(data, forKey: storageKey)
}
}
func clearPersistedState() {
UserDefaults.standard.removeObject(forKey: storageKey)
}
}Pour que NavigationPath.codable fonctionne, tous les types ajoutés au path doivent être Codable en plus de Hashable. Sinon, la propriété codable retourne nil.
Prêt à réussir tes entretiens iOS ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
Question 6 : Comment gérer la navigation avec des flux d'authentification ?
Les flux d'authentification nécessitent souvent de rediriger vers un écran protégé après connexion, ou de revenir au login après déconnexion. Le pattern suivant gère ces transitions proprement.
enum AuthState {
case unauthenticated
case authenticated(User)
}
@Observable
class AuthManager {
var state: AuthState = .unauthenticated
var pendingDeepLink: URL?
func login(email: String, password: String) async throws {
// Simulation d'authentification
let user = try await AuthService.shared.login(email: email, password: password)
state = .authenticated(user)
}
func logout() {
state = .unauthenticated
}
}
struct RootView: View {
@State private var authManager = AuthManager()
@State private var router = NavigationRouter()
var body: some View {
Group {
switch authManager.state {
case .unauthenticated:
// Stack de navigation séparée pour l'auth
AuthNavigationStack(authManager: authManager)
case .authenticated:
// Stack principale de l'app
MainNavigationStack(router: router, authManager: authManager)
}
}
.onChange(of: authManager.state) { oldState, newState in
handleAuthStateChange(from: oldState, to: newState)
}
}
private func handleAuthStateChange(from oldState: AuthState, to newState: AuthState) {
switch (oldState, newState) {
case (.unauthenticated, .authenticated):
// Connexion réussie : traite le deep link en attente
if let pendingURL = authManager.pendingDeepLink {
DeepLinkHandler(router: router).handle(url: pendingURL)
authManager.pendingDeepLink = nil
}
case (.authenticated, .unauthenticated):
// Déconnexion : reset la navigation
router.popToRoot()
default:
break
}
}
}
struct AuthNavigationStack: View {
let authManager: AuthManager
@State private var authPath = NavigationPath()
var body: some View {
NavigationStack(path: $authPath) {
LoginView(authManager: authManager)
.navigationDestination(for: AuthRoute.self) { route in
switch route {
case .register:
RegisterView(authManager: authManager)
case .forgotPassword:
ForgotPasswordView()
}
}
}
}
}
enum AuthRoute: Hashable {
case register
case forgotPassword
}Question 7 : Comment implémenter une navigation modale avec NavigationStack ?
Les modales et les sheets nécessitent leur propre contexte de navigation. Combiner NavigationStack avec les présentations modales demande une gestion séparée des états.
struct ParentView: View {
@State private var mainPath = NavigationPath()
@State private var showSettings = false
@State private var showUserProfile: User?
var body: some View {
NavigationStack(path: $mainPath) {
ContentView()
.toolbar {
Button("Paramètres") {
showSettings = true
}
}
.navigationDestination(for: MainRoute.self) { route in
MainRouteView(route: route)
}
}
// Sheet avec sa propre NavigationStack
.sheet(isPresented: $showSettings) {
SettingsSheet()
}
// Sheet conditionnelle basée sur un item
.sheet(item: $showUserProfile) { user in
UserProfileSheet(user: user)
}
}
}
// Chaque sheet a son propre NavigationStack
struct SettingsSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var settingsPath = NavigationPath()
var body: some View {
NavigationStack(path: $settingsPath) {
SettingsListView()
.navigationTitle("Paramètres")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Fermer") {
dismiss()
}
}
}
.navigationDestination(for: SettingsSection.self) { section in
SettingsDetailView(section: section)
}
}
}
}
// Extension pour rendre User identifiable pour sheet(item:)
extension User: Identifiable {}Gestion d'état et testabilité
Question 8 : Comment tester la navigation de manière unitaire ?
La testabilité est un avantage majeur de NavigationStack. En isolant le router, les tests unitaires vérifient la logique de navigation sans UI.
// Protocol pour abstraire le router
protocol NavigationRouterProtocol {
var pathCount: Int { get }
func navigateTo(_ destination: AppRoute)
func goBack()
func popToRoot()
}
// Implémentation concrète
@Observable
class AppNavigationRouter: NavigationRouterProtocol {
var path = NavigationPath()
var pathCount: Int {
path.count
}
func navigateTo(_ destination: AppRoute) {
path.append(destination)
}
func goBack() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
}
// Tests unitaires
import XCTest
final class NavigationRouterTests: XCTestCase {
var router: AppNavigationRouter!
override func setUp() {
router = AppNavigationRouter()
}
func testNavigateToAddsToPath() {
// Given
XCTAssertEqual(router.pathCount, 0)
// When
router.navigateTo(.userDetail(userId: 42))
// Then
XCTAssertEqual(router.pathCount, 1)
}
func testGoBackRemovesLastItem() {
// Given
router.navigateTo(.userList)
router.navigateTo(.userDetail(userId: 1))
XCTAssertEqual(router.pathCount, 2)
// When
router.goBack()
// Then
XCTAssertEqual(router.pathCount, 1)
}
func testPopToRootClearsPath() {
// Given
router.navigateTo(.userList)
router.navigateTo(.userDetail(userId: 1))
router.navigateTo(.userEdit(userId: 1))
XCTAssertEqual(router.pathCount, 3)
// When
router.popToRoot()
// Then
XCTAssertEqual(router.pathCount, 0)
}
func testGoBackOnEmptyPathDoesNothing() {
// Given
XCTAssertEqual(router.pathCount, 0)
// When
router.goBack()
// Then
XCTAssertEqual(router.pathCount, 0) // Pas de crash
}
}Question 9 : Comment gérer les états de navigation complexes avec plusieurs onglets ?
Les applications avec TabView nécessitent un état de navigation par onglet. Chaque onglet maintient sa propre pile indépendante.
// État de navigation pour chaque onglet
@Observable
class TabNavigationState {
var homePath = NavigationPath()
var searchPath = NavigationPath()
var profilePath = NavigationPath()
var selectedTab: Tab = .home
enum Tab: Hashable {
case home, search, profile
}
func resetCurrentTab() {
switch selectedTab {
case .home:
homePath.removeLast(homePath.count)
case .search:
searchPath.removeLast(searchPath.count)
case .profile:
profilePath.removeLast(profilePath.count)
}
}
func resetAllTabs() {
homePath.removeLast(homePath.count)
searchPath.removeLast(searchPath.count)
profilePath.removeLast(profilePath.count)
}
}
struct TabRootView: View {
@State private var tabState = TabNavigationState()
var body: some View {
TabView(selection: $tabState.selectedTab) {
// Onglet Accueil
NavigationStack(path: $tabState.homePath) {
HomeView()
.navigationDestination(for: HomeRoute.self) { route in
HomeRouteView(route: route)
}
}
.tabItem { Label("Accueil", systemImage: "house") }
.tag(TabNavigationState.Tab.home)
// Onglet Recherche
NavigationStack(path: $tabState.searchPath) {
SearchView()
.navigationDestination(for: SearchRoute.self) { route in
SearchRouteView(route: route)
}
}
.tabItem { Label("Recherche", systemImage: "magnifyingglass") }
.tag(TabNavigationState.Tab.search)
// Onglet Profil
NavigationStack(path: $tabState.profilePath) {
ProfileView()
.navigationDestination(for: ProfileRoute.self) { route in
ProfileRouteView(route: route)
}
}
.tabItem { Label("Profil", systemImage: "person") }
.tag(TabNavigationState.Tab.profile)
}
.environment(tabState)
}
}Question 10 : Comment éviter les problèmes de performance avec NavigationStack ?
Les grandes piles de navigation ou les destinations complexes peuvent impacter les performances. Plusieurs techniques optimisent le rendu et la mémoire.
struct OptimizedNavigationStack: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
LazyContentView()
// ✅ Destinations lazy-loaded
.navigationDestination(for: HeavyRoute.self) { route in
// La vue n'est créée qu'à la navigation
HeavyDetailView(route: route)
}
}
}
}
// ✅ Vue avec chargement lazy du contenu
struct LazyContentView: View {
@State private var items: [Item] = []
var body: some View {
// LazyVStack ne crée que les vues visibles
ScrollView {
LazyVStack(spacing: 12) {
ForEach(items) { item in
NavigationLink(value: HeavyRoute.detail(item.id)) {
ItemRow(item: item)
}
}
}
}
.task {
items = await loadItems()
}
}
}
// ✅ Détail avec chargement progressif
struct HeavyDetailView: View {
let route: HeavyRoute
@State private var data: DetailData?
var body: some View {
Group {
if let data {
DetailContent(data: data)
} else {
ProgressView()
}
}
.task {
// Charge les données seulement quand la vue apparaît
data = await loadDetailData(for: route)
}
}
}
// ❌ À éviter : création eager de vues lourdes
struct BadNavigationStack: View {
let allItems: [Item]
var body: some View {
NavigationStack {
List(allItems) { item in
// Crée immédiatement toutes les destinations
NavigationLink {
HeavyDetailView(item: item) // ❌ Créé à l'avance
} label: {
ItemRow(item: item)
}
}
}
}
}Toujours utiliser navigationDestination(for:) plutôt que les NavigationLink avec destination inline. Le premier pattern charge la vue de destination uniquement lors de la navigation.
Conclusion
NavigationStack transforme la gestion de la navigation en SwiftUI en offrant un contrôle programmatique complet. La maîtrise de ces patterns — de la navigation basique aux deep links en passant par la persistance d'état — distingue les développeurs iOS confirmés lors des entretiens.
Checklist de révision
- ✅ Comprendre la différence entre
NavigationViewetNavigationStack - ✅ Savoir utiliser
NavigationPathpour la navigation programmatique - ✅ Implémenter un router centralisé pour la navigation
- ✅ Gérer les deep links avec reconstruction de pile
- ✅ Persister et restaurer l'état de navigation avec
Codable - ✅ Séparer les flux d'authentification de la navigation principale
- ✅ Combiner modales et
NavigationStackcorrectement - ✅ Écrire des tests unitaires pour la logique de navigation
- ✅ Gérer la navigation multi-onglets avec
TabView - ✅ Optimiser les performances des grandes piles de navigation
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Tags
Partager
Articles similaires

MapKit SwiftUI en entretien technique en 2026 : annotations, overlays et géolocalisation
Maîtrisez MapKit avec SwiftUI pour vos entretiens iOS : annotations personnalisées, overlays, géolocalisation, recherche de lieux et intégration Maps.

SwiftUI Performance : optimiser LazyVStack et listes complexes
Techniques d'optimisation pour LazyVStack et listes SwiftUI. Réduire la consommation mémoire, améliorer le scrolling et éviter les pièges de performance courants.

Swift Testing Framework en entretien 2026 : macros #expect et #require vs XCTest
Maîtrisez le nouveau Swift Testing Framework pour vos entretiens iOS : macros #expect et #require, migration depuis XCTest, patterns avancés et pièges à éviter.