SwiftUI NavigationStack Rehberi 2026: NavigationPath, Router Deseni ve Derin Bağlantılar
SwiftUI NavigationStack ve NavigationPath ile tip güvenli navigasyon: Router deseni, derin bağlantı işleme, cold-launch durum geri yükleme ve VoiceOver erişilebilirliği için 2026 rehberi.
SwiftUI NavigationStack, iOS 16 ile birlikte gelen ve NavigationView'un yerini alan, veri odaklı yığın tabanlı navigasyon API'sidir; NavigationPath ile programatik itme/çekme, tip güvenli yönlendirme ve derin bağlantı desteği sağlar. 2026'da yeni bir SwiftUI uygulaması başlatıyorsanız, NavigationStack artık tartışmasız varsayılandır. Bu rehberde NavigationPath'i, @Observable tabanlı Router desenini, derin bağlantı işleme, cold-launch durum geri yükleme ve VoiceOver erişilebilirliğini gerçek, çalışan kodla anlatıyorum (kendi üretim uygulamamda son iki senedir kullandığım desenler).
NavigationStack, iOS 16'dan bu yana yalnızca yığın tabanlı akışlar için önerilen API'dir; NavigationView kullanımdan kaldırılmıştır.
NavigationPath, farklı Hashable türlerini tek bir yığında tutan tip silinmiş bir konteynerdir; homojen akışlar için [Route] dizisi yeterlidir.
@Observable tabanlı Router sınıfı, Coordinator karmaşasına gerek kalmadan test edilebilir, merkezi bir yönlendirme mimarisi kurar.
Route enum'unu Codable yaparak NavigationPath'i diske serileştirebilir ve soğuk başlatmada tam navigasyon durumunu geri yükleyebilirsiniz.
Derin bağlantılar (onOpenURL) yol yığınına yeni değerler ekleyerek işlenir; böylece evrensel bağlantılar ve bildirimler aynı Router API'sinden geçer.
VoiceOver ve dinamik yazı boyutu, .navigationTitle, accessibilityLabel ve .navigationBarBackButtonHidden ile birleştiğinde ince ayar gerektirir; erişilebilirliği en başta düşünün.
NavigationStack nedir ve neden NavigationView'un yerini aldı?
NavigationStack, SwiftUI'nin iOS 16, iPadOS 16, macOS 13 ve watchOS 9 ile tanıttığı yığın tabanlı bir navigasyon API'sidir. Klasik NavigationView'dan farkı, ekran yığınını bir görünüm ağacı olarak değil, veri olarak temsil etmesidir. Bu tek fark, deep link, cold-launch geri yükleme ve programatik "root'a dön" gibi taleplerin neden nihayet birinci sınıf desteklendiğini açıklar. Apple, NavigationView'u iOS 16 ile birlikte deprecate ettiğini resmi belgelerinde açıkça belirtir.
NavigationView'da her NavigationLink, hedefini kendi içinde taşıdığı için hem test edilmesi zordu hem de programatik akışlarda çoğunlukla isActive Boolean'larına başvurmak zorunda kalıyorduk. Yeni modelde ise ana ekran tüm hedef eşleştirmelerini .navigationDestination(for:) ile tek noktada tanımlar; alt görünümler yalnızca değer iter. Bu hem UIKit tarafındaki push/pop mental modeliyle örtüşür hem de SwiftUI'nin veri odaklı doğasını korur.
Pratikte iki temel varyant var: iPhone için tek sütunlu NavigationStack ve iPad/macOS için üç sütuna kadar açılan NavigationSplitView. Bu rehberdeki desenlerin çoğu her ikisinde de çalışır; farklılıklar geldiğinde ayrıca işaretleyeceğim.
NavigationPath ve tipli dizi arasında ne fark var?
NavigationStack iki yol formatı kabul eder: belirli bir tipin dizisi ([Route]) veya tip silinmiş NavigationPath. Seçim, yığınınızda kaç farklı değer tipi olduğuna bağlıdır. Tek bir enum tüm hedefleri kapsıyorsa dizi daha okunabilirdir; birden fazla bağımsız tip (örneğin ayrı feature modüllerinden gelen UserProfile, Order, Article) itiliyorsa NavigationPath daha esnektir. Ben tek enum'la başlıyorum, yığın karışıklaşırsa NavigationPath'e geçiyorum. Aslında geriye dönüp bakınca, projelerin %90'ında tek enum yeterli oldu.
// 1) Tek bir tip için: sade ve okunaklı
@State private var path: [Route] = []
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: Route.self) { route in
route.destination
}
}
// 2) Karışık tipler için: NavigationPath
@State private var path = NavigationPath()
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: Route.self) { $0.destination }
.navigationDestination(for: Article.self) { ArticleView(article: $0) }
.navigationDestination(for: URL.self) { WebView(url: $0) }
}
NavigationPath'in gizli süper gücü, Codable desteklidir: NavigationPath.CodableRepresentation ile diske yazabilir, uygulama tekrar başladığında geri yükleyebilirsiniz. Ama bu ancak yığındaki tüm tipler CodableveHashable ise mümkündür. Apple'ın NavigationPath belgesi bu kısıtı ayrıntılı açıklar.
Tip güvenli Route enum'u ve navigationDestination
Stringle tanımlanmış navigasyon ("home", "detail/42"...) çabuk kırılır: derleme zamanında hiçbir garanti yoktur ve tipografik bir hata sessizce boş ekran doğurur. Bir keresinde bir güncelleme sonrası "artcle" yazdığımı ancak QA aşamasında fark ettim, hoş değil. 2026'da önerilen desen, tüm hedefleri kapsayan bir Route enum'udur. Enum Hashable (yol için) ve Codable (geri yükleme için) olmalıdır. İlişkili değerler (associated values), URL yolundaki parametrelere birebir karşılık gelir.
enum Route: Hashable, Codable {
case articleList
case articleDetail(id: UUID)
case profile(userID: String)
case settings
case debugMenu
@ViewBuilder var destination: some View {
switch self {
case .articleList: ArticleListView()
case .articleDetail(let id): ArticleDetailView(id: id)
case .profile(let userID): ProfileView(userID: userID)
case .settings: SettingsView()
case .debugMenu: DebugMenuView()
}
}
}
Şimdi tüm eşleştirme tek bir yerde: .navigationDestination(for: Route.self). Alt görünümlerdeki bağlantılar sadece değer iter:
Enum'daki hiçbir case'i unutmadığınız @ViewBuilder switch sayesinde derleyici garantidir. Yeni bir ekran eklediğinizde enum'a bir case ekler, destination switch'inde derleyici sizi uyarır; unutulmuş ekran diye bir şey kalmaz. Bu arada, SwiftUI için Swift 6 Sendable ve strict concurrency rehberimde anlattığım aktör izolasyonu prensipleri, bu enum'u main actor'a bağlı tutmanız gerektiğini hatırlatır. Hedeflerin UI'a bağlı olduğunu unutmayın.
Router deseni: @Observable ile temiz navigasyon
Yolu yalnızca root görünümde @State olarak tutarsanız, derin ağaçlardan yol yönetmek zorlaşır. 2026 pratiği, iOS 17 ile gelen @Observable makrosuyla küçük bir Router sınıfı yazmaktır. Bu sınıf yol dizisini sahiplenir, environment üzerinden alt görünümlere dağılır ve tek satır API'yle push/pop işlemlerini yönetir.
Kök görünüm bu router'ı environment'a koyar; herhangi bir alt görünüm @Environment(AppRouter.self) ile alır. Yolun kendisini @Bindable ile NavigationStack'e bağlarsınız:
@main
struct SwiftCraftedApp: App {
@State private var router = AppRouter()
var body: some Scene {
WindowGroup {
RootView()
.environment(router)
}
}
}
struct RootView: View {
@Environment(AppRouter.self) private var router
var body: some View {
@Bindable var router = router
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { $0.destination }
}
}
}
Bu yaklaşım hem tam Coordinator patenti kadar test edilebilirdir hem de üç sınıflık boilerplate'i tetiklemez. Aynı router'ı unit testlerde doğrudan somutlayıp push çağırıp path'i doğrulayabilirsiniz. SwiftUI ağacına ihtiyaç yoktur.
SwiftUI'de programatik navigasyon nasıl yapılır?
Programatik navigasyonun cevabı iki adımdır: (1) yola bir değer ekleyin, (2) o değerin bir navigationDestination tarafından ele alındığından emin olun. Kullanıcı bir düğmeye basmadığında, yani ağ çağrısı bittikten sonra ya da bir bildirim geldiğinde, aynı API'yi çağırırsınız:
func loadArticleFromServer() async {
do {
let article = try await api.fetchToday()
router.push(.articleDetail(id: article.id))
} catch {
router.push(.debugMenu)
}
}
Yığından geri gitmek de aynı derecede sade. Kullanıcı arayüzündeki geri düğmesine ek olarak elle router.pop() çağırabilir veya router.popToRoot() ile başlangıç ekranına dönebilirsiniz. Bunu, örneğin kullanıcının çıkış yaptığında ana ekrana zorla geri dönmek istediğinizde kullanırsınız.
Programatik navigasyonu manuel navigasyonla karıştırabilirsiniz. SwiftUI, kullanıcı geri tıkladığında da path'i güncelleyip senkron tutar. Böylece kullanıcının fiziksel geri hareketi ile router çağrınız aynı kaynağın (path dizisi) iki farklı sürücüsü olur.
Derin bağlantılar (Deep Links) nasıl işlenir?
Deep link işlemenin sırrı, gelen URL'yi Route enum'una çevirip router'a itmektir. Böylece evrensel bağlantılar, push bildirimleri, Spotlight indeksinden gelen açılışlar ve widget dokunuşları hep aynı yolu izler. Bir DeepLinkParser türü, URL'lerin path segmentlerini enum case'lerine eşler.
struct DeepLinkParser {
/// swiftcrafted://article/CE20F2E8-...
static func route(from url: URL) -> Route? {
guard url.scheme == "swiftcrafted" else { return nil }
let parts = url.pathComponents.filter { $0 != "/" }
switch (url.host, parts.first) {
case ("article", .some(let idString)):
guard let id = UUID(uuidString: idString) else { return nil }
return .articleDetail(id: id)
case ("profile", .some(let userID)):
return .profile(userID: userID)
case ("settings", _):
return .settings
default:
return nil
}
}
}
struct RootView: View {
@Environment(AppRouter.self) private var router
var body: some View {
@Bindable var router = router
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { $0.destination }
}
.onOpenURL { url in
guard let route = DeepLinkParser.route(from: url) else { return }
router.push(route)
}
}
}
Universal Link'leri apple-app-site-association dosyasıyla eşlerken de aynı onOpenURL tetiklenir. Push bildirimleri için UNUserNotificationCenter delege'sinden bir URL üretin ve aynı parser'a besleyin. Tek noktadan tüm giriş yollarını yönetirsiniz.
Cold-launch durum geri yükleme
Deep link uygulamayı sıfırdan açtığında ve kullanıcı 20 dakika sonra geri döndüğünde de aynı navigasyon durumunun yerinde durmasını isteyebiliriz. NavigationPath bunu CodableRepresentation ile mümkün kılar, ancak [Route] ile JSONEncoder kullanarak aynı sonuca çok daha az koddan varabilirsiniz.
@Observable
@MainActor
final class AppRouter {
private static let storageKey = "app-router.path.v1"
var path: [Route] = [] {
didSet { persist() }
}
init() { restore() }
private func persist() {
guard let data = try? JSONEncoder().encode(path) else { return }
UserDefaults.standard.set(data, forKey: Self.storageKey)
}
private func restore() {
guard
let data = UserDefaults.standard.data(forKey: Self.storageKey),
let saved = try? JSONDecoder().decode([Route].self, from: data)
else { return }
path = saved
}
}
Storage key'inde v1 versiyonlama önemlidir. Bir gün Route'a yeni case eklediğinizde ya da associated value şemasını değiştirdiğinizde, v2'ye geçin ve eski v1 anahtarını silin. Böylece eski kullanıcılar boş bir path'le başlar, kırık bir decode ile değil. Ayrıca eksik veriye ("bu makale artık silinmiş") karşı, hedef görünümlerinizin yumuşak bir "bulunamadı" durumu sunması gerekir.
Sekmeler, NavigationSplitView ve iPad
Sekmeli uygulamalarda her sekme kendiNavigationStack'ine sahip olmalıdır. Router'ınızı sekme başına dilimleyin veya her sekme için ayrı bir @Observable router tanımlayın. Böylece "Ana Sayfa" sekmesindeki derinlik "Arama" sekmesinden bağımsız yaşar; iOS'un beklediği kullanıcı deneyimi budur.
iPad ve macOS için NavigationSplitView devreye girer. İki veya üç sütunlu bir yerleşim sağlar; sağ sütun (detail) genelde kendi içinde bir NavigationStack barındırır. Split view'da yan çubuğu (sidebar) List(selection:) ile @State var selection: Route?'a bağlar ve seçim değiştikçe detail bölümünü yenilersiniz. iPad özelinde NavigationSplitView'un yığın kolaps davranışını iyi test edin; dikey moda alındığında, "başlangıç" bölümü tek bir sütuna daralır. (Bu kısmı QA'nın erken keşfettiği bir şeydi, ben iPad Simulator'da fark etmemiştim.)
Kalıcı veriyle çalışıyorsanız, SwiftData @Model ve @Query rehberimde gösterdiğim üzere, detail görünümü doğrudan @Query ile seçili nesneyi çekebilir; router yalnızca hangi kimliğin seçildiğini tutar.
Erişilebilirlik: VoiceOver, dinamik tip ve animasyon
Navigasyon iyi ele alınmadığında en çok VoiceOver kullanıcıları zarar görür. SwiftUI'nin NavigationStack'i başlığı otomatik olarak seslendirir, ancak birkaç ince ayar VoiceOver deneyimini büyük ölçüde iyileştirir. Ben her yeni ekranda üç ana adımı check-listlerim: bilgilendirici navigationTitle, geri düğmesinin gizlendiği durumlarda anlamlı bir accessibilityLabel ve odak sıralaması.
Push animasyonlarını mükemmel eğriye ayarlamak isteseniz de, UIAccessibility.isReduceMotionEnabled açık olduğunda geçişi hafifletmek şart. @Environment(\.accessibilityReduceMotion) ile bunu SwiftUI tarafından okuyup, animasyonu nil yapabilirsiniz:
Dinamik yazı boyutunda çok büyük başlıklar navigasyon çubuğunu kırar; navigationBarTitleDisplayMode(.inline) ile başlığı sabit tutmak, .largeTitle'ı ise root ekranda bırakmak dengeli bir sonuç verir. VoiceOver kullanıcılarının derin bir yığında "geri" hareketini fark edebilmesi için, hedef ekrandaki ilk element odaklanabilir bir başlık olmalıdır; .accessibilityAddTraits(.isHeader) tam bunu sağlar.
Yaygın hatalar ve nasıl önlenir
NavigationStack'i üretimde birkaç yıldır kullanıyorum ve tekrarlayan tuzaklar oldukça belirgin. Bunları önden bilirseniz saatler kazanırsınız.
Navigasyon durumunu ViewModel'de tutmak. Route'lar bir View sorumluluğudur; ViewModel'inize yol dizileri eklemek testleri ve mimariyi bozar. Yol Router'da yaşar.
Route'un Hashable'ını unutmak. Enum'daki associated value'lar Hashable değilse enum da olmaz. Derleyici size bunu birçok satır aşağıda sinsi bir hata olarak fısıldar.
İç içe geçmiş NavigationStack. Bir sheet, kendi NavigationStack'ini içerebilir; sorun değildir. Ancak ana yığın içinde başka bir NavigationStack varsa, dokunma alanları çakışır ve navigationDestination yanlış yerde çalışır.
Kayıtsız hedef.path'e URL ittiniz ama navigationDestination(for: URL.self) tanımlamayı unuttunuz. Derleme başarılı, uygulama boş sayfa gösterir. Test yazın ki bunu yakalayın.
Router'ı test etmemek.@Observable sınıfı ekranı somutlamadan test edebilirsiniz; push, pop, popToRoot için tam kapsama koyun. Swift Testing rehberimde anlattığım @Test makrosu bunu çok tatlı hâle getirir.
Sıkça sorulan sorular
NavigationStack ve NavigationView arasındaki fark nedir?
NavigationView, iOS 16 ile kullanımdan kaldırıldı ve yerine NavigationStack ile NavigationSplitView geldi. NavigationStack, navigasyonu bir dizi Hashable değer olarak temsil eder; bu sayede programatik navigasyon, deep link ve durum geri yükleme birinci sınıf desteklenir. NavigationView'un isActive tabanlı hilelerine artık ihtiyaç yoktur.
NavigationPath ne zaman kullanılmalı, ne zaman [Route] dizisi tercih edilmeli?
Yığınınızda tek bir tip iterken (tipik olarak bir Route enum'u), [Route] dizisi daha tip güvenli ve okunabilirdir. Birden fazla bağımsız Hashable tip iterken, örneğin farklı feature modüllerinden gelen ayrı modeller, NavigationPath'in tip silinmiş yapısı gerekir. Diske serileştirme için ikisi de Codable olmalıdır.
SwiftUI'de derin bağlantı (deep link) nasıl işlenir?
Root görünüme .onOpenURL { url in ... } ekleyip URL'yi bir Route enum'una parse edin, ardından router'ınızın push metodunu çağırın. Aynı akış hem custom URL scheme hem de universal link için çalışır. Uygulama soğuk başlatıldıysa, root'un yerleşmesini bekledikten sonra tetikleyin.
NavigationStack durumu uygulama kapandığında nasıl saklanır?
Route enum'unuzu Codable yapın, path'i [Route] olarak tutun ve didSet içinde JSONEncoder ile UserDefaults'a yazın. Uygulama açılışında aynı anahtarı okuyup path'e atayın. Şema değiştikçe versiyonlu anahtar (v1, v2) kullanarak eski verileri temiz bir şekilde göz ardı edin.
NavigationStack ile VoiceOver arasında dikkat edilmesi gereken ne var?
Her hedef ekranın anlamlı bir navigationTitle'ı olmalı ve ilk odak elementi .accessibilityAddTraits(.isHeader) ile başlık olarak işaretlenmelidir. Kullanıcı Reduce Motion açtığında push animasyonunu @Environment(\.accessibilityReduceMotion) ile kapatın; VoiceOver kullanıcıları için sesli duyuru sırasının yeni ekranda yeniden başladığından emin olun.
Swift Testing, XCTest'in yerini alan modern framework. @Test makrosu, #expect kontrolleri, paralel çalıştırma ve @Suite ile Swift 6 ve Xcode 16'da birim testlerin nasıl yazıldığını, XCTest'ten kademeli geçiş örnekleriyle görün.