Tüm Yazılar
KategoriSwiftUI
Okuma Süresi
22 dk
Yayın Tarihi
...
Kelime Sayısı
4.348kelime

Kahveni hazırla - bu içerikli bir makale!

NavigationStack'in ötesine geçin! Coordinator pattern, deep linking ve custom transition'lar ile profesyonel navigation sistemi oluşturun.

SwiftUI Navigation Sistemi: Deep Linking ve Coordinator Pattern

iOS geliştirmede navigation, kullanıcı deneyiminin kalbinde yer alır. Bir kullanıcı uygulamayı açtığında, onlarca farklı ekran arasında sorunsuzca geçiş yapabilmeli, bir push notification'a tıkladığında doğru sayfaya yönlendirilmeli ve uygulamayı kapattıktan sonra bile kaldığı yerden devam edebilmeli. Kulağa basit geliyor, değil mi? Ama pratikte bu, iOS geliştiricilerin en çok zorlandığı konulardan biri.

Bu rehberde, SwiftUI'nin NavigationStack'inden başlayıp, production-ready bir navigation sistemi kurana kadar adım adım ilerleyeceğiz. Coordinator Pattern, Deep Linking, State Restoration ve Custom Transitions... Hepsini gerçek, çalışan kod örnekleriyle öğreneceksin.

İçindekiler

  • Navigation'ın Evrimi: NavigationView'dan NavigationStack'e
  • NavigationStack Temelleri ve Type-Safe Routing
  • Coordinator Pattern: Navigation Logic'i Merkeze Almak
  • Deep Linking: URL'lerden Ekranlara
  • Tab-Based Navigation Stratejileri
  • State Persistence ve Restoration
  • Custom Transitions ve Hero Animations
  • Testing Navigation Logic
  • Production Checklist

2019'da SwiftUI ilk çıktığında, NavigationView vardı. Basit uygulamalar için yeterliydi ama bir sorun vardı: programmatic navigation neredeyse imkansızdı. isActive binding'leri, gizli state'ler, beklenmedik davranışlar... Tam bir kabustu.

iOS 16 ile birlikte Apple, NavigationStack'i tanıttı ve oyunun kuralları değişti. Artık:

  • Data-driven navigation: Path array'i ile navigation stack'i kontrol edebiliyorsun
  • Type-safe destinations: Generic `navigationDestination` ile type-safe routing
  • Programmatic control: Push, pop, popToRoot - hepsi tek satır kod
⚠️ Dikkat: NavigationView artık deprecated. iOS 16+ hedefliyorsan, NavigationStack kullanmalısın.
Özellik
UINavigationController
NavigationView
NavigationStack
Coordinator Pattern
**Platform**
UIKit
SwiftUI (iOS 13+)
SwiftUI (iOS 16+)
UIKit/SwiftUI
**Programmatic Navigation**
✅ Tam kontrol
⚠️ Sınırlı
✅ Tam kontrol
✅ Tam kontrol
**Type Safety**
❌ Runtime
❌ Runtime
✅ Compile-time
✅ Compile-time
**Deep Linking**
Manuel
Çok zor
Kolay
Kolay
**State Restoration**
Manuel
Yok
Codable ile
Codable ile
**Test Edilebilirlik**
Orta
Zor
İyi
Mükemmel
**Bellek Yönetimi**
Manuel
Otomatik
Otomatik
Manuel
**Custom Transitions**
✅ Tam destek
⚠️ Sınırlı
⚠️ matchedGeometry
✅ Tam destek
**Öğrenme Eğrisi**
Orta
Kolay
Kolay
Zor
**Production Readiness**
✅ Olgun
❌ Deprecated
✅ Stabil
✅ Kanıtlanmış

Eski ve yeni yaklaşımı karşılaştıralım:

swift
1// ❌ ESKİ YÖNTEM - NavigationView (iOS 13-15)
2// Karmaşık, hata yapmaya açık, test edilmesi zor
3struct OldNavigationView: View {
4 @State private var isDetailActive = false
5 @State private var selectedProduct: Product?
6
7 var body: some View {
8 NavigationView {
9 List(products) { product in
10 // Gizli NavigationLink - gerçek bir anti-pattern!
11 ZStack {
12 NavigationLink(
13 destination: ProductDetailView(product: selectedProduct ?? product),
14 isActive: $isDetailActive
15 ) {
16 EmptyView()
17 }
18 .hidden()
19
20 ProductRow(product: product)
21 .onTapGesture {
22 selectedProduct = product
23 isDetailActive = true
24 }
25 }
26 }
27 }
28 }
29}
30 
31// ✅ YENİ YÖNTEM - NavigationStack (iOS 16+)
32// Temiz, anlaşılır, test edilebilir
33struct ModernNavigationView: View {
34 @State private var navigationPath = NavigationPath()
35
36 var body: some View {
37 NavigationStack(path: $navigationPath) {
38 List(products) { product in
39 Button {
40 navigationPath.append(product)
41 } label: {
42 ProductRow(product: product)
43 }
44 }
45 .navigationDestination(for: Product.self) { product in
46 ProductDetailView(product: product)
47 }
48 }
49 }
50}

Farkı görüyor musun? Yeni yöntemde:

  1. Navigation state tek bir NavigationPath'te
  2. Destination'lar type-safe
  3. Programmatic navigation sadece append çağırmak kadar basit

NavigationStack'in gücü, type-safe routing sisteminden geliyor. Önce sağlam bir Route enum'u tanımlayalım:

