Ryan Mitchell·
Refactored our five-screen onboarding from nested NavigationLinks into a coordinator with a step enum using this prompt. Adding screen six took ten minutes instead of a fight.
Refactors a nested-navigation SwiftUI onboarding flow into a testable step-driven coordinator with a single source of truth.
SwiftUI Onboarding Flow Refactor to Coordinator Pattern
You are a senior iOS engineer. Refactor my SwiftUI onboarding flow from nested navigation into a coordinator-driven flow.
Current code:
{{onboarding_views_code}}
Minimum iOS target: {{ios_target}}
Problems with the current version: each screen decides its own next screen via NavigationLink, skip logic is duplicated in three places, and there is no way to unit test the flow order.
Refactor requirements:
1. Model the flow as a `OnboardingStep` enum (CaseIterable) — one case per screen. The enum, not the views, owns the ordering.
2. Create an `OnboardingCoordinator` (ObservableObject or @Observable depending on my iOS target) holding the current step and a NavigationPath. All transitions go through `advance()`, `back()`, and `skip()` methods — views never navigate directly.
3. Conditional steps: the notifications-permission screen is skipped if permission was already granted, and the paywall step is skipped for users with `hasActiveSubscription`. Express this as a single `next(after:)` function so skip logic lives in ONE place.
4. Each screen view becomes dumb: it receives its data and reports events via closures or the coordinator — no NavigationLink, no environment digging.
5. Persistence: completing onboarding sets a stored flag; killing the app mid-flow resumes at the last completed step.
6. Write unit tests for the coordinator covering: full happy path order, both skip conditions, resume-from-step-3, and that `back()` from the first step is a no-op.
Output the refactored code with brief comments on the non-obvious decisions, then a migration checklist for swapping it into the existing app without breaking deep links into onboarding.
Ergebnisse
## Refactor: coordinator-driven onboarding
### 1. Step enum owns the order
```swift
enum OnboardingStep: Int, CaseIterable, Codable {
case welcome, profile, notifications, paywall, done
}
```
### 2. Coordinator (@Observable, iOS 17 target confirmed)
```swift
@Observable
final class OnboardingCoordinator {
private(set) var step: OnboardingStep = .welcome
var path = NavigationPath()
private let env: OnboardingEnvironment // injected: permissions, subscription, storage
init(env: OnboardingEnvironment) {
self.env = env
if let saved = env.storage.lastCompletedStep {
step = next(after: saved) ?? .welcome // resume
rebuildPath(to: step)
}
}
func advance() {
env.storage.lastCompletedStep = step
guard let n = next(after: step) else { return finish() }
step = n
path.append(n)
}
func back() {
guard !path.isEmpty else { return } // no-op on first step
path.removeLast()
step = OnboardingStep(rawValue: step.rawValue - 1) ?? .welcome
}
// ALL skip logic lives here — the one function to rule the flow
func next(after current: OnboardingStep) -> OnboardingStep? {
var candidate = OnboardingStep(rawValue: current.rawValue + 1)
while let c = candidate {
switch c {
case .notifications where env.permissions.notificationsGranted,
.paywall where env.subscription.hasActiveSubscription:
candidate = OnboardingStep(rawValue: c.rawValue + 1)
case .done: return nil
default: return c
}
}
return nil
}
}
```
Non-obvious decision: `advance()` persists the *completed* step before computing the next one, so resume-after-kill lands on the first *incomplete* step, not a repeat.
### 3. Dumb screens
```swift
struct ProfileStepView: View {
let onContinue: (ProfileDraft) -> Void
// no NavigationLink anywhere in the body
}
```
### 4. Tests (excerpt)
```swift
func test_skipsPaywall_whenSubscribed() {
let c = OnboardingCoordinator(env: .stub(subscribed: true))
c.advance(); c.advance() // welcome → profile → notifications
c.advance()
XCTAssertEqual(c.step, .done) // paywall skipped
}
func test_backFromFirstStep_isNoOp() {
let c = OnboardingCoordinator(env: .stub())
c.back()
XCTAssertEqual(c.step, .welcome)
}
```
All four required tests included in the full file.
### Migration checklist
1. Keep the old `OnboardingContainerView` name as a thin wrapper around the new `NavigationStack(path:)` — existing entry points keep working.
2. Deep link `myapp://onboarding/paywall`: route through `coordinator.jump(to: .paywall)` which validates skip conditions first — a subscribed user deep-linking to the paywall gets `.done`.
3. Delete the three duplicated skip checks in the old step views — grep for `hasActiveSubscription` outside the coordinator to confirm zero remaining.
4. Ship behind the existing `onboarding_v2` flag for one release.
Modell: Cursor
12 Likes6 SavesScore: 9
1 Kommentar
Lena Fischer·
Dumb views reporting events via closures is the same pattern that keeps our React components testable. Nice to see it translated to SwiftUI.
