Développement Mobile

Naviguer entre écrans avec NavigationStack et NavigationSplitView

11 دقائق للقراءة

La navigation est l’un des aspects où SwiftUI a le plus évolué depuis sa sortie. La première API NavigationView a montré ses limites (mauvaise prise en charge du programmatique, comportement inconsistant entre iPhone et iPad). Depuis iOS 16, deux composants se partagent le rôle : NavigationStack pour les piles classiques mono-colonne, NavigationSplitView pour les agencements multi-colonnes adaptés aux écrans larges. Ce tutoriel détaille les deux composants pas à pas, avec un mini-projet de catalogue de livres.

📘 Guide principal de la série : Développer une application iOS avec Swift et SwiftUI : panorama 2026. Ce tutoriel suppose maîtrisées les bases SwiftUI couvertes dans Construire sa première vue SwiftUI.

Prérequis

  • Xcode 26 sur macOS Tahoe.
  • Cible de déploiement iOS 16 minimum (NavigationStack), iOS 17 conseillé pour bénéficier de @Observable.
  • Bases SwiftUI : views, modifiers, state.
  • Temps estimé : 60 à 90 minutes.

Étape 1 — Créer une pile de navigation simple

Le composant NavigationStack englobe le contenu et active la navigation hiérarchique. À l’intérieur, on utilise NavigationLink pour déclencher la transition vers un écran de destination. Cette mécanique est familière pour quiconque a déjà touché à UIKit ou à React Router.

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                NavigationLink("Détail simple") {
                    Text("Vous êtes sur l'écran de détail")
                        .navigationTitle("Détail")
                }

                NavigationLink {
                    DetailComplexe(message: "Bonjour")
                } label: {
                    Label("Détail avec paramètre", systemImage: "arrow.right")
                }
            }
            .padding()
            .navigationTitle("Accueil")
        }
    }
}

struct DetailComplexe: View {
    let message: String
    var body: some View {
        Text(message)
            .font(.title)
            .navigationTitle("Détail")
    }
}

Au lancement, le simulateur affiche l’écran d’accueil avec deux liens. Cliquer pousse l’écran de destination sur la pile avec l’animation slide-in standard iOS, et la barre de navigation affiche un bouton retour. navigationTitle doit être appelé à l’intérieur de l’écran de destination, pas sur le NavigationStack — c’est une source d’incompréhension récurrente chez les nouveaux venus.

Étape 2 — Naviguer programmatiquement avec un path binding

La grande nouveauté de NavigationStack est la navigation programmatique propre. On lui passe un binding vers un tableau qui représente la pile courante. Pousser un écran revient à ajouter un élément au tableau ; revenir à l’écran d’accueil revient à le vider.

struct ContentView: View {
    @State private var chemin: [String] = []

    var body: some View {
        NavigationStack(path: $chemin) {
            VStack(spacing: 16) {
                Button("Aller en page 1") {
                    chemin.append("page-1")
                }
                Button("Aller en page 2 directement") {
                    chemin = ["page-1", "page-2"]
                }
                Button("Tout réinitialiser") {
                    chemin.removeAll()
                }
            }
            .navigationTitle("Accueil")
            .navigationDestination(for: String.self) { id in
                Text("Vous êtes sur \(id)")
                    .navigationTitle(id)
            }
        }
    }
}

Le modifier .navigationDestination(for:) déclare la vue de destination pour un type donné. Le tableau chemin contient les valeurs poussées sur la pile ; NavigationStack dérive la hiérarchie de vues à partir de ce tableau. Ce modèle permet une navigation deep-link facile, une restauration d’état au redémarrage, et un retour au root en une ligne de code.

Étape 3 — Navigation typée vers un modèle

Le pattern précédent utilisait des strings comme valeurs de pile. Dans une app réelle, on préfère pousser un modèle métier (un livre, un produit, un utilisateur) et déléguer le rendu à une vue dédiée. NavigationLink(value:) et .navigationDestination(for:) rendent ce pattern naturel.