swift
1// 🎯 Type-Safe Route Tanımı
2// Tüm navigation destination'larını tek bir enum'da topla
3enum AppRoute: Hashable {
4 // Ana sayfalar
5 case home
6 case search(query: String? = nil)
7 case profile(userId: UUID)
8 case settings
9
10 // Ürün flow'u
11 case productList(categoryId: UUID)
12 case productDetail(productId: UUID)
13 case productReviews(productId: UUID)
14
15 // Sepet flow'u
16 case cart
17 case checkout
18 case orderConfirmation(orderId: UUID)
19
20 // Diğer
21 case webView(url: URL, title: String)
22 case imageGallery(images: [URL], startIndex: Int)
23}
24 
25// Route'ları view'lara map'leyen extension
26extension AppRoute {
27 @ViewBuilder
28 var destination: some View {
29 switch self {
30 case .home:
31 HomeView()
32
33 case .search(let query):
34 SearchView(initialQuery: query)
35
36 case .profile(let userId):
37 ProfileView(userId: userId)
38
39 case .settings:
40 SettingsView()
41
42 case .productList(let categoryId):
43 ProductListView(categoryId: categoryId)
44
45 case .productDetail(let productId):
46 ProductDetailView(productId: productId)
47
48 case .productReviews(let productId):
49 ProductReviewsView(productId: productId)
50
51 case .cart:
52 CartView()
53
54 case .checkout:
55 CheckoutView()
56
57 case .orderConfirmation(let orderId):
58 OrderConfirmationView(orderId: orderId)
59
60 case .webView(let url, let title):
61 WebViewContainer(url: url, title: title)
62
63 case .imageGallery(let images, let startIndex):
64 ImageGalleryView(images: images, startIndex: startIndex)
65 }
66 }
67}

Şimdi bu route'ları kullanan bir NavigationStack kuralım:

swift
1// 🏗️ Ana Navigation Container
2struct AppNavigationContainer: View {
3 @State private var path = NavigationPath()
4
5 var body: some View {
6 NavigationStack(path: $path) {
7 HomeView()
8 // Tek bir navigationDestination ile tüm route'ları handle et
9 .navigationDestination(for: AppRoute.self) { route in
10 route.destination
11 }
12 }
13 // Path'i environment üzerinden paylaş
14 .environment(\.navigationPath, $path)
15 }
16}
17 
18// Custom Environment Key
19private struct NavigationPathKey: EnvironmentKey {
20 static let defaultValue: Binding<NavigationPath> = .constant(NavigationPath())
21}
22 
23extension EnvironmentValues {
24 var navigationPath: Binding<NavigationPath> {
25 get { self[NavigationPathKey.self] }
26 set { self[NavigationPathKey.self] = newValue }
27 }
28}
29 
30// Herhangi bir view'dan navigation yapmak artık çok kolay
31struct ProductRow: View {
32 let product: Product
33 @Environment(\.navigationPath) private var path
34
35 var body: some View {
36 Button {
37 path.wrappedValue.append(AppRoute.productDetail(productId: product.id))
38 } label: {
39 HStack {
40 AsyncImage(url: product.thumbnailURL) { image in
41 image.resizable().aspectRatio(contentMode: .fill)
42 } placeholder: {
43 Color.gray.opacity(0.3)
44 }
45 .frame(width: 60, height: 60)
46 .clipShape(RoundedRectangle(cornerRadius: 8))
47
48 VStack(alignment: .leading, spacing: 4) {
49 Text(product.name)
50 .font(.headline)
51 Text(product.formattedPrice)
52 .font(.subheadline)
53 .foregroundStyle(.secondary)
54 }
55
56 Spacer()
57
58 Image(systemName: "chevron.right")
59 .foregroundStyle(.tertiary)
60 }
61 .padding(.vertical, 8)
62 }
63 .buttonStyle(.plain)
64 }
65}
💡 Pro Tip: NavigationPath, type-erased bir collection. İçine farklı Hashable tipleri atabilirsin. Ama ben tek bir Route enum'u kullanmanı öneriyorum - kod çok daha okunabilir ve maintain edilebilir oluyor.

Coordinator Pattern: Navigation Logic'i Merkeze Almak

View'ların içinde navigation logic'i dağınık olarak tutmak, küçük projelerde işe yarar. Ama proje büyüdükçe kabus başlar. Coordinator Pattern, navigation mantığını merkezi bir yere toplar ve view'ları navigation'dan bağımsız hale getirir.

