iOS Navigation Patterns
This document describes the SwiftUI navigation patterns used in the iOS app, covering hierarchical navigation, modal presentation, deep links, and testing strategies.
Core Navigation Architecture
NavigationStack Container
All tab views use NavigationStack as the root container:
struct RoundsTabView: View {
@State private var showingCreateRound = false
var body: some View {
NavigationStack {
RoundListView(
repository: repo,
onCreateRoundTapped: { showingCreateRound = true }
)
.sheet(isPresented: $showingCreateRound) {
RoundCreationView(onRoundCreated: { _ in
refreshCounter += 1
})
}
}
}
}NavigationLink for Hierarchical Navigation
Use NavigationLink with inline destination for list-to-detail navigation:
NavigationLink {
RoundDetailView(
roundId: round.id,
repository: repository,
activeScoringRepository: activeScoringRepository
)
} label: {
RoundCardView(round: round)
}
.buttonStyle(PlainButtonStyle()) // Prevent NavigationLink stylingHub Pattern for Feature Organization
Hub views group related features with navigation options:
struct OnlineHubView: View {
var body: some View {
NavigationStack {
List {
NavigationLink(destination: TournamentListView()) {
OnlineHubRow(title: "Tournaments", ...)
}
NavigationLink(destination: LeaderboardBrowserView()) {
OnlineHubRow(title: "Global Leaderboard", ...)
}
}
.listStyle(.insetGrouped)
.navigationTitle("Online")
}
}
}Dependency Injection
DependencyContainer Pattern
Centralized dependency injection provides services to views:
struct RoundsTabView: View {
private var roundRepository: (any RoundRepositoryProtocol)? {
DependencyContainer.shared.roundRepositoryBridge
}
var body: some View {
if let repo = roundRepository {
RoundListView(repository: repo)
} else {
// Test environment fallback
UnavailableView()
}
}
}Protocol-Based Injection
All dependencies use protocols for testability:
struct RoundDetailView: View {
let roundId: Int32
let repository: any RoundRepositoryProtocol
let activeScoringRepository: any ActiveScoringRepositoryProtocol
}Optional Dependencies for Test Environments
Handle nil dependencies gracefully when KMP database isn’t available:
@ViewBuilder
private var myPublishedRoundsView: some View {
if let repo = roundPublishingRepository {
MyPublishedRoundsView(repository: repo)
} else {
VStack {
Image(systemName: "exclamationmark.triangle")
Text("Not Available")
}
}
}Modal Presentation
Sheet for Creation/Edit Forms
Use .sheet() for modal presentation of forms:
@State private var showingCreateRound = false
Button { showingCreateRound = true } label: {
Image(systemName: "plus")
}
.sheet(isPresented: $showingCreateRound) {
RoundCreationView(onRoundCreated: { newRound in
refreshCounter += 1 // Trigger list refresh
})
}Callback Pattern for Modal Results
Pass callbacks for modal completion handling:
RoundCreationView(onRoundCreated: { createdRound in
// Handle creation result
})
VerifyScoreSheet(
request: request,
onDismiss: { showVerifySheet = false },
onVerify: { imageData, notes in
await submitVerification(imageData: imageData, notes: notes)
}
)State Management
ViewModel Pattern
Use @StateObject with dedicated ViewModels:
struct RoundListView: View {
@StateObject private var viewModel: RoundListViewModel
init(repository: any RoundRepositoryProtocol) {
_viewModel = StateObject(wrappedValue:
RoundListViewModel(repository: repository)
)
}
}Loading/Error/Content States
Consistent state pattern for async content:
@ViewBuilder
private var contentView: some View {
if viewModel.isLoading {
loadingView
} else if let errorMessage = viewModel.errorMessage {
errorView(message: errorMessage)
} else if viewModel.filteredRounds.isEmpty {
emptyStateView
} else {
roundsListView
}
}Refresh Patterns
Counter-based refresh for external triggers:
struct RoundsTabView: View {
@State private var refreshCounter: Int = 0
RoundListView(refreshCounter: refreshCounter)
.sheet(isPresented: $showingCreateRound) {
RoundCreationView(onRoundCreated: { _ in
refreshCounter += 1 // Trigger refresh
})
}
}
struct RoundListView: View {
var refreshCounter: Int
var body: some View {
// ...
.onChange(of: refreshCounter) { _, _ in
viewModel.loadRoundsSync()
}
}
}Deep Link Handling
Token-Based Deep Links
Handle verification deep links with validation:
struct VerificationDeepLinkView: View {
let token: String
let verificationService: any VerificationServiceProtocol
let onDismiss: () -> Void
var body: some View {
NavigationStack {
Group {
if isLoading {
loadingView
} else if let error = error {
errorView(error)
} else if verificationRequest != nil {
verifyContentView
}
}
}
.task {
await loadVerificationRequest()
}
}
}Deep Link Validation Flow
- Extract token from URL
- Look up request by token
- Validate user is signed in
- Validate user is not the requester
- Present verification flow
private func loadVerificationRequest() async {
guard let user = currentUser else {
error = "Please sign in to verify scores"
return
}
let request = try await verificationService.getRequestForVerification(token: token)
if request.requesterId == user.uid {
error = "You cannot verify your own score"
} else if request.status.name != VerificationRequestStatus.pending.name {
error = "This verification link is no longer valid"
} else {
verificationRequest = request
}
}Navigation Testing
Mock Repository Pattern
Create unified mock repositories for navigation tests:
class MockRoundNavigationRepository {
var roundsToReturn: [Round] = []
var roundDetailsToReturn: RoundWithDetails?
var shouldFail = false
var shouldFailOnUpdate = false
func getAllRounds() async throws -> [Round] {
if shouldFail { throw MockError() }
return roundsToReturn
}
}
extension MockRoundNavigationRepository: RoundRepositoryProtocol {}
extension MockRoundNavigationRepository: RoundDetailRepositoryProtocol {}Navigation State Tests
Test that navigation data is available:
func testRoundList_providesRoundData_forNavigation() async {
let testRounds = createTestRounds()
mockRepository.roundsToReturn = testRounds
let viewModel = RoundListViewModel(repository: mockRepository)
await viewModel.loadRounds()
let selectedRound = viewModel.filteredRounds.first { $0.id == 1 }
XCTAssertNotNil(selectedRound, "Round should be available from list")
}Action Button State Tests
Test context-appropriate actions:
func testRoundDetail_startButton_availableForPlannedRound() async {
let plannedRound = createTestRound(status: .planned)
mockRepository.roundDetailsToReturn = createTestRoundWithDetails(round: plannedRound)
let viewModel = RoundDetailViewModel(repository: mockRepository)
await viewModel.loadRound(roundId: 1)
XCTAssertTrue(viewModel.canStartRound, "Start button should be available")
XCTAssertFalse(viewModel.canResumeRound, "Resume should NOT be available")
}Component Patterns
Reusable Row Components
Create consistent row components for lists:
struct OnlineHubRow: View {
let title: String
let description: String
let systemImage: String
let color: Color
var body: some View {
HStack(spacing: 16) {
Image(systemName: systemImage)
.font(.title)
.foregroundColor(color)
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
Text(description).font(.subheadline).foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
}
}Status Badge Components
Create status-aware badge components:
struct RoundStatusBadge: View {
let status: RoundStatus
var body: some View {
Text(statusText)
.font(.caption)
.fontWeight(.semibold)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(statusColor.opacity(0.2))
.foregroundColor(statusColor)
.cornerRadius(6)
}
private var statusColor: Color {
switch status {
case .planned: return .blue
case .inProgress: return .green
case .completed: return .gray
case .paused: return .orange
default: return .gray
}
}
}Best Practices
- Use NavigationStack at tab level: Each tab owns its navigation stack
- Inject dependencies via protocols: Enables testing and preview support
- Handle nil dependencies gracefully: Support test environments
- Use callbacks for modal results: Clean communication pattern
- Separate ViewModel from View: @StateObject for state management
- Test navigation state thoroughly: Verify data availability and action states
- Create reusable components: Consistent UI patterns across views
- Use .task for async loading: Clean async/await integration
Related Documentation
- Swift Interop Patterns - Using KMP types in Swift
- Firestore Rules Testing - Testing security rules