struct Livre: Identifiable, Hashable {
    let id = UUID()
    let titre: String
    let auteur: String
    let pages: Int
}

struct CatalogueView: View {
    let livres: [Livre] = [
        Livre(titre: "Le Soleil des indépendances", auteur: "Ahmadou Kourouma", pages: 195),
        Livre(titre: "L'Aventure ambiguë", auteur: "Cheikh Hamidou Kane", pages: 191),
        Livre(titre: "Les Bouts de bois de Dieu", auteur: "Ousmane Sembène", pages: 379)
    ]

    var body: some View {
        NavigationStack {
            List(livres) { livre in
                NavigationLink(value: livre) {
                    VStack(alignment: .leading) {
                        Text(livre.titre).font(.headline)
                        Text(livre.auteur).font(.subheadline)
                    }
                }
            }
            .navigationTitle("Catalogue")
            .navigationDestination(for: Livre.self) { livre in
                DetailLivre(livre: livre)
            }
        }
    }
}

struct DetailLivre: View {
    let livre: Livre
    var body: some View {
        List {
            LabeledContent("Titre", value: livre.titre)
            LabeledContent("Auteur", value: livre.auteur)
            LabeledContent("Pages", value: "\(livre.pages)")
        }
        .navigationTitle(livre.titre)
    }
}

La struct Livre conforme Hashable parce que NavigationStack doit pouvoir comparer les valeurs poussées sur la pile. Conformer Hashable à une struct ne demande aucun code si toutes les propriétés sont elles-mêmes Hashable — le compilateur dérive l’implémentation. LabeledContent (iOS 16+) produit l’agencement standard label/valeur des Réglages iOS.

Étape 4 — Toolbar et boutons de navigation

La barre de navigation peut accueillir des boutons d’action via le modifier .toolbar. ToolbarItem(placement:) contrôle la position : leading, trailing, principal, ou barre du bas pour les apps avec .toolbar(.visible, for: .bottomBar).

.toolbar {
    ToolbarItem(placement: .topBarLeading) {
        Button("Trier") { /* action */ }
    }
    ToolbarItem(placement: .topBarTrailing) {
        Menu {
            Button("Par titre") { /* action */ }
            Button("Par auteur") { /* action */ }
        } label: {
            Image(systemName: "ellipsis.circle")
        }
    }
}

Le Menu SwiftUI produit un menu contextuel natif iOS avec animation pop-out. Cette mécanique est utilisée massivement dans les apps Apple (Mail, Notes, Réglages) et économise des dizaines de lignes par rapport à un UIMenu manuel en UIKit.

Étape 5 — NavigationSplitView pour iPad et Mac

Sur écran large, l’agencement mono-colonne perd en efficacité. NavigationSplitView propose un layout multi-colonnes : la sidebar à gauche, le contenu au centre, le détail à droite. Sur iPhone, le composant retombe naturellement sur une pile à colonnes successives, sans code conditionnel.

struct ContentView: View {
    @State private var categorieSelectionnee: String? = nil
    @State private var livreSelectionne: Livre? = nil

    let categories = ["Roman", "Essai", "Poésie"]
    let livres: [Livre] = [/* … */]

    var body: some View {
        NavigationSplitView {
            List(categories, id: \\.self, selection: $categorieSelectionnee) { cat in
                Text(cat)
            }
            .navigationTitle("Catégories")
        } content: {
            if let cat = categorieSelectionnee {
                List(livres.filter { _ in true }, selection: $livreSelectionne) { livre in
                    NavigationLink(value: livre) {
                        Text(livre.titre)
                    }
                }
                .navigationTitle(cat)
            } else {
                Text("Choisissez une catégorie")
            }
        } detail: {
            if let livre = livreSelectionne {
                DetailLivre(livre: livre)
            } else {
                Text("Choisissez un livre")
            }
        }
    }
}