swift
1// 🎛️ Navigation Coordinator - Tüm Navigation Logic Burada
2@MainActor
3final class NavigationCoordinator: ObservableObject {
4 // MARK: - Published Properties
5 @Published var path = NavigationPath()
6 @Published var presentedSheet: SheetDestination?
7 @Published var presentedFullScreenCover: FullScreenDestination?
8 @Published var alertItem: AlertItem?
9 @Published var confirmationDialog: ConfirmationDialogItem?
10
11 // MARK: - Private State
12 private var navigationHistory: [Date: AppRoute] = [:]
13
14 // MARK: - Navigation Actions
15
16 /// Yeni bir ekrana git
17 func navigate(to route: AppRoute) {
18 path.append(route)
19 trackNavigation(route)
20 }
21
22 /// Birden fazla ekrana sırayla git
23 func navigate(to routes: [AppRoute]) {
24 routes.forEach { route in
25 path.append(route)
26 trackNavigation(route)
27 }
28 }
29
30 /// Bir önceki ekrana dön
31 func goBack() {
32 guard !path.isEmpty else { return }
33 path.removeLast()
34 }
35
36 /// Belirli sayıda ekran geri git
37 func goBack(count: Int) {
38 let actualCount = min(count, path.count)
39 path.removeLast(actualCount)
40 }
41
42 /// Ana sayfaya dön
43 func popToRoot() {
44 path = NavigationPath()
45 }
46
47 /// Belirli bir route'a kadar geri git (o route dahil)
48 /// NOT: NavigationPath type-erased olduğundan bu zor.
49 /// Alternatif: [AppRoute] array kullanmak
50 func popTo(_ route: AppRoute) {
51 // Bu implementation için typed array gerekli
52 // Aşağıda TypedNavigationCoordinator örneğine bak
53 }
54
55 // MARK: - Sheet Presentation
56
57 func present(_ sheet: SheetDestination) {
58 presentedSheet = sheet
59 }
60
61 func presentFullScreen(_ cover: FullScreenDestination) {
62 presentedFullScreenCover = cover
63 }
64
65 func dismissSheet() {
66 presentedSheet = nil
67 }
68
69 func dismissFullScreen() {
70 presentedFullScreenCover = nil
71 }
72
73 // MARK: - Alerts & Confirmations
74
75 func showAlert(_ alert: AlertItem) {
76 alertItem = alert
77 }
78
79 func showConfirmation(_ dialog: ConfirmationDialogItem) {
80 confirmationDialog = dialog
81 }
82
83 // MARK: - Analytics & Tracking
84
85 private func trackNavigation(_ route: AppRoute) {
86 navigationHistory[Date()] = route
87 // Analytics'e gönder
88 Analytics.shared.track(.screenView(route.analyticsName))
89 }
90}
91 
92// MARK: - Sheet Destinations
93enum SheetDestination: Identifiable {
94 case addToCart(product: Product)
95 case editProfile
96 case filters(onApply: (FilterOptions) -> Void)
97 case share(items: [Any])
98 case login(completion: (Bool) -> Void)
99
100 var id: String {
101 switch self {
102 case .addToCart: return "addToCart"
103 case .editProfile: return "editProfile"
104 case .filters: return "filters"
105 case .share: return "share"
106 case .login: return "login"
107 }
108 }
109}
110 
111// MARK: - FullScreen Destinations
112enum FullScreenDestination: Identifiable {
113 case onboarding
114 case mediaViewer(media: [Media], startIndex: Int)
115 case camera(completion: (UIImage?) -> Void)
116
117 var id: String {
118 switch self {
119 case .onboarding: return "onboarding"
120 case .mediaViewer: return "mediaViewer"
121 case .camera: return "camera"
122 }
123 }
124}
125 
126// MARK: - Alert & Dialog Items
127struct AlertItem: Identifiable {
128 let id = UUID()
129 let title: String
130 let message: String?
131 let primaryButton: Alert.Button
132 let secondaryButton: Alert.Button?
133}
134 
135struct ConfirmationDialogItem: Identifiable {
136 let id = UUID()
137 let title: String
138 let message: String?
139 let actions: [ConfirmationAction]
140}
141 
142struct ConfirmationAction: Identifiable {
143 let id = UUID()
144 let title: String
145 let role: ButtonRole?
146 let action: () -> Void
147}

Bu coordinator'ı uygulamaya entegre edelim:

swift
1// 🏠 Ana Uygulama Yapısı
2@main
3struct MyApp: App {
4 @StateObject private var coordinator = NavigationCoordinator()
5
6 var body: some Scene {
7 WindowGroup {
8 RootView()
9 .environmentObject(coordinator)
10 }
11 }
12}
13 
14struct RootView: View {
15 @EnvironmentObject private var coordinator: NavigationCoordinator
16
17 var body: some View {
18 NavigationStack(path: $coordinator.path) {
19 HomeView()
20 .navigationDestination(for: AppRoute.self) { route in
21 route.destination
22 .environmentObject(coordinator)
23 }
24 }
25 // Sheet presentations
26 .sheet(item: $coordinator.presentedSheet) { sheet in
27 sheetContent(for: sheet)
28 }
29 // Full screen covers
30 .fullScreenCover(item: $coordinator.presentedFullScreenCover) { cover in
31 fullScreenContent(for: cover)
32 }
33 // Alerts
34 .alert(item: $coordinator.alertItem) { item in
35 Alert(
36 title: Text(item.title),
37 message: item.message.map { Text($0) },
38 primaryButton: item.primaryButton,
39 secondaryButton: item.secondaryButton ?? .cancel()
40 )
41 }
42 }
43
44 @ViewBuilder
45 private func sheetContent(for sheet: SheetDestination) -> some View {
46 switch sheet {
47 case .addToCart(let product):
48 AddToCartSheet(product: product)
49 case .editProfile:
50 EditProfileView()
51 case .filters(let onApply):
52 FiltersSheet(onApply: onApply)
53 case .share(let items):
54 ShareSheet(items: items)
55 case .login(let completion):
56 LoginView(completion: completion)
57 }
58 }
59
60 @ViewBuilder
61 private func fullScreenContent(for cover: FullScreenDestination) -> some View {
62 switch cover {
63 case .onboarding:
64 OnboardingView()
65 case .mediaViewer(let media, let startIndex):
66 MediaViewerView(media: media, startIndex: startIndex)
67 case .camera(let completion):
68 CameraView(completion: completion)
69 }
70 }
71}
72 
73// View'larda kullanımı artık çok temiz
74struct ProductDetailView: View {
75 let productId: UUID
76 @EnvironmentObject private var coordinator: NavigationCoordinator
77 @StateObject private var viewModel: ProductDetailViewModel
78
79 init(productId: UUID) {
80 self.productId = productId
81 _viewModel = StateObject(wrappedValue: ProductDetailViewModel(productId: productId))
82 }
83
84 var body: some View {
85 ScrollView {
86 // Ürün detayları...
87 }
88 .toolbar {
89 ToolbarItem(placement: .primaryAction) {
90 Button {
91 coordinator.present(.share(items: [viewModel.product.shareURL]))
92 } label: {
93 Image(systemName: "square.and.arrow.up")
94 }
95 }
96 }
97 .safeAreaInset(edge: .bottom) {
98 Button {
99 coordinator.present(.addToCart(product: viewModel.product))
100 } label: {
101 Text("Sepete Ekle")
102 .font(.headline)
103 .frame(maxWidth: .infinity)
104 .padding()
105 .background(Color.accentColor)
106 .foregroundColor(.white)
107 .clipShape(RoundedRectangle(cornerRadius: 12))
108 }
109 .padding()
110 }
111 }
112}

