Le bug du « plat qui passe dessous »
Comment, dans une app de notation de plats, une page restaurant s'obstinait à s'afficher sous l'écran qu'on venait de pousser — et la règle de NavigationStack qui règle tout.
Tout commence par un rapport de bug d'apparence anodine. On ouvre le détail d'un plat, on tape sur la mention « Servi à… », la page du restaurant s'ouvre. Parfait. On tape ensuite sur un plat du menu… et là, surprise : on reste sur la page du restaurant, le plat s'étant glissé en dessous.
Ce comportement bizarre — « l'écran que je pousse atterrit sous l'écran courant » — n'est pas exotique. C'est le symptôme d'une seule erreur d'architecture de navigation, répétée à plusieurs endroits. Voici la traque, la mécanique sous-jacente de NavigationStack, et un patron pour ne plus retomber dedans.
📌 TL;DR
Sur une même NavigationStack, ne mélangez jamais les push value-based (NavigationLink(value:) / path.append) et item-based (navigationDestination(item:)). Un push value placé après un push item s'affiche sous lui. Unifiez tout sur un NavigationPath value-based.
1. Deux façons de pousser un écran
SwiftUI propose plusieurs manières d'empiler une destination. Deux nous intéressent, et c'est leur cohabitation qui pose problème.
① Value-based (piloté par le chemin)
On pousse une valeur ; une destination typée, déclarée à la racine, décide quelle vue afficher. La pile est un tableau ordonné — le NavigationPath.
NavigationStack(path: $path) {
List(dishes) { dish in
NavigationLink(value: dish) { DishRow(dish) } // pousse une valeur
}
.navigationDestination(for: CanonicalDish.self) { dish in
CanonicalDishDetailView(dish: dish) // la racine résout la valeur
}
}
// ailleurs, par programmation :
path.append(dish)② Item-based (piloté par un optionnel)
On lie une destination à un @Stateoptionnel : dès qu'il devient non-nil, l'écran est poussé. Pratique pour un déclenchement impératif (un tap, une réponse réseau…).
@State private var selectedDish: CanonicalDish?
.navigationDestination(item: $selectedDish) { dish in
CanonicalDishDetailView(dish: dish)
}
Button("Voir") { selectedDish = dish } // déclenché à la mainLes deux marchent. Le piège, c'est de les mélanger sur la même pile.
2. Pourquoi ça « passe dessous »
Une pile avec un path gère ses destinations comme un tableau ancré à la racine. Une destination navigationDestination(item:), elle, pousse dans une couche séparée, par-dessus. Quand les deux coexistent, un nouveau push value part dans la voie du path, sous la couche item déjà empilée.
❌ Pile mélangée (le bug)
✅ Pile homogène (corrigé)
Dans notre app, le chemin vers le détail d'un plat était parfois value-based (l'accueil, le classement), parfois item-based (les écrans en sheet — « Mes avis », « avis de quelqu'un », la wishlist). Et la page resto poussait ses plats en item-based. Dès qu'une descente enchaînait un push item puis un push value, le dernier passait dessous.
⚠️ La règle qui explique tout
Un push value après un push item sur la même pile s'affiche en dessous. La seule configuration fiable : tout value-based sur un seul path.
3. La fausse piste
Premier réflexe : « rendons donc tout item-based ». Mauvaise idée — empiler deux couches item sur une base value casse au deuxième niveau, et les onglets sont irréductiblement value-based (ils utilisent un NavigationPathet des transitions zoom). Pas de demi-mesure : il faut unifier. La majorité de l'app étant déjà value-based, le sens de la migration était clair.
4. Le fix, étape par étape
Un type porteur de contexte
La page resto doit pousser un plat avec son restaurant. On ne peut pas réutiliser la destination CanonicalDish de la racine (elle perdrait le contexte). On crée donc un type valeur dédié — qui pointe vers sa propre destination au lieu d'être masqué par celle, générique, de la racine.
struct RestaurantMenuDish: Hashable {
let dish: CanonicalDish
let restaurant: Restaurant
}
// la page resto pousse une VALEUR (plus de navigationDestination(item:)) :
NavigationLink(value: RestaurantMenuDish(dish: dish, restaurant: restaurant)) {
DishRow(dish: dish)
}Un helper qui déclare les destinations… et injecte un « pusher »
Certains push (avatar d'un avis, plat favori d'un profil) viennent de cartes réutilisables à callback, dans des vues qui ne possèdent pas le path. On expose une clé d'environnement : une fermeture navigationPush liée au path de la pile.
// 1. la clé d'environnement
private struct NavigationPushKey: EnvironmentKey {
static let defaultValue: (any Hashable) -> Void = { _ in }
}
extension EnvironmentValues {
var navigationPush: (any Hashable) -> Void {
get { self[NavigationPushKey.self] }
set { self[NavigationPushKey.self] = newValue }
}
}
// 2. un helper qui déclare les destinations partagées ET injecte le pusher
extension View {
func entityNavigationDestinations(path: Binding<NavigationPath>) -> some View {
self
.navigationDestination(for: Restaurant.self) { RestaurantDetailView(restaurant: $0) }
.navigationDestination(for: RestaurantMenuDish.self) { CanonicalDishDetailView(dish: $0.dish, restaurantContext: $0.restaurant) }
.navigationDestination(for: DishRating.self) { RatingDetailView(rating: $0) }
.navigationDestination(for: UserProfile.self) { PublicProfileView(userId: $0.id, initialName: $0.name) }
.environment(\.navigationPush) { value in
path.wrappedValue.append(value) // value-based, sur le path de cette pile
}
}
}Une vue « profonde » navigue alors ainsi, sans jamais posséder le path :
@Environment(\.navigationPush) private var navigationPush
RatingCard(rating: rating,
onUserTap: { user in navigationPush(user) }) // → pousse sur le path de l'hôte🧩 Petit bonus Swift
path.append(value) avec value de type any Hashablecompile : depuis Swift 5.7, l'ouverture d'existentiel permet de passer un existentiel à une fonction générique append<V: Hashable>(_:). La valeur est ajoutée au chemin avec son type dynamique, donc la bonne navigationDestination(for:) la capture.
5. Le détail qui tue : l'ordre des modificateurs
Pour qu'une vue poussée hérite de navigationPush, l'.environment doit envelopper toutes les navigationDestination de la pile. Comme il est injecté à la fin du helper, il suffit d'appliquer le helper en dernier, après les destinations propres à la racine :
Group { … }
.navigationDestination(for: CanonicalDish.self) { dishDetail($0) }
.navigationDestination(for: RestaurantBrand.self) { BrandDishesView(brand: $0) }
.entityNavigationDestinations(path: $path) // ← EN DERNIER : son .environment enveloppe toutPlacé ainsi, l'environnement est le modificateur le plus externe : chaque destination est évaluée dansl'environnement qui contient le pusher. Sur iOS 18, la propagation de l'environnement vers les destinations de navigationDestinationest fiable ; le pusher arrive jusqu'aux écrans les plus profonds.
✅ Le résultat
Les 4 onglets, les sheets (« Mes avis », « avis de quelqu'un », wishlist…), les listes d'abonnés et les deep-links tournent désormais sur un seul NavigationPath value-based. Les enchaînements profonds — profil → plat favori → « Servi à… » — se composent au lieu de passer dessous.
6. L'exception assumée
Un seul navigationDestination(item:)a survécu, à dessein : sur le détail d'un plat, le push vers le détail d'un avis. Il garde un hook bien pratique :
.navigationDestination(item: $selectedRating) { RatingDetailView(rating: $0) }
.onChange(of: selectedRating) { _, value in
if value == nil { Task { await reloadRatings() } } // rafraîchit les notes au retour
}Compromis conscient : ce reload-au-retour a de la valeur, et la seule descente qui resterait imparfaite (plat → avis → auteur) est rare — l'auteur, lui, s'ouvre déjà directement depuis le plat, en value-based. Documenter une exception vaut mieux que la subir.
7. À retenir
- Une seule façon de pousser par pile.En pratique : tout value-based sur un
NavigationPath. - Une destination typée par type et par racine. Jamais redéclarée dans une vue enfant.
- Value après item = dessous. Si « l'écran que je pousse atterrit en dessous », cherchez un
navigationDestination(item:)en amont. - Cartes partagées à callback ? Un pusher dans
@Environmentlaisse les vues profondes naviguer sans posséder le path. - L'ordre des modificateurs compte. L'
.environmentdoit envelopper les destinations → helper en dernier.
Moralité : ce qu'on prenait pour un bug d'affichage capricieux n'était qu'une pile qui mélangeait deux modèles mentaux. SwiftUI a un modèle — un chemin ordonné — et il faut s'y tenir d'un bout à l'autre de la pile. Une fois la navigation homogène, plus rien ne passe dessous.