Skip to content

Migrate UIKit navigation to SwiftUI NavigationStack#3

Open
g-enius wants to merge 32 commits intomainfrom
feature/navigation-stack
Open

Migrate UIKit navigation to SwiftUI NavigationStack#3
g-enius wants to merge 32 commits intomainfrom
feature/navigation-stack

Conversation

@g-enius
Copy link
Owner

@g-enius g-enius commented Feb 26, 2026

Summary

Replaces the UIKit navigation layer (UINavigationController + Coordinator hierarchy) with pure SwiftUI NavigationStack, while keeping Combine for reactive state.

  • 17 files deleted (coordinators, view controllers, UIKit extensions)
  • 6 files added (AppCoordinator, AppRootView, MainTabView + SwiftUI equivalents)
  • Net reduction: ~880 lines
  • Minimum deployment target raised from iOS 15 → iOS 16

Next step: PR #6 — Replace Combine with @Observable and AsyncStream builds on this branch.

Key Changes

Navigation

  • UINavigationControllerNavigationStack + NavigationPath
  • UITabBarController → SwiftUI TabView
  • Multiple coordinator classes → single AppCoordinator: ObservableObject, ServiceLocatorProvider
  • pushViewController(_:animated:)path.append(item)
  • present(_:animated:).sheet(isPresented:)

App Lifecycle

  • AppDelegate + SceneDelegate → SwiftUI @main App
  • Deep links: scene(_:openURLContexts:).onOpenURL { }

Dependency Injection

  • ServiceLocator.shared singleton → instance-based ServiceLocator threaded through coordinators, sessions, and ViewModels
  • ServiceLocatorProvider protocol + @Service property wrapper with static subscript(_enclosingInstance:) for declarative service resolution
  • Session teardown no longer calls serviceLocator.reset() — live views may still resolve services via @Service during SwiftUI teardown; the next session's activate() overwrites with fresh instances

ViewModel → Coordinator Communication

  • Navigation closures (var onShowDetail: ((FeaturedItem) -> Void)?) — same as main branch

What Stays the Same

  • Combine (@Published + .sink) for reactive state
  • MVVM architecture, session-scoped DI, all service protocols

Why NavigationStack + NavigationPath over NavigationLink

This branch uses programmatic navigation exclusively — NavigationStack with NavigationPath managed by the coordinator. NavigationLink is deliberately avoided:

  • Navigation belongs in the coordinator, not the view. NavigationLink couples navigation decisions to SwiftUI views, making it hard to trigger navigation from ViewModels, deep links, or programmatic flows.
  • NavigationLink(isActive:) and NavigationLink(tag:selection:) are deprecated since iOS 16. Apple replaced them with navigationDestination(for:) + NavigationPath, which is exactly what this branch uses.
  • NavigationPath is type-erased and composable. The coordinator can path.append(any Hashable) without Views knowing the destination type. NavigationLink requires the destination View at the call site.
  • Testing is simpler. Navigation is testable via closures on ViewModels (onShowDetail, onShowProfile) — no need to tap UI elements.
// How navigation works in this branch:
// 1. View calls ViewModel closure
viewModel.didTapFeaturedItem(item)

// 2. ViewModel fires navigation closure (set by coordinator)
onShowDetail?(item)

// 3. Coordinator appends to NavigationPath
coordinator.homePath.append(item)

// 4. NavigationStack picks up the change via .navigationDestination(for:)

If you support iOS 17+: how this code evolves

If your deployment target is iOS 17+, you can remove Combine entirely. Here's how each pattern changes:

ViewModelsObservableObject + @Published@Observable:

// iOS 16 (this branch)                    // iOS 17+ (observation)
class HomeViewModel: ObservableObject {     @Observable class HomeViewModel {
    @Published var items = []                   var items = []
    @Published var isLoading = false             var isLoading = false
}                                           }

Views@ObservedObject / @StateObject@Bindable / @State:

// iOS 16 (this branch)                    // iOS 17+
@ObservedObject var viewModel: HomeVM       @Bindable var viewModel: HomeVM
@StateObject var viewModel = HomeVM()       @State var viewModel = HomeVM()

Service eventsAnyPublisherAsyncStream:

// iOS 16 (this branch)                    // iOS 17+
favoritesService.favoritesDidChange         let stream = favoritesService.favoritesChanges
    .sink { self.favoriteIds = $0 }         Task { for await ids in stream {
    .store(in: &cancellables)                   self.favoriteIds = ids
                                            }}

See the observation branch for the complete migration (PR #6).

Test plan

  • All 73 unit tests pass
  • Build succeeds on iOS 16+ simulator
  • Home → Detail → back navigation
  • Profile modal present/dismiss
  • Items → Detail → back navigation
  • Deep links work via .onOpenURL
  • Login → main flow transition
  • Logout works without crash (services remain registered during teardown)

🤖 Generated with Claude Code

g-enius

This comment was marked as resolved.

@g-enius g-enius force-pushed the feature/navigation-stack branch from ca1401e to 5ecdab7 Compare February 28, 2026 00:36
g-enius

This comment was marked as resolved.

@claude

This comment was marked as resolved.

g-enius and others added 28 commits March 22, 2026 12:45
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The DispatchQueue.main change was unnecessary and broke CI tests.
RunLoop.main works correctly with Combine debounce in test contexts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ster

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Both feature branches target iOS 16+ where Task.sleep(for:) is available.
.milliseconds/.seconds is clearer than raw nanosecond counts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…table import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This branch is pure SwiftUI, no UIKit controllers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
DetailTabContent → DetailContent, ProfileTabContent → ProfileContent,
LoginTabContent → LoginContent. Moved LoginContent to its own file
since it has nothing to do with tabs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude claude bot force-pushed the feature/navigation-stack branch from 621bee6 to 5c3a497 Compare March 22, 2026 12:47
…wrappers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant