Skip to content

Migrate UIKit navigation to SwiftUI NavigationStack#3

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

Migrate UIKit navigation to SwiftUI NavigationStack#3
g-enius wants to merge 29 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 #4 — Replace Combine with AsyncSequence and @Observable builds on this branch.

Key Changes

Navigation

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

App Lifecycle

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

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+ (async-sequence)
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 async-sequence branch for the complete migration (PR #4).

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

🤖 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 27 commits March 2, 2026 11:07
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>
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 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>
Copy link
Owner Author

@g-enius g-enius left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed again. All new changes LGTM!

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