Deep Linking: URL'lerden Ekranlara

Deep linking, modern uygulamaların olmazsa olmazı. Bir kullanıcı:

  • Push notification'a tıkladığında
  • Email'deki bir linke bastığında
  • Safari'den uygulamayı açtığında
  • Başka bir uygulamadan yönlendirildiğinde

...doğru ekrana gitmeli. Bunu iki şekilde yapabilirsin:

  1. URL Schemes: myapp://product/123 - Basit ama güvensiz
  2. Universal Links: https://myapp.com/product/123 - Güvenli ve önerilen
swift
1// 🔗 Deep Link Handler - URL'leri Route'lara Çevir
2final class DeepLinkHandler {
3
4 // Singleton - ama test için dependency injection tercih et
5 static let shared = DeepLinkHandler()
6
7 // MARK: - URL Parsing
8
9 /// URL'i AppRoute'a çevir
10 func route(from url: URL) -> DeepLinkResult {
11 // URL Scheme mi Universal Link mi?
12 if url.scheme == "myapp" {
13 return parseURLScheme(url)
14 } else if url.host?.contains("myapp.com") == true {
15 return parseUniversalLink(url)
16 }
17
18 return .unhandled
19 }
20
21 private func parseURLScheme(_ url: URL) -> DeepLinkResult {
22 // myapp://product/123
23 // myapp://profile/user-id-here
24 // myapp://cart
25 // myapp://search?q=iphone
26
27 guard let host = url.host else { return .unhandled }
28
29 let pathComponents = url.pathComponents.filter { $0 != "/" }
30 let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
31
32 switch host {
33 case "product":
34 if let idString = pathComponents.first, let id = UUID(uuidString: idString) {
35 return .navigate([.productDetail(productId: id)])
36 }
37
38 case "profile":
39 if let idString = pathComponents.first, let id = UUID(uuidString: idString) {
40 return .navigate([.profile(userId: id)])
41 }
42
43 case "cart":
44 return .navigate([.cart])
45
46 case "checkout":
47 return .navigate([.cart, .checkout])
48
49 case "search":
50 let query = queryItems?.first(where: { $0.name == "q" })?.value
51 return .navigate([.search(query: query)])
52
53 case "order":
54 if let idString = pathComponents.first, let id = UUID(uuidString: idString) {
55 return .navigate([.orderConfirmation(orderId: id)])
56 }
57
58 case "category":
59 if let idString = pathComponents.first, let id = UUID(uuidString: idString) {
60 // Kategori > Ürün listesi flow'u
61 if pathComponents.count > 1,
62 let productIdString = pathComponents[safe: 1],
63 let productId = UUID(uuidString: productIdString) {
64 return .navigate([
65 .productList(categoryId: id),
66 .productDetail(productId: productId)
67 ])
68 }
69 return .navigate([.productList(categoryId: id)])
70 }
71
72 default:
73 break
74 }
75
76 return .unhandled
77 }
78
79 private func parseUniversalLink(_ url: URL) -> DeepLinkResult {
80 // https://myapp.com/product/123
81 // Aynı logic, farklı URL format
82
83 let pathComponents = url.pathComponents.filter { $0 != "/" }
84 guard let first = pathComponents.first else { return .unhandled }
85
86 switch first {
87 case "product", "p":
88 if let idString = pathComponents[safe: 1], let id = UUID(uuidString: idString) {
89 return .navigate([.productDetail(productId: id)])
90 }
91
92 case "user", "u", "profile":
93 if let idString = pathComponents[safe: 1], let id = UUID(uuidString: idString) {
94 return .navigate([.profile(userId: id)])
95 }
96
97 // ... diğer route'lar
98
99 default:
100 break
101 }
102
103 return .unhandled
104 }
105}
106 
107// MARK: - Deep Link Result
108enum DeepLinkResult {
109 case navigate([AppRoute])
110 case presentSheet(SheetDestination)
111 case presentFullScreen(FullScreenDestination)
112 case unhandled
113}
114 
115// MARK: - Array Safe Subscript
116extension Array {
117 subscript(safe index: Index) -> Element? {
118 indices.contains(index) ? self[index] : nil
119 }
120}

Şimdi bunu uygulamaya entegre edelim:

