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
  • .name returns 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 TypeSwift TypeNotes
IntInt32Kotlin Int is 32-bit
LongInt64Use .int64Value
StringStringDirect
BooleanBoolDirect
List<T>[T]Arrays
Flow<T>Async streamUse FlowUtils or KMP-NativeCoroutines
enum classClass with static propertiesCompare 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.int64Value

Error 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 localizedDescription for 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.kt can 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 .task modifiers 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 pressed

Why 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 .floatValue to extract the underlying Float

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:

  1. Must resume continuation exactly once (success OR failure)
  2. Set continuation to nil after resuming
  3. Use @MainActor for 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 #available check)
  • Intentionally want split view behavior on iPad


Tags: ios kmp patterns swift-kotlin-interop Status: Active reference Last Updated: 2025-12-02