Sur iPad en mode paysage, les trois colonnes s’affichent côte à côte. Sur iPhone, l’utilisateur navigue de la sidebar au contenu puis au détail en cascade. Sur Mac, l’agencement adopte le look Finder/Notes avec les colonnes redimensionnables. Le même code sert les trois plateformes — c’est l’un des plus gros gains de SwiftUI sur UIKit.

Étape 6 — Onglets avec TabView

Pour les apps à plusieurs sections de premier niveau (Twitter avec Timeline / Explore / Messages, par exemple), TabView produit la barre d’onglets standard iOS. Chaque onglet contient typiquement son propre NavigationStack.

struct ContentView: View {
    var body: some View {
        TabView {
            CatalogueView()
                .tabItem {
                    Label("Catalogue", systemImage: "books.vertical")
                }

            FavorisView()
                .tabItem {
                    Label("Favoris", systemImage: "heart.fill")
                }

            ProfilView()
                .tabItem {
                    Label("Profil", systemImage: "person.circle")
                }
        }
    }
}

Chaque tabItem définit l’icône et le label dans la barre. iOS 18 a introduit une nouvelle API Tab qui modernise la syntaxe, mais l’ancienne reste valide pour la compatibilité descendante. Pour bénéficier des nouveautés visuelles (tab bar redéssinée sur iPadOS 18, sidebar adaptable), passer à la nouvelle API quand la cible de déploiement le permet.

Étape 7 — Sheets, alerts et confirmation dialogs

La navigation hiérarchique ne couvre pas tous les cas. Pour présenter une vue modale temporaire, on utilise .sheet. Pour une question oui/non, .alert. Pour un choix multiple, .confirmationDialog.

@State private var afficherEdition = false
@State private var afficherSupprimer = false

Button("Modifier") { afficherEdition = true }
    .sheet(isPresented: $afficherEdition) {
        FormulaireEdition()
    }

Button("Supprimer", role: .destructive) { afficherSupprimer = true }
    .confirmationDialog(
        "Supprimer ce livre ?",
        isPresented: $afficherSupprimer,
        titleVisibility: .visible
    ) {
        Button("Supprimer", role: .destructive) {
            // Logique de suppression
        }
        Button("Annuler", role: .cancel) { }
    }

Le rôle .destructive sur un bouton lui donne automatiquement la couleur rouge système et l’identifie aux yeux de VoiceOver comme action destructrice. Cette discipline améliore l’accessibilité sans effort.

Étape 8 — Restaurer l’état de navigation

Une bonne app restaure son état au redémarrage : si l’utilisateur consultait l’écran de détail d’un livre, il devrait y revenir au prochain lancement. Le binding path de NavigationStack facilite cette persistance — il suffit de sauvegarder le tableau et de le restaurer à l’init.

@main
struct MonAppApp: App {
    @AppStorage("nav-path") private var pathData: Data = Data()
    @State private var chemin: [Livre] = []

    var body: some Scene {
        WindowGroup {
            CatalogueView(chemin: $chemin)
                .onAppear { restaurer() }
                .onChange(of: chemin) { _, nouveau in sauvegarder(nouveau) }
        }
    }

    private func restaurer() {
        if let livres = try? JSONDecoder().decode([Livre].self, from: pathData) {
            chemin = livres
        }
    }

    private func sauvegarder(_ livres: [Livre]) {
        if let data = try? JSONEncoder().encode(livres) {
            pathData = data
        }
    }
}

Pour que Livre soit encodable, il doit conformer Codable (souvent généré automatiquement). @AppStorage persiste dans UserDefaults, suffisant pour quelques kilo-octets. Pour des données plus volumineuses, utiliser un fichier dans le répertoire Documents ou SwiftData.

Structurer un projet à plusieurs écrans : conseils de découpage