swift
1// 📱 App Entry Point ile Deep Link Handling
2@main
3struct MyApp: App {
4 @StateObject private var coordinator = NavigationCoordinator()
5 @Environment(\.scenePhase) private var scenePhase
6
7 var body: some Scene {
8 WindowGroup {
9 RootView()
10 .environmentObject(coordinator)
11 // URL Scheme handling
12 .onOpenURL { url in
13 handleDeepLink(url)
14 }
15 }
16 }
17
18 private func handleDeepLink(_ url: URL) {
19 let result = DeepLinkHandler.shared.route(from: url)
20
21 switch result {
22 case .navigate(let routes):
23 // Önce root'a dön, sonra navigate et
24 coordinator.popToRoot()
25
26 // Kısa bir delay - animation için
27 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
28 coordinator.navigate(to: routes)
29 }
30
31 case .presentSheet(let sheet):
32 coordinator.present(sheet)
33
34 case .presentFullScreen(let cover):
35 coordinator.presentFullScreen(cover)
36
37 case .unhandled:
38 // Analytics'e log'la
39 Analytics.shared.track(.deepLinkFailed(url: url.absoluteString))
40 }
41 }
42}
43 
44// MARK: - Push Notification Deep Linking
45// AppDelegate veya NotificationCenter'dan gelen notification'ları handle et
46extension NavigationCoordinator {
47 func handleNotification(_ notification: UNNotificationResponse) {
48 guard let urlString = notification.notification.request.content.userInfo["deep_link"] as? String,
49 let url = URL(string: urlString) else {
50 return
51 }
52
53 let result = DeepLinkHandler.shared.route(from: url)
54
55 if case .navigate(let routes) = result {
56 // Notification'dan geldiğini track et
57 Analytics.shared.track(.notificationOpened(
58 id: notification.notification.request.identifier,
59 destination: routes.last?.analyticsName ?? "unknown"
60 ))
61
62 popToRoot()
63 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
64 self.navigate(to: routes)
65 }
66 }
67 }
68}
💡 Pro Tip: Universal Links için Apple App Site Association (AASA) dosyasını doğru configure etmeyi unutma! .well-known/apple-app-site-association path'inde olmalı ve Content-Type application/json olmalı.

Tab-Based Navigation Stratejileri

Modern uygulamaların çoğu tab bar kullanıyor. Her tab'ın kendi navigation stack'i olmalı. Aksi halde kullanıcı bir tab'dan diğerine geçtiğinde navigation state'i kaybeder.

swift
1// 🗂️ Tab-Based Navigation Architecture
2struct MainTabView: View {
3 @State private var selectedTab: Tab = .home
4 @StateObject private var tabCoordinators = TabCoordinators()
5
6 var body: some View {
7 TabView(selection: $selectedTab) {
8 // Home Tab
9 NavigationStack(path: $tabCoordinators.home.path) {
10 HomeView()
11 .navigationDestination(for: AppRoute.self) { $0.destination }
12 }
13 .tabItem {
14 Label("Ana Sayfa", systemImage: selectedTab == .home ? "house.fill" : "house")
15 }
16 .tag(Tab.home)
17 .environmentObject(tabCoordinators.home)
18
19 // Search Tab
20 NavigationStack(path: $tabCoordinators.search.path) {
21 SearchView()
22 .navigationDestination(for: AppRoute.self) { $0.destination }
23 }
24 .tabItem {
25 Label("Keşfet", systemImage: selectedTab == .search ? "magnifyingglass.circle.fill" : "magnifyingglass")
26 }
27 .tag(Tab.search)
28 .environmentObject(tabCoordinators.search)
29
30 // Cart Tab
31 NavigationStack(path: $tabCoordinators.cart.path) {
32 CartView()
33 .navigationDestination(for: AppRoute.self) { $0.destination }
34 }
35 .tabItem {
36 Label("Sepet", systemImage: selectedTab == .cart ? "cart.fill" : "cart")
37 }
38 .tag(Tab.cart)
39 .badge(tabCoordinators.cartBadgeCount)
40 .environmentObject(tabCoordinators.cart)
41
42 // Profile Tab
43 NavigationStack(path: $tabCoordinators.profile.path) {
44 ProfileView()
45 .navigationDestination(for: AppRoute.self) { $0.destination }
46 }
47 .tabItem {
48 Label("Profil", systemImage: selectedTab == .profile ? "person.fill" : "person")
49 }
50 .tag(Tab.profile)
51 .environmentObject(tabCoordinators.profile)
52 }
53 .onChange(of: selectedTab) { oldTab, newTab in
54 // Aynı tab'a tekrar tıklandığında root'a dön
55 if oldTab == newTab {
56 tabCoordinators.coordinator(for: newTab).popToRoot()
57 }
58 }
59 }
60}
61 
62// MARK: - Tab Enum
63enum Tab: String, CaseIterable {
64 case home
65 case search
66 case cart
67 case profile
68}
69 
70// MARK: - Tab Coordinators Container
71@MainActor
72final class TabCoordinators: ObservableObject {
73 let home = TabNavigationCoordinator()
74 let search = TabNavigationCoordinator()
75 let cart = TabNavigationCoordinator()
76 let profile = TabNavigationCoordinator()
77
78 @Published var cartBadgeCount: Int = 0
79
80 func coordinator(for tab: Tab) -> TabNavigationCoordinator {
81 switch tab {
82 case .home: return home
83 case .search: return search
84 case .cart: return cart
85 case .profile: return profile
86 }
87 }
88
89 func resetAll() {
90 home.popToRoot()
91 search.popToRoot()
92 cart.popToRoot()
93 profile.popToRoot()
94 }
95}
96 
97// MARK: - Tab-Specific Coordinator
98@MainActor
99final class TabNavigationCoordinator: ObservableObject {
100 @Published var path = NavigationPath()
101 @Published var presentedSheet: SheetDestination?
102
103 func navigate(to route: AppRoute) {
104 path.append(route)
105 }
106
107 func goBack() {
108 guard !path.isEmpty else { return }
109 path.removeLast()
110 }
111
112 func popToRoot() {
113 path = NavigationPath()
114 }
115
116 func present(_ sheet: SheetDestination) {
117 presentedSheet = sheet
118 }
119
120 func dismissSheet() {
121 presentedSheet = nil
122 }
123}

Deep linking ile tab navigation'ı birleştirmek:

swift
1// 🔗 Deep Link + Tab Navigation Entegrasyonu
2extension DeepLinkHandler {
3 func routeWithTab(from url: URL) -> (Tab, [AppRoute])? {
4 let result = route(from: url)
5
6 guard case .navigate(let routes) = result else {
7 return nil
8 }
9
10 // Route'a göre tab belirle
11 let tab = determineTab(for: routes)
12 return (tab, routes)
13 }
14
15 private func determineTab(for routes: [AppRoute]) -> Tab {
16 guard let firstRoute = routes.first else { return .home }
17
18 switch firstRoute {
19 case .home, .productList, .productDetail:
20 return .home
21 case .search:
22 return .search
23 case .cart, .checkout, .orderConfirmation:
24 return .cart
25 case .profile, .settings:
26 return .profile
27 default:
28 return .home
29 }
30 }
31}
32 
33// Kullanımı:
34func handleDeepLink(_ url: URL) {
35 if let (tab, routes) = DeepLinkHandler.shared.routeWithTab(from: url) {
36 // Önce doğru tab'a geç
37 selectedTab = tab
38
39 // Sonra navigate et
40 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
41 let coordinator = tabCoordinators.coordinator(for: tab)
42 coordinator.popToRoot()
43 routes.forEach { coordinator.navigate(to: $0) }
44 }
45 }
46}

State Persistence ve Restoration

Kullanıcı uygulamayı kapattığında veya sistem memory baskısı nedeniyle uygulamayı kill ettiğinde, navigation state'i korunmalı. iOS 17+ için bunu Codable ve AppStorage ile yapabiliriz.

swift
1// 💾 Navigation State Persistence
2// NOT: NavigationPath Codable değil, bu yüzden typed array kullanıyoruz
3 
4@MainActor
5final class PersistableNavigationCoordinator: ObservableObject {
6 @Published var routes: [AppRoute] = []
7 @Published var selectedTab: Tab = .home
8
9 private let persistence = NavigationPersistence()
10
11 init() {
12 // Önceki state'i restore et
13 restoreState()
14 }
15
16 // MARK: - Navigation Actions
17
18 func navigate(to route: AppRoute) {
19 routes.append(route)
20 saveState()
21 }
22
23 func goBack() {
24 guard !routes.isEmpty else { return }
25 routes.removeLast()
26 saveState()
27 }
28
29 func popToRoot() {
30 routes.removeAll()
31 saveState()
32 }
33
34 func popTo(_ route: AppRoute) {
35 if let index = routes.firstIndex(of: route) {
36 routes = Array(routes.prefix(through: index))
37 saveState()
38 }
39 }
40
41 // MARK: - Persistence
42
43 private func saveState() {
44 persistence.save(NavigationState(
45 routes: routes,
46 selectedTab: selectedTab
47 ))
48 }
49
50 private func restoreState() {
51 if let state = persistence.restore() {
52 self.routes = state.routes
53 self.selectedTab = state.selectedTab
54 }
55 }
56}
57 
58// MARK: - Persistence Layer
59struct NavigationState: Codable {
60 let routes: [AppRoute]
61 let selectedTab: Tab
62 let timestamp: Date
63
64 init(routes: [AppRoute], selectedTab: Tab) {
65 self.routes = routes
66 self.selectedTab = selectedTab
67 self.timestamp = Date()
68 }
69}
70 
71final class NavigationPersistence {
72 private let key = "navigation_state"
73 private let maxAge: TimeInterval = 60 * 60 * 24 // 24 saat
74
75 func save(_ state: NavigationState) {
76 guard let data = try? JSONEncoder().encode(state) else { return }
77 UserDefaults.standard.set(data, forKey: key)
78 }
79
80 func restore() -> NavigationState? {
81 guard let data = UserDefaults.standard.data(forKey: key),
82 let state = try? JSONDecoder().decode(NavigationState.self, from: data) else {
83 return nil
84 }
85
86 // Çok eski state'i restore etme
87 guard Date().timeIntervalSince(state.timestamp) < maxAge else {
88 clear()
89 return nil
90 }
91
92 return state
93 }
94
95 func clear() {
96 UserDefaults.standard.removeObject(forKey: key)
97 }
98}
99 
100// MARK: - AppRoute Codable Extension
101extension AppRoute: Codable {
102 enum CodingKeys: String, CodingKey {
103 case type, productId, userId, categoryId, orderId, query, url, title, images, startIndex
104 }
105
106 init(from decoder: Decoder) throws {
107 let container = try decoder.container(keyedBy: CodingKeys.self)
108 let type = try container.decode(String.self, forKey: .type)
109
110 switch type {
111 case "home":
112 self = .home
113 case "search":
114 let query = try container.decodeIfPresent(String.self, forKey: .query)
115 self = .search(query: query)
116 case "profile":
117 let userId = try container.decode(UUID.self, forKey: .userId)
118 self = .profile(userId: userId)
119 case "productDetail":
120 let productId = try container.decode(UUID.self, forKey: .productId)
121 self = .productDetail(productId: productId)
122 case "productList":
123 let categoryId = try container.decode(UUID.self, forKey: .categoryId)
124 self = .productList(categoryId: categoryId)
125 case "cart":
126 self = .cart
127 case "checkout":
128 self = .checkout
129 case "orderConfirmation":
130 let orderId = try container.decode(UUID.self, forKey: .orderId)
131 self = .orderConfirmation(orderId: orderId)
132 case "settings":
133 self = .settings
134 default:
135 self = .home
136 }
137 }
138
139 func encode(to encoder: Encoder) throws {
140 var container = encoder.container(keyedBy: CodingKeys.self)
141
142 switch self {
143 case .home:
144 try container.encode("home", forKey: .type)
145 case .search(let query):
146 try container.encode("search", forKey: .type)
147 try container.encodeIfPresent(query, forKey: .query)
148 case .profile(let userId):
149 try container.encode("profile", forKey: .type)
150 try container.encode(userId, forKey: .userId)
151 case .productDetail(let productId):
152 try container.encode("productDetail", forKey: .type)
153 try container.encode(productId, forKey: .productId)
154 case .productList(let categoryId):
155 try container.encode("productList", forKey: .type)
156 try container.encode(categoryId, forKey: .categoryId)
157 case .cart:
158 try container.encode("cart", forKey: .type)
159 case .checkout:
160 try container.encode("checkout", forKey: .type)
161 case .orderConfirmation(let orderId):
162 try container.encode("orderConfirmation", forKey: .type)
163 try container.encode(orderId, forKey: .orderId)
164 case .settings:
165 try container.encode("settings", forKey: .type)
166 default:
167 try container.encode("home", forKey: .type)
168 }
169 }
170}

Custom Transitions ve Hero Animations

SwiftUI'nin matchedGeometryEffect'i, ekranlar arası akıcı hero animation'lar yapmanı sağlar. Ama dikkatli kullan - yanlış kullanımda performans sorunlarına yol açabilir.

swift
1// 🎬 Hero Animation ile Navigation
2// Namespace'i shared state olarak tut
3 
4struct ProductGridView: View {
5 let products: [Product]
6 @Namespace private var heroNamespace
7 @State private var selectedProduct: Product?
8
9 var body: some View {
10 ScrollView {
11 LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))], spacing: 16) {
12 ForEach(products) { product in
13 ProductGridCell(
14 product: product,
15 namespace: heroNamespace,
16 isSelected: selectedProduct?.id == product.id
17 )
18 .onTapGesture {
19 withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
20 selectedProduct = product
21 }
22 }
23 }
24 }
25 .padding()
26 }
27 .overlay {
28 if let product = selectedProduct {
29 ProductDetailOverlay(
30 product: product,
31 namespace: heroNamespace,
32 onDismiss: {
33 withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
34 selectedProduct = nil
35 }
36 }
37 )
38 }
39 }
40 }
41}
42 
43struct ProductGridCell: View {
44 let product: Product
45 let namespace: Namespace.ID
46 let isSelected: Bool
47
48 var body: some View {
49 VStack(alignment: .leading, spacing: 8) {
50 AsyncImage(url: product.imageURL) { image in
51 image
52 .resizable()
53 .aspectRatio(contentMode: .fill)
54 } placeholder: {
55 Rectangle()
56 .fill(Color.gray.opacity(0.2))
57 }
58 .frame(height: 200)
59 .clipShape(RoundedRectangle(cornerRadius: 12))
60 .matchedGeometryEffect(id: "image-\(product.id)", in: namespace)
61
62 Text(product.name)
63 .font(.headline)
64 .lineLimit(2)
65 .matchedGeometryEffect(id: "title-\(product.id)", in: namespace)
66
67 Text(product.formattedPrice)
68 .font(.subheadline)
69 .foregroundStyle(.secondary)
70 .matchedGeometryEffect(id: "price-\(product.id)", in: namespace)
71 }
72 .opacity(isSelected ? 0 : 1)
73 }
74}
75 
76struct ProductDetailOverlay: View {
77 let product: Product
78 let namespace: Namespace.ID
79 let onDismiss: () -> Void
80
81 var body: some View {
82 ScrollView {
83 VStack(alignment: .leading, spacing: 16) {
84 // Hero image
85 AsyncImage(url: product.imageURL) { image in
86 image
87 .resizable()
88 .aspectRatio(contentMode: .fill)
89 } placeholder: {
90 Rectangle()
91 .fill(Color.gray.opacity(0.2))
92 }
93 .frame(height: 400)
94 .clipShape(RoundedRectangle(cornerRadius: 16))
95 .matchedGeometryEffect(id: "image-\(product.id)", in: namespace)
96
97 VStack(alignment: .leading, spacing: 8) {
98 Text(product.name)
99 .font(.title)
100 .fontWeight(.bold)
101 .matchedGeometryEffect(id: "title-\(product.id)", in: namespace)
102
103 Text(product.formattedPrice)
104 .font(.title2)
105 .foregroundStyle(.secondary)
106 .matchedGeometryEffect(id: "price-\(product.id)", in: namespace)
107
108 // Ek detaylar - hero animation dışında
109 Text(product.description)
110 .font(.body)
111 .foregroundStyle(.secondary)
112 .padding(.top)
113 }
114 .padding(.horizontal)
115 }
116 }
117 .background(.background)
118 .overlay(alignment: .topTrailing) {
119 Button {
120 onDismiss()
121 } label: {
122 Image(systemName: "xmark.circle.fill")
123 .font(.title)
124 .foregroundStyle(.secondary)
125 }
126 .padding()
127 }
128 }
129}
⚠️ Performans Uyarısı: matchedGeometryEffect, her frame'de layout hesaplaması yapar. Çok fazla element'e uygulamak performansı düşürür. Sadece 2-3 kritik element için kullan.

Testing Navigation Logic

Navigation logic'i test etmek, uygulamanın güvenilirliği için kritik. Coordinator'ları mock'layarak unit test yazabilirsin:

