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.

Questions d'entretien SwiftUI NavigationStack pour développeurs iOS

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.

Structure de ce guide

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.

NavigationComparison.swiftswift
// ❌ 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.

NavigationPathBasics.swiftswift
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
}
Type-erased mais type-safe

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.

ProgrammaticNavigation.swiftswift
// 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

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.

DeepLinkHandler.swiftswift
@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.

NavigationPersistence.swiftswift
// 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)
    }
}
Compatibilité Codable

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.

AuthNavigationFlow.swiftswift
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.

ModalNavigation.swiftswift
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.

NavigationTests.swiftswift
// 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.

TabBasedNavigation.swiftswift
// É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.

NavigationPerformance.swiftswift
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)
                }
            }
        }
    }
}
Bonnes pratiques de performance

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 NavigationView et NavigationStack
  • ✅ Savoir utiliser NavigationPath pour 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 NavigationStack correctement
  • ✅ É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

#swiftui
#ios
#navigation
#entretien
#navigationstack

Partager

Articles similaires