Au-delà des trois ou quatre écrans, l’organisation des fichiers fait toute la différence pour s’y retrouver. Le pattern le plus pratiqué consiste à créer un dossier par section fonctionnelle (Catalogue, Profil, Réglages), avec à l’intérieur les écrans et les composants spécifiques à cette section. Les composants génériques réutilisables (boutons stylés, cartes, badges) vont dans un dossier Components/ à part. Cette discipline rend les déplacements et renommages mécaniques, et permet à plusieurs développeurs de travailler en parallèle sans collisions de fichiers.

Pour la navigation entre sections, deux écoles cohabitent. La première utilise TabView au plus haut niveau avec un NavigationStack par onglet ; chaque section a sa propre pile, indépendante des autres. La seconde encapsule le tout dans un NavigationSplitView, plus adapté aux apps iPad et Mac qui exposent une sidebar permanente. Le choix entre les deux dépend de la cible : iPhone-only en mode portrait, choisir TabView ; multi-plateforme, choisir NavigationSplitView qui s’adapte mieux. Aucune des deux n’empêche d’utiliser l’autre dans un écran particulier — on peut très bien avoir une section avec un TabView imbriqué dans une sidebar NavigationSplitView.

Erreurs fréquentes en navigation SwiftUI

Erreur Cause Solution
Le titre de navigation n’apparaît pas .navigationTitle sur le NavigationStack au lieu de la vue interne Déplacer le modifier sur la vue contenue dans le stack
« Type ‘X’ does not conform to protocol ‘Hashable’ » Type poussé via NavigationLink(value:) non-Hashable Conformer Hashable ; trivial si toutes les propriétés sont déjà Hashable
L’écran de détail ne se charge pas avec navigationDestination .navigationDestination(for:) hors du NavigationStack Placer le modifier à l’intérieur du NavigationStack, sur la vue racine
Sheet vide ou écran noir .sheet appliqué à une vue détruite après animation Appliquer le modifier au container parent stable
NavigationSplitView affiche mal sur iPhone Aucun problème — comportement attendu Tester en mode paysage iPad pour valider le rendu multi-colonnes
Bouton retour absent Présentation modale au lieu de push Vérifier qu’on utilise NavigationLink et pas .sheet

Foire aux questions

Faut-il abandonner NavigationView ?

Oui pour les nouveaux projets. NavigationView est déprécié depuis iOS 16 et les bugs connus ne seront pas corrigés. Pour les projets existants, migrer écran par écran vers NavigationStack.

Comment naviguer après un événement asynchrone ?

Modifier le binding path dans le bloc .task ou dans une méthode async. NavigationStack détecte le changement et anime la transition. Exemple : chemin.append(livre) après un fetch réseau réussi.

NavigationSplitView ou NavigationStack sur iPad ?

NavigationSplitView est le choix par défaut pour iPad — il s’adapte au format et exploite l’espace écran. Sur iPhone, il retombe sur une pile naturelle. Utiliser NavigationStack sur iPad seulement quand le contenu n’a pas de structure hiérarchique évidente.

Comment passer un binding via NavigationLink ?

Encapsuler le binding dans une struct intermédiaire ou utiliser un modèle @Observable partagé via @Environment. NavigationLink(value:) n’accepte que des types Hashable, donc pas un binding direct.

Peut-on personnaliser l’animation de transition ?

Pas directement sur NavigationStack — Apple impose l’animation système pour la cohérence. Pour des transitions custom, utiliser .fullScreenCover avec un modifier .transition sur le contenu, ou implémenter une navigation manuelle avec ZStack.

Quel composant pour un wizard ou tunnel d’onboarding ?

Pour un tunnel séquentiel (étape 1 → 2 → 3), TabView avec le style .page est plus adapté que NavigationStack. Il fournit le swipe horizontal natif et les indicateurs de page. Cibler iOS 16 ou plus récent pour bénéficier de la pleine prise en charge.

Tutoriels suivants conseillés

Ressources officielles

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité