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
Navigation'ın Evrimi: NavigationView'dan NavigationStack'e
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.
Navigation Yaklaşımları Karşılaştırma Tablosu
Ö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 zor3struct OldNavigationView: View {4 @State private var isDetailActive = false5 @State private var selectedProduct: Product?6 7 var body: some View {8 NavigationView {9 List(products) { product in10 // Gizli NavigationLink - gerçek bir anti-pattern!11 ZStack {12 NavigationLink(13 destination: ProductDetailView(product: selectedProduct ?? product),14 isActive: $isDetailActive15 ) {16 EmptyView()17 }18 .hidden()19 20 ProductRow(product: product)21 .onTapGesture {22 selectedProduct = product23 isDetailActive = true24 }25 }26 }27 }28 }29}30 31// ✅ YENİ YÖNTEM - NavigationStack (iOS 16+)32// Temiz, anlaşılır, test edilebilir33struct ModernNavigationView: View {34 @State private var navigationPath = NavigationPath()35 36 var body: some View {37 NavigationStack(path: $navigationPath) {38 List(products) { product in39 Button {40 navigationPath.append(product)41 } label: {42 ProductRow(product: product)43 }44 }45 .navigationDestination(for: Product.self) { product in46 ProductDetailView(product: product)47 }48 }49 }50}Farkı görüyor musun? Yeni yöntemde:
- Navigation state tek bir
NavigationPath'te - Destination'lar type-safe
- Programmatic navigation sadece
appendçağırmak kadar basit
NavigationStack Temelleri ve Type-Safe Routing
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 topla3enum AppRoute: Hashable {4 // Ana sayfalar5 case home6 case search(query: String? = nil)7 case profile(userId: UUID)8 case settings9 10 // Ürün flow'u11 case productList(categoryId: UUID)12 case productDetail(productId: UUID)13 case productReviews(productId: UUID)14 15 // Sepet flow'u16 case cart17 case checkout18 case orderConfirmation(orderId: UUID)19 20 // Diğer21 case webView(url: URL, title: String)22 case imageGallery(images: [URL], startIndex: Int)23}24 25// Route'ları view'lara map'leyen extension26extension AppRoute {27 @ViewBuilder28 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 Container2struct 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 et9 .navigationDestination(for: AppRoute.self) { route in10 route.destination11 }12 }13 // Path'i environment üzerinden paylaş14 .environment(\.navigationPath, $path)15 }16}17 18// Custom Environment Key19private 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 kolay31struct ProductRow: View {32 let product: Product33 @Environment(\.navigationPath) private var path34 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 in41 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 Burada2@MainActor3final class NavigationCoordinator: ObservableObject {4 // MARK: - Published Properties5 @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 State12 private var navigationHistory: [Date: AppRoute] = [:]13 14 // MARK: - Navigation Actions15 16 /// Yeni bir ekrana git17 func navigate(to route: AppRoute) {18 path.append(route)19 trackNavigation(route)20 }21 22 /// Birden fazla ekrana sırayla git23 func navigate(to routes: [AppRoute]) {24 routes.forEach { route in25 path.append(route)26 trackNavigation(route)27 }28 }29 30 /// Bir önceki ekrana dön31 func goBack() {32 guard !path.isEmpty else { return }33 path.removeLast()34 }35 36 /// Belirli sayıda ekran geri git37 func goBack(count: Int) {38 let actualCount = min(count, path.count)39 path.removeLast(actualCount)40 }41 42 /// Ana sayfaya dön43 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 kullanmak50 func popTo(_ route: AppRoute) {51 // Bu implementation için typed array gerekli52 // Aşağıda TypedNavigationCoordinator örneğine bak53 }54 55 // MARK: - Sheet Presentation56 57 func present(_ sheet: SheetDestination) {58 presentedSheet = sheet59 }60 61 func presentFullScreen(_ cover: FullScreenDestination) {62 presentedFullScreenCover = cover63 }64 65 func dismissSheet() {66 presentedSheet = nil67 }68 69 func dismissFullScreen() {70 presentedFullScreenCover = nil71 }72 73 // MARK: - Alerts & Confirmations74 75 func showAlert(_ alert: AlertItem) {76 alertItem = alert77 }78 79 func showConfirmation(_ dialog: ConfirmationDialogItem) {80 confirmationDialog = dialog81 }82 83 // MARK: - Analytics & Tracking84 85 private func trackNavigation(_ route: AppRoute) {86 navigationHistory[Date()] = route87 // Analytics'e gönder88 Analytics.shared.track(.screenView(route.analyticsName))89 }90}91 92// MARK: - Sheet Destinations93enum SheetDestination: Identifiable {94 case addToCart(product: Product)95 case editProfile96 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 Destinations112enum FullScreenDestination: Identifiable {113 case onboarding114 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 Items127struct AlertItem: Identifiable {128 let id = UUID()129 let title: String130 let message: String?131 let primaryButton: Alert.Button132 let secondaryButton: Alert.Button?133}134 135struct ConfirmationDialogItem: Identifiable {136 let id = UUID()137 let title: String138 let message: String?139 let actions: [ConfirmationAction]140}141 142struct ConfirmationAction: Identifiable {143 let id = UUID()144 let title: String145 let role: ButtonRole?146 let action: () -> Void147}Bu coordinator'ı uygulamaya entegre edelim:
swift
1// 🏠 Ana Uygulama Yapısı2@main3struct 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: NavigationCoordinator16 17 var body: some View {18 NavigationStack(path: $coordinator.path) {19 HomeView()20 .navigationDestination(for: AppRoute.self) { route in21 route.destination22 .environmentObject(coordinator)23 }24 }25 // Sheet presentations26 .sheet(item: $coordinator.presentedSheet) { sheet in27 sheetContent(for: sheet)28 }29 // Full screen covers30 .fullScreenCover(item: $coordinator.presentedFullScreenCover) { cover in31 fullScreenContent(for: cover)32 }33 // Alerts34 .alert(item: $coordinator.alertItem) { item in35 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 @ViewBuilder45 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 @ViewBuilder61 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 temiz74struct ProductDetailView: View {75 let productId: UUID76 @EnvironmentObject private var coordinator: NavigationCoordinator77 @StateObject private var viewModel: ProductDetailViewModel78 79 init(productId: UUID) {80 self.productId = productId81 _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:
- URL Schemes:
myapp://product/123- Basit ama güvensiz - Universal Links:
https://myapp.com/product/123- Güvenli ve önerilen
swift
1// 🔗 Deep Link Handler - URL'leri Route'lara Çevir2final class DeepLinkHandler {3 4 // Singleton - ama test için dependency injection tercih et5 static let shared = DeepLinkHandler()6 7 // MARK: - URL Parsing8 9 /// URL'i AppRoute'a çevir10 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 .unhandled19 }20 21 private func parseURLScheme(_ url: URL) -> DeepLinkResult {22 // myapp://product/12323 // myapp://profile/user-id-here24 // myapp://cart25 // myapp://search?q=iphone26 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)?.queryItems31 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" })?.value51 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'u61 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 break74 }75 76 return .unhandled77 }78 79 private func parseUniversalLink(_ url: URL) -> DeepLinkResult {80 // https://myapp.com/product/12381 // Aynı logic, farklı URL format82 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'lar98 99 default:100 break101 }102 103 return .unhandled104 }105}106 107// MARK: - Deep Link Result108enum DeepLinkResult {109 case navigate([AppRoute])110 case presentSheet(SheetDestination)111 case presentFullScreen(FullScreenDestination)112 case unhandled113}114 115// MARK: - Array Safe Subscript116extension Array {117 subscript(safe index: Index) -> Element? {118 indices.contains(index) ? self[index] : nil119 }120}Şimdi bunu uygulamaya entegre edelim:
swift
1// 📱 App Entry Point ile Deep Link Handling2@main3struct MyApp: App {4 @StateObject private var coordinator = NavigationCoordinator()5 @Environment(\.scenePhase) private var scenePhase6 7 var body: some Scene {8 WindowGroup {9 RootView()10 .environmentObject(coordinator)11 // URL Scheme handling12 .onOpenURL { url in13 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 et24 coordinator.popToRoot()25 26 // Kısa bir delay - animation için27 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'la39 Analytics.shared.track(.deepLinkFailed(url: url.absoluteString))40 }41 }42}43 44// MARK: - Push Notification Deep Linking45// AppDelegate veya NotificationCenter'dan gelen notification'ları handle et46extension 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 return51 }52 53 let result = DeepLinkHandler.shared.route(from: url)54 55 if case .navigate(let routes) = result {56 // Notification'dan geldiğini track et57 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-associationpath'inde olmalı ve Content-Typeapplication/jsonolmalı.
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 Architecture2struct MainTabView: View {3 @State private var selectedTab: Tab = .home4 @StateObject private var tabCoordinators = TabCoordinators()5 6 var body: some View {7 TabView(selection: $selectedTab) {8 // Home Tab9 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 Tab20 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 Tab31 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 Tab43 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 in54 // Aynı tab'a tekrar tıklandığında root'a dön55 if oldTab == newTab {56 tabCoordinators.coordinator(for: newTab).popToRoot()57 }58 }59 }60}61 62// MARK: - Tab Enum63enum Tab: String, CaseIterable {64 case home65 case search66 case cart67 case profile68}69 70// MARK: - Tab Coordinators Container71@MainActor72final 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 = 079 80 func coordinator(for tab: Tab) -> TabNavigationCoordinator {81 switch tab {82 case .home: return home83 case .search: return search84 case .cart: return cart85 case .profile: return profile86 }87 }88 89 func resetAll() {90 home.popToRoot()91 search.popToRoot()92 cart.popToRoot()93 profile.popToRoot()94 }95}96 97// MARK: - Tab-Specific Coordinator98@MainActor99final 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 = sheet118 }119 120 func dismissSheet() {121 presentedSheet = nil122 }123}Deep linking ile tab navigation'ı birleştirmek:
swift
1// 🔗 Deep Link + Tab Navigation Entegrasyonu2extension 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 nil8 }9 10 // Route'a göre tab belirle11 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 .home21 case .search:22 return .search23 case .cart, .checkout, .orderConfirmation:24 return .cart25 case .profile, .settings:26 return .profile27 default:28 return .home29 }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 = tab38 39 // Sonra navigate et40 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 Persistence2// NOT: NavigationPath Codable değil, bu yüzden typed array kullanıyoruz3 4@MainActor5final class PersistableNavigationCoordinator: ObservableObject {6 @Published var routes: [AppRoute] = []7 @Published var selectedTab: Tab = .home8 9 private let persistence = NavigationPersistence()10 11 init() {12 // Önceki state'i restore et13 restoreState()14 }15 16 // MARK: - Navigation Actions17 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: - Persistence42 43 private func saveState() {44 persistence.save(NavigationState(45 routes: routes,46 selectedTab: selectedTab47 ))48 }49 50 private func restoreState() {51 if let state = persistence.restore() {52 self.routes = state.routes53 self.selectedTab = state.selectedTab54 }55 }56}57 58// MARK: - Persistence Layer59struct NavigationState: Codable {60 let routes: [AppRoute]61 let selectedTab: Tab62 let timestamp: Date63 64 init(routes: [AppRoute], selectedTab: Tab) {65 self.routes = routes66 self.selectedTab = selectedTab67 self.timestamp = Date()68 }69}70 71final class NavigationPersistence {72 private let key = "navigation_state"73 private let maxAge: TimeInterval = 60 * 60 * 24 // 24 saat74 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 nil84 }85 86 // Çok eski state'i restore etme87 guard Date().timeIntervalSince(state.timestamp) < maxAge else {88 clear()89 return nil90 }91 92 return state93 }94 95 func clear() {96 UserDefaults.standard.removeObject(forKey: key)97 }98}99 100// MARK: - AppRoute Codable Extension101extension AppRoute: Codable {102 enum CodingKeys: String, CodingKey {103 case type, productId, userId, categoryId, orderId, query, url, title, images, startIndex104 }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 = .home113 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 = .cart127 case "checkout":128 self = .checkout129 case "orderConfirmation":130 let orderId = try container.decode(UUID.self, forKey: .orderId)131 self = .orderConfirmation(orderId: orderId)132 case "settings":133 self = .settings134 default:135 self = .home136 }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 Navigation2// Namespace'i shared state olarak tut3 4struct ProductGridView: View {5 let products: [Product]6 @Namespace private var heroNamespace7 @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 in13 ProductGridCell(14 product: product,15 namespace: heroNamespace,16 isSelected: selectedProduct?.id == product.id17 )18 .onTapGesture {19 withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {20 selectedProduct = product21 }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 = nil35 }36 }37 )38 }39 }40 }41}42 43struct ProductGridCell: View {44 let product: Product45 let namespace: Namespace.ID46 let isSelected: Bool47 48 var body: some View {49 VStack(alignment: .leading, spacing: 8) {50 AsyncImage(url: product.imageURL) { image in51 image52 .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: Product78 let namespace: Namespace.ID79 let onDismiss: () -> Void80 81 var body: some View {82 ScrollView {83 VStack(alignment: .leading, spacing: 16) {84 // Hero image85 AsyncImage(url: product.imageURL) { image in86 image87 .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ışında109 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 Tests2import XCTest3@testable import MyApp4 5@MainActor6final 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 = nil16 super.tearDown()17 }18 19 // MARK: - Navigation Tests20 21 func testNavigateToRoute() {22 // Given23 let route = AppRoute.productDetail(productId: UUID())24 25 // When26 coordinator.navigate(to: route)27 28 // Then29 XCTAssertEqual(coordinator.path.count, 1)30 }31 32 func testNavigateToMultipleRoutes() {33 // Given34 let routes: [AppRoute] = [35 .productList(categoryId: UUID()),36 .productDetail(productId: UUID())37 ]38 39 // When40 coordinator.navigate(to: routes)41 42 // Then43 XCTAssertEqual(coordinator.path.count, 2)44 }45 46 func testGoBack() {47 // Given48 coordinator.navigate(to: .home)49 coordinator.navigate(to: .settings)50 51 // When52 coordinator.goBack()53 54 // Then55 XCTAssertEqual(coordinator.path.count, 1)56 }57 58 func testGoBackOnEmptyPath() {59 // Given - empty path60 61 // When62 coordinator.goBack()63 64 // Then - should not crash65 XCTAssertTrue(coordinator.path.isEmpty)66 }67 68 func testPopToRoot() {69 // Given70 coordinator.navigate(to: [.home, .settings, .profile(userId: UUID())])71 72 // When73 coordinator.popToRoot()74 75 // Then76 XCTAssertTrue(coordinator.path.isEmpty)77 }78 79 // MARK: - Deep Link Tests80 81 func testDeepLinkProductDetail() {82 // Given83 let productId = UUID()84 let url = URL(string: "myapp://product/\(productId.uuidString)")!85 86 // When87 let result = DeepLinkHandler.shared.route(from: url)88 89 // Then90 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 // Given104 let url = URL(string: "myapp://invalid/path")!105 106 // When107 let result = DeepLinkHandler.shared.route(from: url)108 109 // Then110 if case .unhandled = result {111 // Success112 } else {113 XCTFail("Expected unhandled result")114 }115 }116}117 118// MARK: - Mock Coordinator for View Tests119@MainActor120final class MockNavigationCoordinator: NavigationCoordinator {121 var navigatedRoutes: [AppRoute] = []122 var presentedSheets: [SheetDestination] = []123 var goBackCallCount = 0124 var popToRootCallCount = 0125 126 override func navigate(to route: AppRoute) {127 navigatedRoutes.append(route)128 super.navigate(to: route)129 }130 131 override func goBack() {132 goBackCallCount += 1133 super.goBack()134 }135 136 override func popToRoot() {137 popToRootCallCount += 1138 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:
- Tek Route Enum: Tüm destination'larını tek bir enum'da topla
- Coordinator Kullan: View'lardan navigation logic'i ayır
- Deep Link'i Unutma: Her önemli ekran URL ile erişilebilir olmalı
- Test Yaz: Navigation bug'ları en sinir bozucu bug'lar
- 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
- Apple Developer - NavigationStack
- WWDC22 - The SwiftUI Cookbook for Navigation
- Swift with Majid - Mastering NavigationStack
Easter Egg
Gizli bir bilgi buldun!
Bu bölümde gizli bir bilgi var. Keşfetmek ister misin?

