KMP iOS Integration Patterns
Last Updated: 2025-11-29 Status: Active reference for iOS development with KMP shared code
Overview
This guide documents patterns for integrating Kotlin Multiplatform (KMP) shared code into iOS SwiftUI applications. These patterns were developed during iOS Phase 2-4 implementation.
Flow Conversion Patterns
FlowUtils.first() - Flow to Single Value
KMP DAOs return Flow<List<T>> for reactive data access. When you only need a single fetch (not reactive updates), use FlowUtils.first():
// KMP provides a helper in shared/common
// FlowUtils.shared.first(flow:) -> Any
func getAllRisers() async throws -> [Riser] {
let flow = riserDao.getAllRisers() // Returns Flow<List<Riser>>
return try await FlowUtils.shared.first(flow: flow) as! [Riser]
}Why Force Cast is Safe:
- KMP Flow<List
> is guaranteed to emit [Riser] - The type is enforced at compile time in Kotlin
- Swift just needs help with type inference
When to Use:
- One-time data fetches (load on appear)
- Equipment lists, round lists, tournament lists
- Any non-reactive data loading
When NOT to Use:
- Real-time updates (use KMP-NativeCoroutines Flow collection instead)
- Live scoring where data changes frequently
Enum Comparison Pattern
KMP Enums via .name Property
KMP enums bridge to Swift as classes, not native Swift enums. This affects comparison:
// ❌ WRONG - Direct comparison fails
if equipmentType == .riser { ... } // Compiler error or unexpected behavior
// ✅ CORRECT - Compare via .name property
switch equipmentType.name {
case EquipmentType.riser.name:
return "Risers"
case EquipmentType.arrow.name:
return "Arrows"
case EquipmentType.limbs.name:
return "Limbs"
default:
return "Equipment"
}Why This Happens:
- KMP enum cases become static properties on a class
- Swift sees them as object references, not enum cases
.namereturns the enum case name as a String
Alternative - Extension Method:
extension EquipmentType {
var displayName: String {
switch self.name {
case EquipmentType.riser.name: return "Risers"
case EquipmentType.arrow.name: return "Arrows"
// ...
default: return "Equipment"
}
}
}Concurrent Fetching Pattern
async let for Parallel Calls
When loading multiple data sources, use async let for concurrent execution:
func loadEquipmentCounts() async {
do {
// All calls start immediately (concurrent)
async let risers = repository.getAllRisers()
async let arrows = repository.getAllArrows()
async let limbs = repository.getAllLimbs()
// ... more calls
// Await all results (parallel wait)
let (risersResult, arrowsResult, limbsResult, ...) = try await (
risers, arrows, limbs, ...
)
// Process results
equipmentCounts = [
EquipmentType.riser.name: risersResult.count,
// ...
]
} catch {
errorMessage = "Failed: \(error.localizedDescription)"
}
}Performance:
- 10 sequential calls: 10x single call time
- 10 concurrent calls: ~1x single call time (assuming no contention)
When to Use:
- Loading counts for multiple equipment types
- Fetching data for dashboard summaries
- Any multiple independent data loads
Type-Erased Protocol Pattern
Generic Display for Multiple KMP Types
When displaying different KMP types in a single view, use a type-erased protocol:
// Define common interface
protocol EquipmentItem {
var equipmentId: Int64 { get }
var equipmentBrand: String { get }
var equipmentModel: String { get }
var equipmentType: EquipmentType { get }
}
// Create wrappers for each KMP type
struct RiserItem: EquipmentItem {
let riser: Riser
var equipmentId: Int64 { riser.id }
var equipmentBrand: String { riser.brand }
var equipmentModel: String { riser.model }
var equipmentType: EquipmentType { .riser }
}
// ViewModel stores type-erased items
@Published var equipment: [any EquipmentItem] = []
// Load and wrap
func loadEquipment(type: EquipmentType) async {
let items: [any EquipmentItem] = switch type.name {
case EquipmentType.riser.name:
try await repository.getAllRisers().map { RiserItem(riser: $0) }
// ...
}
equipment = items
}Benefits:
- Single view handles all types
- Type-safe wrappers preserve full data
- Easy to add new types
Repository Bridge Pattern
Adapting KMP DAOs to Swift Protocols
Create a bridge class that adapts KMP DAOs to Swift-friendly protocols:
// Swift protocol for dependency injection and testing
protocol EquipmentRepositoryProtocol {
func getAllRisers() async throws -> [Riser]
func deleteRiser(id: Int64) async throws
// ... more methods
}
// Bridge adapts KMP DAOs to protocol
@MainActor
class EquipmentRepositoryBridge: EquipmentRepositoryProtocol {
private let riserDao: RiserDao
private let arrowDao: ArrowDao
// ... 10 DAOs
init(riserDao: RiserDao, arrowDao: ArrowDao, ...) {
self.riserDao = riserDao
// ...
}
func getAllRisers() async throws -> [Riser] {
let flow = riserDao.getAllRisers()
return try await FlowUtils.shared.first(flow: flow) as! [Riser]
}
func deleteRiser(id: Int64) async throws {
try await riserDao.deleteRiserById(id: id)
}
}Benefits:
- Centralized Flow → async conversion
- Easy to mock for testing
- Decouples ViewModels from KMP details
Type Conversion Reference
Common Swift ↔ Kotlin Type Conversions
| Kotlin Type | Swift Type | Notes |
|---|---|---|
Int | Int32 | Kotlin Int is 32-bit |
Long | Int64 | Use .int64Value |
String | String | Direct |
Boolean | Bool | Direct |
List<T> | [T] | Arrays |
Flow<T> | Async stream | Use FlowUtils or KMP-NativeCoroutines |
enum class | Class with static properties | Compare via .name |
KotlinLong Conversion
// KMP entity has Long id
let entity: SomeKmpEntity
// ❌ WRONG
let id = entity.id.longValue // No such property
// ✅ CORRECT
let id = entity.id.int64ValueError Handling Pattern
Async/Await Error Propagation
func loadEquipment(type: EquipmentType) async {
isLoading = true
errorMessage = nil
do {
let items = try await repository.getAllItems(for: type)
equipment = items
isLoading = false
} catch {
errorMessage = "Failed to load: \(error.localizedDescription)"
isLoading = false
}
}Key Points:
- Set loading state before async call
- Clear previous error before new attempt
- Always reset loading state (success or failure)
- Use
localizedDescriptionfor user-facing errors
Preview Support Pattern
Mock Repositories for SwiftUI Previews
// In view file, private to that file
private class PreviewEquipmentRepository: EquipmentRepositoryProtocol {
func getAllRisers() async throws -> [Riser] { [] }
func getAllArrows() async throws -> [Arrow] { [] }
// ... return empty arrays for all
}
// In view init
init(viewModel: EquipmentListViewModel? = nil) {
if let vm = viewModel {
_viewModel = StateObject(wrappedValue: vm)
} else if let bridge = DependencyContainer.shared.equipmentRepositoryBridge {
_viewModel = StateObject(wrappedValue: EquipmentListViewModel(repository: bridge))
} else {
// Fallback for previews
_viewModel = StateObject(wrappedValue: EquipmentListViewModel(repository: PreviewEquipmentRepository()))
}
}
#Preview {
EquipmentHubView() // Works without database setup
}@MainActor for KMP Suspend Calls
Task Closures Calling KMP Functions
Swift Task closures run on arbitrary threads by default. KMP suspend functions may have thread requirements and can crash if called from the wrong context.
// ❌ WRONG - May crash on KMP suspend call
Task {
await handleTargetTap(location: location) // Calls KMP suspend function
}
// ✅ CORRECT - Ensures main thread dispatch
Task { @MainActor in
await handleTargetTap(location: location)
}Why This Matters:
- KMP-NativeCoroutines bridges Kotlin suspend functions to Swift async
- Some KMP code expects main thread execution
ObjCExportCoroutines.ktcan crash without proper thread context
Rule: Any Task closure that calls KMP suspend functions should have @MainActor annotation.
When to Use:
- UI event handlers that call KMP code (button taps, gestures)
- SwiftUI
.taskmodifiers calling KMP suspend functions - Any async context where KMP code is invoked
UPSERT Pattern for Database Operations
Handling Existing Records on Resume
When resuming data entry (e.g., continuing an incomplete round), records may already exist. Simple INSERT will fail with UNIQUE constraint violations.
// ❌ WRONG - Crashes on resume with existing data
try await roundDao.insertArrowScore(arrowScore: newArrow)
// ✅ CORRECT - Check for existing record first
let existingArrow = existingArrows.first { $0.arrowNumber == arrowNumber }
if let existing = existingArrow {
// Arrow exists - UPDATE with existing ID
let updatedArrow = ArrowScore(
id: existing.id, // Preserve existing ID
endId: endId,
arrowNumber: arrowNumber,
scoreValue: score,
isX: isX
)
try await roundDao.updateArrowScore(arrowScore: updatedArrow)
} else {
// New arrow - INSERT
let newArrow = ArrowScore(
id: 0, // Auto-generated
endId: endId,
arrowNumber: arrowNumber,
scoreValue: score,
isX: isX
)
try await roundDao.insertArrowScore(arrowScore: newArrow)
}Why This Pattern:
- Users may exit and resume data entry
- Partial data exists in database
- INSERT violates UNIQUE constraints
- UPDATE preserves existing record ID
When to Use:
- Arrow score entry (resume incomplete end)
- End score completion (resume incomplete round)
- Any data entry that can be interrupted and resumed
Explicit Parameter Pattern (Android Parity)
Don’t Auto-Detect User Intent
Never infer parameters that the user should explicitly provide. Auto-detection loses information.
// ❌ WRONG - Auto-detection loses user intent
func enterScore(_ score: Int32) async {
await enterScore(score, isX: score == 10) // ALL 10s become X!
}
// ✅ CORRECT - Caller must explicitly specify
func enterScore(_ score: Int32) async {
await enterScore(score, isX: false) // Default false
}
// Caller explicitly passes true for X button
viewModel.enterScore(10, isX: true) // X button pressed
viewModel.enterScore(10, isX: false) // 10 button pressedWhy This Matters:
- X-ring (center dot) and 10-ring (inner gold) are different
- User intent is lost with auto-detection
- Android implementation uses explicit flags
- Data integrity requires preserving the distinction
Android Reference (ScoreInputSection.kt):
onXClick = { viewModel.enterScore(10, isX = true) }
onScoreClick = { score -> viewModel.enterScore(score, isX = false) }When to Use:
- Any scenario where default behavior might “helpfully” change user input
- Matching Android behavior for cross-platform consistency
- Preserving data distinctions that matter to users
Resume State Pattern
Loading Existing Data on View Appear
When returning to a partially completed task, load existing state into view.
func loadExistingState() async {
// Find incomplete work
let incompleteEnd = roundDetails.ends.first { !$0.endScore.isCompleted }
guard let incompleteEnd = incompleteEnd,
!incompleteEnd.arrows.isEmpty else {
// No existing data - start fresh
return
}
// Sort to preserve order
let sortedArrows = incompleteEnd.arrows.sorted {
$0.arrowNumber < $1.arrowNumber
}
// Load into view state
currentEndScores = sortedArrows.map { $0.scoreValue }
currentEndXRings = sortedArrows.map { $0.isX }
currentArrowNumber = Int32(currentEndScores.count + 1)
}Key Points:
- Check for existing data before initializing to defaults
- Sort data to preserve original order
- Update all related state atomically
- Set “next” position based on existing count
When to Use:
- Active scoring (resume incomplete end)
- Form editing (load existing values)
- Any stateful UI that can be resumed
KotlinFloat Coordinate Conversion
Swift Double to KMP Float
When storing coordinates in the KMP database, convert Swift Double to KotlinFloat:
// ❌ WRONG - KMP expects KotlinFloat, not Swift Double
try await dao.saveCoordinate(x: 0.5, y: 0.3)
// ✅ CORRECT - Convert to KotlinFloat
let kmpXCoord: KotlinFloat? = xCoordinate.map { KotlinFloat(value: Float($0)) }
let kmpYCoord: KotlinFloat? = yCoordinate.map { KotlinFloat(value: Float($0)) }Reading Back:
// Convert KotlinFloat to Swift Double on read
if let x = arrow.xCoordinate?.floatValue,
let y = arrow.yCoordinate?.floatValue {
let swiftX = Double(x)
let swiftY = Double(y)
}Why KotlinFloat:
- KMP database stores
Float?for optional coordinates - Bridges to Swift as
KotlinFloat? - Use
.floatValueto extract the underlyingFloat
Shared Helper Pattern (ScoringOperations)
Extracting Common Logic
When multiple repository bridges share the same logic, extract to a shared helper:
/// Shared operations used by multiple repository bridges
enum ScoringOperations {
static func saveArrowScore(
roundId: Int32,
endNumber: Int32,
arrowNumber: Int32,
score: Int32,
isX: Bool,
xCoordinate: Double?,
yCoordinate: Double?,
roundDao: RoundDao,
caller: String = "ScoringOperations"
) async throws {
// Get or create end score
let endScore = try await getOrCreateEndScore(
roundId: roundId,
endNumber: endNumber,
roundDao: roundDao
)
// Check for existing arrow (UPSERT pattern)
let existingArrows = try await roundDao.getArrowScoresForEnd(endId: Int64(endScore.id))
let existingArrow = existingArrows.first { $0.arrowNumber == arrowNumber }
if let existing = existingArrow {
// UPDATE existing
try await roundDao.updateArrowScore(arrowScore: updatedArrow)
} else {
// INSERT new
try await roundDao.insertArrowScore(arrowScore: newArrow)
}
}
}Benefits:
- Single source of truth for complex operations
- Reduces duplication across bridges
- Easier to maintain and test
- Bridges can evolve independently if needed
When to Use:
- Scoring operations (arrow save, end completion)
- Complex multi-step database operations
- Logic shared between RoundRepositoryBridge and ActiveScoringRepositoryBridge
Protocol Extraction Pattern
Interface Segregation for Repository Protocols
Extract protocols to a dedicated file for cleaner architecture:
// DI/Protocols.swift
/// Protocol for ActiveScoring repository operations
protocol ActiveScoringRepositoryProtocol {
func getRoundWithDetails(roundId: Int32) async throws -> RoundWithDetails?
func saveArrowScore(
roundId: Int32,
endNumber: Int32,
arrowNumber: Int32,
score: Int32,
isX: Bool,
xCoordinate: Double?,
yCoordinate: Double?
) async throws
func completeEnd(roundId: Int32, endNumber: Int32) async throws
func updateRoundStatus(roundId: Int32, status: RoundStatus) async throws
}Benefits:
- ViewModels depend on protocols, not concrete bridges
- Enables dependency injection for testing
- Follows Interface Segregation Principle (ISP)
- Protocols can be tailored to each consumer’s needs
File Organization:
DI/
├── Protocols.swift # All repository protocols
├── ScoringOperations.swift # Shared scoring helpers
├── ActiveScoringRepositoryBridge.swift
├── RoundRepositoryBridge.swift
└── EquipmentRepositoryBridge.swift
Single Source of Truth Pattern
Eliminating Parallel Arrays
Replace parallel arrays with a single struct array:
// ❌ WRONG - Parallel arrays can get out of sync
@Published var currentEndScores: [Int32] = []
@Published var currentEndXRings: [Bool] = []
@Published var currentEndCoordinates: [ShotCoordinate?] = []
// Adding requires updating all three
currentEndScores.append(score)
currentEndXRings.append(isX)
currentEndCoordinates.append(placement)
// Undo requires removing from all three
currentEndScores.removeLast()
currentEndXRings.removeLast()
currentEndCoordinates.removeLast()
// ✅ CORRECT - Single source of truth
struct ArrowEntry: Equatable {
let score: Int32
let isX: Bool
let placement: ShotCoordinate?
}
@Published var currentEndArrows: [ArrowEntry] = []
// Computed properties for backward compatibility
var currentEndScores: [Int32] { currentEndArrows.map { $0.score } }
var currentEndXRings: [Bool] { currentEndArrows.map { $0.isX } }
// Single operation for add/undo
currentEndArrows.append(ArrowEntry(score: score, isX: isX, placement: placement))
currentEndArrows.removeLast()Benefits:
- Cannot get out of sync
- Single point of modification
- Simpler undo logic
- Atomic state changes
When to Use:
- Multiple related values that change together
- Data that needs to maintain order
- State that requires undo functionality
Refresh Data Preservation Pattern
Preserve Previous Data on Refresh Failure
When implementing pull-to-refresh, preserve previous data if refresh fails:
func refresh() async {
// Capture current state before refresh attempt
let previousRounds = recentRounds
let previousTotalRounds = totalRounds
let previousTotalArrows = totalArrowsShot
let previousAverageScore = averageScore
let previousIsEmpty = isEmpty
isLoading = true
errorMessage = nil
do {
// Attempt to fetch fresh data
let rounds = try await repository.getRecentCompletedRounds(limit: 10)
recentRounds = rounds
totalRounds = rounds.count
// ... update other state
isLoading = false
} catch {
// RESTORE previous data on error
recentRounds = previousRounds
totalRounds = previousTotalRounds
totalArrowsShot = previousTotalArrows
averageScore = previousAverageScore
isEmpty = previousIsEmpty
errorMessage = "Failed to refresh: \(error.localizedDescription)"
isLoading = false
}
}Benefits:
- User doesn’t lose data on network failure
- Error message informs user of failure
- Previous state remains visible and usable
- Better UX than showing empty/error state
When to Use:
- Pull-to-refresh operations
- Background sync operations
- Any refresh where previous data is still valid
TDD ViewModel Pattern
Test-First Development for iOS ViewModels
Write tests before implementation using mock repositories:
// 1. Define mock repository with configurable responses
class MockAnalyticsRepository: AnalyticsRepositoryProtocol {
var roundsToReturn: [Round] = []
var statisticsToReturn: [Int32: RoundStatisticsData] = [:]
var shouldThrowError: Bool = false
var errorToThrow: Error = NSError(domain: "test", code: 1)
// Call tracking for verification
var getRecentRoundsCallCount = 0
var getStatisticsCallCount = 0
func getRecentCompletedRounds(limit: Int32) async throws -> [Round] {
getRecentRoundsCallCount += 1
if shouldThrowError { throw errorToThrow }
return roundsToReturn
}
}
// 2. Write failing test first
func testLoadAnalytics_withRounds_setsStatistics() async {
// Arrange
let mockRepo = MockAnalyticsRepository()
mockRepo.roundsToReturn = [testRound1, testRound2]
mockRepo.statisticsToReturn = [1: stats1, 2: stats2]
let viewModel = AnalyticsHubViewModel(repository: mockRepo)
// Act
await viewModel.loadAnalytics()
// Assert
XCTAssertEqual(viewModel.totalRounds, 2)
XCTAssertEqual(viewModel.totalArrowsShot, 24)
XCTAssertFalse(viewModel.isEmpty)
}
// 3. Implement to pass
@MainActor
class AnalyticsHubViewModel: ObservableObject {
func loadAnalytics() async {
// Implementation that passes the test
}
}Benefits:
- Tests define expected behavior first
- Mock repositories enable isolated testing
- Call tracking verifies interactions
- @MainActor ensures thread safety
When to Use:
- All ViewModel implementations
- Complex business logic
- Integration with KMP bridges
KMP AuthResult Swift Limitation
Problem
Swift doesn’t support KMP’s covariant sealed class generics. When a KMP function returns AuthResult<AuthUser>, Swift cannot properly handle the AuthResult.Failure case.
Solution
Throw Swift-native errors instead of returning AuthResult.Failure:
enum AuthBridgeError: LocalizedError {
case noUserSignedIn
case notImplemented(String)
case networkError(String)
case invalidCredentials(String)
var errorDescription: String? {
switch self {
case .noUserSignedIn:
return "No user is currently signed in"
case .notImplemented(let feature):
return "\(feature) is not yet implemented"
// ... other cases
}
}
}
// In bridge implementation
func signInWithGoogle() async throws -> AuthResult<AuthUser> {
do {
let tokens = try await googleSignInProvider.signIn()
let credential = GoogleAuthProvider.credential(
withIDToken: tokens.idToken,
accessToken: tokens.accessToken
)
let authResult = try await Auth.auth().signIn(with: credential)
return AuthResultSuccess(data: mapToAuthUser(authResult.user))
} catch {
// Throw Swift error instead of returning AuthResult.Failure
throw AuthBridgeError.networkError(error.localizedDescription)
}
}ViewModel Error Handling
func signInWithGoogle() async {
isLoading = true
errorMessage = nil
do {
_ = try await authRepository.signInWithGoogle()
// Success: auth state listener handles UI update
} catch {
errorMessage = "Google Sign-In failed: \(error.localizedDescription)"
}
isLoading = false
}Why This Pattern:
- Swift cannot destructure KMP sealed class variants
- Throwing errors provides clear error handling path
- ViewModel can catch and display user-friendly messages
- Matches Swift async/await conventions
@AppStorage + ObservableObject Pattern
Problem
Combining @AppStorage with ObservableObject doesn’t automatically trigger SwiftUI updates because @AppStorage is designed for views, not observable classes.
Solution
Use a computed property with explicit objectWillChange.send():
@MainActor
class ThemeManager: ObservableObject {
/// Persisted in UserDefaults via @AppStorage
@AppStorage("themeMode") private var themeModeRaw: String = ThemeMode.system.rawValue
/// Computed property with objectWillChange.send() for SwiftUI observation
var themeMode: ThemeMode {
get { ThemeMode(rawValue: themeModeRaw) ?? .system }
set {
objectWillChange.send() // Critical: notify observers BEFORE change
themeModeRaw = newValue.rawValue
}
}
func setTheme(_ mode: ThemeMode) {
themeMode = mode
}
}Usage in App Root
@main
struct ArcheryApprenticeApp: App {
@StateObject private var themeManager = ThemeManager()
var body: some Scene {
WindowGroup {
MainTabView()
.environmentObject(themeManager)
.preferredColorScheme(themeManager.preferredColorScheme)
}
}
}Key Points:
- Call
objectWillChange.send()BEFORE setting the value - Use computed property to wrap @AppStorage
- Pass as environmentObject for child views
- Apply at app root for global effect
Native Firebase Auth State Listener
Problem
KMP Flow doesn’t work well with Swift async/await for real-time auth state updates.
Solution
Use Firebase’s native auth state listener directly in Swift:
class FirebaseAuthRepositoryBridge: AuthRepository {
/// Native Swift auth state listener using Firebase SDK.
/// Use this instead of observeAuthState() for real-time updates.
func addAuthStateListener(_ callback: @escaping (User?) -> Void) -> AuthStateDidChangeListenerHandle {
return Auth.auth().addStateDidChangeListener { _, user in
callback(user)
}
}
func removeAuthStateListener(_ handle: AuthStateDidChangeListenerHandle) {
Auth.auth().removeStateDidChangeListener(handle)
}
}Usage in Views
struct SignInView: View {
@State private var currentUser: FirebaseAuth.User?
@State private var authListenerHandle: AuthStateDidChangeListenerHandle?
var body: some View {
// ... view content
}
.onAppear {
currentUser = Auth.auth().currentUser
authListenerHandle = Auth.auth().addStateDidChangeListener { _, user in
currentUser = user
}
}
.onDisappear {
if let handle = authListenerHandle {
Auth.auth().removeStateDidChangeListener(handle)
}
}
}Benefits:
- Real-time auth state updates
- No KMP coroutine issues
- Clean lifecycle management
- Works with SwiftUI view lifecycle
Continuation-Based Async Bridging
Problem
Apple’s AuthenticationServices uses delegate callbacks, but modern Swift code uses async/await.
Solution
Use withCheckedThrowingContinuation to bridge delegate → async/await:
@MainActor
class AppleSignInProviderImpl: NSObject {
private var continuation: CheckedContinuation<AppleSignInCredentials, Error>?
func signIn() async throws -> AppleSignInCredentials {
return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
authorizationController.performRequests()
}
}
}
// In delegate success callback
extension AppleSignInProviderImpl: ASAuthorizationControllerDelegate {
func authorizationController(controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization) {
// Extract credentials...
continuation?.resume(returning: credentials)
continuation = nil // Clean up - must resume exactly once
}
func authorizationController(controller: ASAuthorizationController,
didCompleteWithError error: Error) {
continuation?.resume(throwing: error)
continuation = nil
}
}Critical Rules:
- Must resume continuation exactly once (success OR failure)
- Set continuation to nil after resuming
- Use
@MainActorfor thread safety
iPad NavigationView vs NavigationStack
Problem
NavigationView defaults to split view on iPad, causing unexpected layout issues.
Solution
Use NavigationStack (iOS 16+) for consistent single-column navigation:
// ❌ WRONG - Creates split view on iPad
NavigationView {
List { ... }
}
// ✅ CORRECT - Single column on all devices
NavigationStack {
List { ... }
}Also Avoid:
- Nested navigation containers (NavigationView inside NavigationView)
- NavigationView in child views when parent already has one
When NavigationView is Acceptable:
- iOS 15 compatibility required (use
if #availablecheck) - Intentionally want split view behavior on iPad
Related Documentation
- Equipment Hub Guide - Phase 3a implementation
- Visual Scoring Guide - Phase 4 implementation
- Analytics Dashboard Guide - Phase 5a implementation
- Authentication Guide - Phase 5 auth implementation
- Apple Sign-In Guide - Phase 5d Apple Sign-In
- KMP Migration Architecture - Overall architecture
- iOS Testing Strategy - TDD patterns
- iOS Troubleshooting - Common issues and fixes
- Phase 4b Session - Visual scoring bug fixes
Tags: ios kmp patterns swift-kotlin-interop Status: Active reference Last Updated: 2025-12-02