swift
1// 🧪 Navigation Coordinator Tests
2import XCTest
3@testable import MyApp
4 
5@MainActor
6final class NavigationCoordinatorTests: XCTestCase {
7 var coordinator: NavigationCoordinator!
8
9 override func setUp() {
10 super.setUp()
11 coordinator = NavigationCoordinator()
12 }
13
14 override func tearDown() {
15 coordinator = nil
16 super.tearDown()
17 }
18
19 // MARK: - Navigation Tests
20
21 func testNavigateToRoute() {
22 // Given
23 let route = AppRoute.productDetail(productId: UUID())
24
25 // When
26 coordinator.navigate(to: route)
27
28 // Then
29 XCTAssertEqual(coordinator.path.count, 1)
30 }
31
32 func testNavigateToMultipleRoutes() {
33 // Given
34 let routes: [AppRoute] = [
35 .productList(categoryId: UUID()),
36 .productDetail(productId: UUID())
37 ]
38
39 // When
40 coordinator.navigate(to: routes)
41
42 // Then
43 XCTAssertEqual(coordinator.path.count, 2)
44 }
45
46 func testGoBack() {
47 // Given
48 coordinator.navigate(to: .home)
49 coordinator.navigate(to: .settings)
50
51 // When
52 coordinator.goBack()
53
54 // Then
55 XCTAssertEqual(coordinator.path.count, 1)
56 }
57
58 func testGoBackOnEmptyPath() {
59 // Given - empty path
60
61 // When
62 coordinator.goBack()
63
64 // Then - should not crash
65 XCTAssertTrue(coordinator.path.isEmpty)
66 }
67
68 func testPopToRoot() {
69 // Given
70 coordinator.navigate(to: [.home, .settings, .profile(userId: UUID())])
71
72 // When
73 coordinator.popToRoot()
74
75 // Then
76 XCTAssertTrue(coordinator.path.isEmpty)
77 }
78
79 // MARK: - Deep Link Tests
80
81 func testDeepLinkProductDetail() {
82 // Given
83 let productId = UUID()
84 let url = URL(string: "myapp://product/\(productId.uuidString)")!
85
86 // When
87 let result = DeepLinkHandler.shared.route(from: url)
88
89 // Then
90 if case .navigate(let routes) = result {
91 XCTAssertEqual(routes.count, 1)
92 if case .productDetail(let id) = routes[0] {
93 XCTAssertEqual(id, productId)
94 } else {
95 XCTFail("Expected productDetail route")
96 }
97 } else {
98 XCTFail("Expected navigate result")
99 }
100 }
101
102 func testInvalidDeepLink() {
103 // Given
104 let url = URL(string: "myapp://invalid/path")!
105
106 // When
107 let result = DeepLinkHandler.shared.route(from: url)
108
109 // Then
110 if case .unhandled = result {
111 // Success
112 } else {
113 XCTFail("Expected unhandled result")
114 }
115 }
116}
117 
118// MARK: - Mock Coordinator for View Tests
119@MainActor
120final class MockNavigationCoordinator: NavigationCoordinator {
121 var navigatedRoutes: [AppRoute] = []
122 var presentedSheets: [SheetDestination] = []
123 var goBackCallCount = 0
124 var popToRootCallCount = 0
125
126 override func navigate(to route: AppRoute) {
127 navigatedRoutes.append(route)
128 super.navigate(to: route)
129 }
130
131 override func goBack() {
132 goBackCallCount += 1
133 super.goBack()
134 }
135
136 override func popToRoot() {
137 popToRootCallCount += 1
138 super.popToRoot()
139 }
140
141 override func present(_ sheet: SheetDestination) {
142 presentedSheets.append(sheet)
143 super.present(sheet)
144 }
145}

Sonuç ve Öneriler

Bu rehberde SwiftUI navigation sisteminin derinliklerine indik. Özetleyelim:

Öğrendiklerimiz:

  • NavigationStack: iOS 16+ için standart - NavigationView artık deprecated
  • Type-safe routing: ile derleme zamanında hata yakalama
  • Coordinator Pattern: ile navigation logic'i merkezileştirme
  • Deep Linking: ile URL'lerden ekranlara yönlendirme
  • Tab-based navigation: ile her tab'a özel stack yönetimi
  • State persistence: ile navigation state'i koruma
  • Hero animations: ile akıcı geçişler
  • Testing: ile güvenilir navigation

En Önemli Tavsiyeler:

  1. Tek Route Enum: Tüm destination'larını tek bir enum'da topla
  2. Coordinator Kullan: View'lardan navigation logic'i ayır
  3. Deep Link'i Unutma: Her önemli ekran URL ile erişilebilir olmalı
  4. Test Yaz: Navigation bug'ları en sinir bozucu bug'lar
  5. Performansa Dikkat: matchedGeometryEffect'i ölçülü kullan

Okuyucu Ödülü

Tebrikler! Bu uzun rehberi sonuna kadar okuduğun için sana özel bir hediyem var:

ALTIN İPUCU

Bu yazının en değerli bilgisi

Bu ipucu, yazının en önemli çıkarımını içeriyor.

Kaynaklar

Easter Egg

Gizli bir bilgi buldun!

Bu bölümde gizli bir bilgi var. Keşfetmek ister misin?

Etiketler

#SwiftUI#Navigation#iOS#UIKit#Router#Deep Link
Muhittin Çamdalı

Muhittin Çamdalı

Senior iOS Developer

12+ yıllık deneyime sahip iOS Developer. Swift, SwiftUI ve modern iOS mimarileri konusunda uzman. Apple platformlarında performanslı ve kullanıcı dostu uygulamalar geliştiriyorum.

iOS Geliştirme Haberleri

Haftalık Swift tips, SwiftUI tricks ve iOS best practices. Spam yok, sadece değerli içerik.

Gizliliğinize saygı duyuyoruz. İstediğiniz zaman abonelikten çıkabilirsiniz.

Paylaş

Bunu da begenebilirsiniz