Consuming Coroutines and Flow from Swift and SwiftUI

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

A shared ViewModel is only useful if the iOS app can observe it. Kotlin’s suspend functions and Flow are language and library features that Objective-C — the language Kotlin/Native uses to talk to Swift — has never heard of, so something has to translate them. That something is one of three things: the default export (completion handlers, and a Flow that is just an opaque interface), a bridging library (SKIE or KMP-NativeCoroutines), or Swift export, JetBrains' newer, Alpha direct mapping. This page shows what each of them produces in Swift, how a SwiftUI view should collect a shared StateFlow, and how cancellation and threading behave, so you can choose with your eyes open.

The baseline is the one used across this section: Kotlin 2.4.20, SKIE 0.10.15, KMP-NativeCoroutines 1.0.6 and Xcode 26.4. Swift export is Alpha; the Objective-C and C interop used by the default export is Beta. Every example is a slice of TrailMate Everywhere, the multiplatform version of the Android section’s TrailMate app (package com.example.trailmate). The Kotlin side of flows is covered on Coroutines, Flow and Concurrency in Shared Code and Flows; the shared ViewModel itself on Architecture, Shared ViewModels and State. Swift concurrency and SwiftUI state are not re-taught: see Swift Concurrency in Apps and State and Data Flow with Observation. How the Objective-C header is generated, and how SKIE and Swift export are enabled, is the subject of Objective-C Interop, Swift Export and SKIE.

The Problem: Kotlin Concurrency Has No Objective-C Equivalent

Consider the two shared APIs TrailMate’s iOS screen needs. One loads a trail once, and the other exposes the screen state over time.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/presentation/TrailListViewModel.kt
package com.example.trailmate.presentation

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.trailmate.domain.Trail
import com.example.trailmate.domain.TrailRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

sealed interface TrailListUiState {
    data object Loading : TrailListUiState
    data class Success(val trails: List<Trail>) : TrailListUiState
    data class Error(val message: String) : TrailListUiState
}

class TrailListViewModel(
    private val repository: TrailRepository,
) : ViewModel() {

    private val _uiState = MutableStateFlow<TrailListUiState>(TrailListUiState.Loading)
    val uiState: StateFlow<TrailListUiState> = _uiState.asStateFlow()

    init { refresh() }

    fun refresh() {
        viewModelScope.launch {
            _uiState.value = try {
                TrailListUiState.Success(repository.loadTrails())
            } catch (e: Exception) {
                TrailListUiState.Error(e.message ?: "Unknown error")
            }
        }
    }

    // A suspend function exposed directly, to show how suspend looks from Swift.
    suspend fun toggleFavorite(trailId: String): Boolean = repository.toggleFavorite(trailId)
}

A suspend function is compiled to a function with an extra continuation parameter, and Flow.collect is itself suspend. Objective-C has no continuations, no structured concurrency and only limited generics, so Kotlin/Native invents a representation; its quality separates the approaches below.

The Default Export: Completion Handlers and an Opaque Flow

With no library at all, the Objective-C export turns a suspend function into a method with a trailing completion handler, and Swift’s importer additionally offers an async form of the same method. toggleFavorite is visible in Swift as follows.

// iosApp/iosApp/Features/TrailList/DefaultExportExamples.swift
import Shared // the umbrella framework produced by :sharedLogic

func toggleWithCompletionHandler(viewModel: TrailListViewModel, id: String) {
    // Completion-handler form: the result is boxed as KotlinBoolean, errors arrive as NSError.
    viewModel.toggleFavorite(trailId: id) { isFavorite, error in
        if let error {
            print("Toggle failed: \(error)")
        } else {
            print("Is favorite: \(isFavorite?.boolValue ?? false)")
        }
    }
}

func toggleWithAsync(viewModel: TrailListViewModel, id: String) async {
    do {
        // The importer synthesizes an async overload from the completion-handler method.
        let isFavorite = try await viewModel.toggleFavorite(trailId: id)
        print("Is favorite: \(isFavorite.boolValue)")
    } catch {
        print("Toggle failed: \(error)")
    }
}

This works, and for a handful of one-shot calls it may be enough. Its limits are the reason libraries exist.

  • Errors only arrive when declared. A Kotlin exception that is not covered by @Throws does not become a Swift Error; it terminates the app. A suspend function needs @Throws(Exception::class) (or a narrower type) for the completion handler to receive an NSError. CancellationException is always transported as an error.

  • Cancellation is not something to rely on. The completion-handler form hands Swift no Job, so there is nothing to cancel. The synthesized async form does not give you the guarantee a Kotlin developer expects either: do not assume that cancelling the surrounding Swift Task stops the Kotlin coroutine and releases its resources. Treat the default export as fire-and-wait.

  • Results are boxed. Boolean becomes KotlinBoolean, Int becomes KotlinInt, and a List<Trail> becomes NSArray with generics only partly preserved.

  • A Flow is not a sequence. The default export gives Swift a Kotlin_coroutinesFlow — a Kotlin interface with a collect(collector:completionHandler:) method. You must implement a Kotlin FlowCollector class in Swift, and StateFlow<T> loses its type argument (the value property is an Any?).

The default FlowCollector route has no usable shape in production: you implement a Kotlin interface (Kotlinx_coroutines_coreFlowCollector, a name chosen by the header generator) in Swift, receive Any? values and have no cancellation, so the collection runs for as long as the ViewModel scope lives. See Kotlin docs — Interoperability with Swift/Objective-C.

Choosing a Bridge: What Each Option Is

There are three ways to improve on this, and they are not interchangeable.

  • KMP-NativeCoroutines (Rick Clephas, 1.0.6) is a KSP and Kotlin compiler plugin pair. Annotating a declaration with @NativeCoroutines generates extra, Swift-friendly properties and functions beside it. A small Swift package turns those into async calls, AsyncSequence, Combine publishers or RxSwift observables.

  • SKIE (Touchlab, 0.10.15) is a Kotlin/Native compiler plugin that changes the generated Obj-C header and adds Swift code to the framework, so suspend becomes a native async function and Flow becomes a native AsyncSequence with no annotations in the shared code.

  • Swift export is JetBrains' own Alpha replacement for the Objective-C header: it generates Swift directly and maps suspend and Flow to async and AsyncSequence natively.

Choose one: SKIE and KMP-NativeCoroutines overlap, and combining them produces duplicate APIs. Swift export is a different export pipeline and not yet a drop-in replacement for SKIE.

KMP-NativeCoroutines

KMP-NativeCoroutines keeps the shared code idiomatic and adds a thin generated layer. Version 1.0.6 is the stable 1.0 line (it needs a Kotlin version at or above the one it was built for; check the release notes when you bump Kotlin).

Gradle setup

// sharedLogic/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.ksp)
    alias(libs.plugins.kmpNativeCoroutines) // com.rickclephas.kmp.nativecoroutines
}

kotlin {
    iosArm64()
    iosSimulatorArm64()

    sourceSets {
        all {
            // @ObjCName, used by the generated declarations, is still an experimental annotation.
            languageSettings.optIn("kotlin.experimental.ExperimentalObjCName")
        }
        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
        }
    }
}

Declare plugin versions in gradle/libs.versions.toml (KMP-NativeCoroutines 1.0.6, and the KSP release that matches your Kotlin). The Swift side is added with Swift Package Manager, linking only the products you use (KMPNativeCoroutinesAsync, KMPNativeCoroutinesCombine, KMPNativeCoroutinesRxSwift); see Swift Packages and Modularization.

Annotating the ViewModel

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/presentation/TrailListViewModel.kt
package com.example.trailmate.presentation

import androidx.lifecycle.ViewModel
import com.example.trailmate.domain.TrailRepository
import com.rickclephas.kmp.nativecoroutines.NativeCoroutines
import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

class TrailListViewModel(
    private val repository: TrailRepository,
) : ViewModel() {

    private val _uiState = MutableStateFlow<TrailListUiState>(TrailListUiState.Loading)

    // StateFlow: generates uiStateValue (current value) plus a collectable uiState.
    @NativeCoroutinesState
    val uiState: StateFlow<TrailListUiState> = _uiState.asStateFlow()

    // suspend function: generates toggleFavorite that returns a cancellable "native" handle.
    @NativeCoroutines
    suspend fun toggleFavorite(trailId: String): Boolean = repository.toggleFavorite(trailId)

    // Plain Flow property: use @NativeCoroutines (shared and cold flows).
    @NativeCoroutines
    val favoriteCount = repository.observeFavoriteCount()
}

The distinction between the two annotations matters. @NativeCoroutinesState is for StateFlow properties: it generates a synchronous uiStateValue property in addition to the flow, which is what SwiftUI wants for an initial render without waiting for the first emission. @NativeCoroutines is for suspend functions and for any other Flow or SharedFlow. The generated declarations return functions in a NativeSuspend or NativeFlow shape: a closure that starts the work and returns a cancellation closure. That closure is the piece that makes real cancellation possible, because Swift can call it when the Task is cancelled. A third annotation, @NativeCoroutinesRefined, hides the generated API from Swift (through @ShouldRefineInSwift) so you can wrap it in your own Swift types.

Calling it from Swift

// iosApp/iosApp/Features/TrailList/NativeCoroutinesExamples.swift
import Shared
import KMPNativeCoroutinesAsync

// One-shot call -> async throws. Cancelling the Swift Task cancels the Kotlin coroutine.
func toggle(viewModel: TrailListViewModel, id: String) async throws -> Bool {
    try await asyncFunction(for: viewModel.toggleFavorite(trailId: id))
}

// Flow -> AsyncSequence. Leaving the loop (or cancelling the Task) cancels the collection.
func observe(viewModel: TrailListViewModel) async {
    do {
        let sequence = asyncSequence(for: viewModel.uiState)
        for try await state in sequence {
            print("Received \(state)")
        }
    } catch {
        print("Flow failed: \(error)")
    }
}

// Flow -> Combine publisher: createPublisher(for: viewModel.uiState), see the adapters below.

// Current StateFlow value, synchronously.
func currentState(viewModel: TrailListViewModel) -> TrailListUiState {
    viewModel.uiStateValue
}

The same shape exists for other consumers. createPublisher(for:) and createFuture(for:) come from the Combine package; createObservable(for:) and createSingle(for:) come from the RxSwift package. Those are the right choice if the iOS app has not adopted Swift concurrency (or your team already has a large Combine pipeline); for a new SwiftUI app, asyncFunction and asyncSequence are simpler. Where Combine stands in modern apps is covered on Swift Concurrency in Apps.

SKIE

SKIE takes the opposite approach: leave the Kotlin code alone and improve the generated framework.

Gradle setup

// sharedLogic/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.skie) // co.touchlab.skie, version 0.10.15
}

kotlin {
    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "Shared"
            isStatic = true
        }
    }
}

No annotation is needed. Once the plugin is applied, the framework that Xcode links already exposes suspend and Flow in Swift-native form. SKIE’s Kotlin/Native compiler plugin depends closely on the Kotlin compiler version, so each SKIE release supports a specific range of Kotlin versions: update the two together and check the SKIE compatibility table before bumping Kotlin. The plugin is third-party; it is not a JetBrains artifact.

What Swift sees

With SKIE, the TrailListViewModel from the first section is consumed with no wrapper function at all.

// iosApp/iosApp/Features/TrailList/SkieExamples.swift
import Shared // SKIE-enhanced framework

// suspend -> native async throws, with Bool instead of KotlinBoolean.
func toggle(viewModel: TrailListViewModel, id: String) async throws -> Bool {
    try await viewModel.toggleFavorite(trailId: id)
}

// StateFlow -> SkieSwiftStateFlow<TrailListUiState>: an AsyncSequence with a synchronous .value.
func observe(viewModel: TrailListViewModel) async {
    let initial: TrailListUiState = viewModel.uiState.value
    print("Initial \(initial)")

    // for-await ends when the Task is cancelled, which cancels the Kotlin collection.
    for await state in viewModel.uiState {
        print("Received \(state)")
    }
}

// Sealed interface -> exhaustive switch via SKIE's onEnum(of:).
func describe(state: TrailListUiState) -> String {
    switch onEnum(of: state) {
    case .loading:
        return "Loading"
    case .success(let success):
        return "\(success.trails.count) trails"
    case .error(let error):
        return error.message
    }
}

SKIE’s suspend functions are real async functions: Swift task cancellation reaches the Kotlin coroutine and CancellationException is not reported as a failure. Flow becomes SkieSwiftFlow and StateFlow becomes SkieSwiftStateFlow with a synchronous, typed value; both conform to the ordinary protocols, so for await and .task {} just work.

Trade-offs

  • Coupling to the compiler. SKIE hooks into Kotlin/Native compilation, so a new Kotlin release can be blocked until SKIE catches up. Pin both versions together.

  • Build time and framework size. It generates additional Swift sources and compiles them into the framework.

  • Not an official artifact. Support lives in Touchlab’s repository, not in the Kotlin documentation.

  • Not combined with Swift export at this baseline: SKIE extends the Obj-C export, which Swift export replaces.

Swift Export: The Built-in Mapping

Swift export, enabled with swiftExport {} and embedSwiftExportForXcode, generates Swift declarations directly instead of an Obj-C header. It is Alpha at this baseline, which means interfaces, generated names and supported constructs can still change between Kotlin releases.

For concurrency the target is the same shape SKIE produces: a suspend function becomes an async function, and a Flow becomes an AsyncSequence. In Swift the call site looks like the SKIE example, with plain types and no helper libraries.

// iosApp/iosApp/Features/TrailList/SwiftExportExamples.swift
import Shared // generated by Swift export (Alpha); module name configured in swiftExport {}

func toggle(viewModel: TrailListViewModel, id: String) async throws -> Bool {
    // suspend -> async throws, no completion handler and no boxing.
    try await viewModel.toggleFavorite(trailId: id)
}

func observe(viewModel: TrailListViewModel) async {
    // Flow -> AsyncSequence; sealed classes map to Swift enums (since 2.4.20).
    for await state in viewModel.uiState {
        switch state {
        case .loading:
            print("Loading")
        case .success(let success):
            print("\(success.trails.count) trails")
        case .error(let error):
            print(error.message)
        }
    }
}

The attraction is no third-party plugin and no annotation; the cost is maturity. Supported constructs, the shape of StateFlow and generated names are still evolving. Prototype on a branch, keep production on SKIE or KMP-NativeCoroutines, and re-evaluate at Beta. Limits are listed on Objective-C Interop, Swift Export and SKIE.

Comparing the Approaches

Aspect Default export KMP-NativeCoroutines 1.0.6 SKIE 0.10.15 Swift export (Alpha)

suspend in Swift

Completion handler, plus a synthesized async; boxed results.

asyncFunction(for:) returning async throws, or a Combine future.

Native async throws with plain types.

Native async throws with plain types.

Flow in Swift

Opaque Kotlin interface; you write a FlowCollector.

asyncSequence(for:), createPublisher(for:), createObservable(for:).

SkieSwiftFlow, an AsyncSequence.

AsyncSequence.

StateFlow.value

Any?.

Generated uiStateValue (with @NativeCoroutinesState).

Synchronous typed .value.

Evolving; check the current release notes.

Cancellation

No handle; do not rely on it.

Real: Swift task or subscription cancels the Kotlin coroutine.

Real: Swift task cancellation reaches the coroutine.

Mapped natively; verify against your Kotlin version.

Exhaustive switch on sealed types

No.

No (needs SKIE or Swift export).

Yes, with onEnum(of:).

Yes, as native Swift enums (since 2.4.20).

Stability

Beta (Obj-C interop).

Stable 1.0 library, on Beta Obj-C interop.

Third-party, widely used.

Alpha.

As a rule of thumb: pick SKIE for a SwiftUI-first app that wants the least ceremony and exhaustive switch; pick KMP-NativeCoroutines when you need Combine or RxSwift, when you want to avoid a Kotlin/Native compiler plugin, or when the team prefers explicit annotations; stay on the default export only for a few simple calls; and treat Swift export as the direction to watch.

Observing a Shared StateFlow from SwiftUI

Whatever the bridge, the SwiftUI pattern is the same: a small Swift model owns the shared ViewModel, collects its StateFlow in a Task, and publishes the latest value so SwiftUI re-renders. This section uses SKIE’s AsyncSequence; the KMP-NativeCoroutines variant differs only in the line that creates the sequence.

An @Observable wrapper

// iosApp/iosApp/Features/TrailList/TrailListModel.swift
import Observation
import Shared

@MainActor
@Observable
final class TrailListModel {
    // The shared Kotlin ViewModel; owning it keeps its viewModelScope alive.
    private let viewModel: TrailListViewModel

    private(set) var uiState: TrailListUiState

    init(viewModel: TrailListViewModel) {
        self.viewModel = viewModel
        self.uiState = viewModel.uiState.value // initial value, synchronously
    }

    /// Runs until the surrounding Task is cancelled.
    func observe() async {
        // With KMP-NativeCoroutines this line becomes:
        //   for try await state in asyncSequence(for: viewModel.uiState) { ... }
        for await state in viewModel.uiState {
            uiState = state
        }
    }

    func refresh() {
        viewModel.refresh()
    }

    func toggleFavorite(id: String) async {
        _ = try? await viewModel.toggleFavorite(trailId: id)
    }
}

The wrapper keeps Kotlin types at the edge of the Swift code, makes the screen previewable (see Xcode Projects and Previews) and leaves one place to change if you switch bridges.

The view

// iosApp/iosApp/Features/TrailList/TrailListView.swift
import SwiftUI
import Shared

struct TrailListView: View {
    @State private var model: TrailListModel

    init(viewModel: TrailListViewModel) {
        _model = State(initialValue: TrailListModel(viewModel: viewModel))
    }

    var body: some View {
        content
            .navigationTitle("Trails")
            .refreshable { model.refresh() }
            // Starts when the view appears and is cancelled automatically when it disappears.
            .task { await model.observe() }
    }

    @ViewBuilder
    private var content: some View {
        switch onEnum(of: model.uiState) {
        case .loading:
            ProgressView()
        case .success(let success):
            List(success.trails, id: \.id) { trail in
                Text(trail.name)
            }
        case .error(let error):
            ContentUnavailableView(error.message, systemImage: "exclamationmark.triangle")
        }
    }
}

.task { } is the important modifier. SwiftUI starts the task when the view appears and cancels it when the view disappears, which cancels the for await loop, which (with SKIE or KMP-NativeCoroutines) cancels the Kotlin collection. You never write a deinit or call cancel() yourself. The state and @State mechanics are explained on State and Data Flow with Observation.

The Whole Flow: Subscription, Emissions and Cancellation

The sequence below shows what happens from the moment the list view appears to the moment it is dismissed.

sequenceDiagram participant V as SwiftUI TrailListView participant M as TrailListModel (Swift wrapper) participant VM as TrailListViewModel (Kotlin) participant R as TrailRepository V->>M: .task starts observe() M->>VM: uiState collection begins (for await) VM-->>M: current value (Loading) M-->>V: uiState = Loading, body re-evaluates VM->>R: refresh(): loadTrails() R-->>VM: List of Trail VM-->>M: emit Success(trails) M-->>V: uiState = Success, list renders Note over V,M: user navigates away V->>M: view disappears, Task cancelled M->>VM: cancellation reaches the Kotlin collector Note over VM: collection stops, viewModelScope stays alive Note over VM: ViewModel is released later, onCleared() cancels viewModelScope

The diagram separates two lifetimes: cancelling the collection stops the observation, but the ViewModel’s viewModelScope ends only when the object is cleared.

Lifecycle: Who Owns the ViewModel

On Android, the ViewModelStore ties a ViewModel to an Activity or navigation entry, and onCleared() runs when the screen is permanently gone. iOS has no ViewModelStore; the lifetime is whatever Swift object holds the reference. This has three practical consequences.

  • The Swift owner decides. In the earlier example, @State private var model makes SwiftUI keep the TrailListModel, and therefore the shared ViewModel, alive for as long as the view’s identity survives. When the identity is dropped, the references are released, the Kotlin object becomes unreachable and the Kotlin/Native garbage collector eventually finalizes it.

  • onCleared() is not prompt. It depends on Kotlin garbage collection; for deterministic clearing, Google’s KMP guide describes a Swift-side ViewModelStoreOwner that calls viewModelStore.clear(). See Android Developers — ViewModel in Kotlin Multiplatform.

  • Do not create a ViewModel in body. SwiftUI re-evaluates body often, so create it once through State(initialValue:), @StateObject or an injected parent (see the Koin doInitKoin pattern on Dependency Injection in KMP: Koin, Metro and Manual DI).

A collector that is never cancelled keeps its Swift closure, and everything the closure captured, alive for as long as the ViewModel scope lives (the cross-boundary retain-cycle case covered on Kotlin/Native Memory, Performance, Binary Size and Debugging). The remedy is to tie the collection to a cancellable owner such as .task.

Cancellation in Detail

The cancellation story differs per approach, and it is the single most important reason to prefer a bridge over the default export.

Situation With SKIE or KMP-NativeCoroutines With the default export

View disappears while a .task { for await …​ } loop is running

The loop ends; the Kotlin collect is cancelled and the producer upstream stops (for a stateIn/WhileSubscribed flow, it stops after the timeout).

The SwiftUI task ends, but the Kotlin collect call keeps running.

try await toggleFavorite(…​) inside a cancelled Task

The Kotlin coroutine receives a CancellationException; the Swift call throws CancellationError.

Do not rely on cancellation; the coroutine may run to completion.

Kotlin throws a CancellationException

Swift sees a cancellation, not an app failure.

Delivered as an NSError only when @Throws declares it.

Swallowing a cancellation in Swift defeats the mechanism: a catch that ignores CancellationError inside a loop keeps a cancelled Task spinning, so check Task.isCancelled or rethrow CancellationError. In the completion-handler form, an ignored error argument is the Swift equivalent of swallowing an exception.

Threading

Threading is where the shared and native worlds touch, and it is where most hard-to-reproduce bugs hide.

  • Kotlin to Swift. With the new memory manager (the only one since 1.9.20) any thread may call any Kotlin object; there is no freezing. See Coroutines, Flow and Concurrency in Shared Code.

  • Which thread delivers emissions. A coroutine collecting on Dispatchers.Main (the default for viewModelScope) delivers on the main queue. A StateFlow produced from a background dispatcher with flowOn may deliver wherever the collector’s context says, so never assume the main thread from the Swift side.

  • SwiftUI wants the main actor. @Observable mutations that drive the UI must happen on @MainActor, which is why TrailListModel is annotated. A Swift Task created in .task {} inherits the main actor from the view, and the for await loop resumes on it, so assigning uiState is safe even if Kotlin emitted from another thread. With Combine you add .receive(on: DispatchQueue.main).

  • Swift to Kotlin. A suspend call from Swift awaits, so it does not block the main actor.

  • Swift 6 strict concurrency. Kotlin classes are Obj-C classes that Swift cannot prove Sendable; keep the ViewModel on the main actor (as in TrailListModel). See Swift Concurrency in Apps.

Designing the Shared API for Swift

Some problems are best solved in the shared code.

  • Expose StateFlow, not a cold Flow, for state. Swift then has a current value, the first render needs no Loading flash, and stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial) stops the upstream a few seconds after the last Swift collector is cancelled.

  • Model one-off events as state. A navigation event emitted on a SharedFlow while the SwiftUI view is not collecting is lost; a field in uiState that the view acknowledges is deterministic on both platforms.

Test the Kotlin ViewModel in commonTest with Turbine (see Testing Shared Code) and the Swift wrapper with Testing with Swift Testing, injecting a TrailListViewModel built over a fake repository.

Common Mistakes

  • Using the default Flow export in production. No cancellation and no element type; add a bridge early.

  • Applying both SKIE and KMP-NativeCoroutines. Duplicate overloads; pick one per framework.

  • Forgetting the annotation (KMP-NativeCoroutines). An un-annotated StateFlow silently uses the default export.

  • Collecting without an owner. A Task started in init and never cancelled keeps the collection and its captures alive; use .task {}.

  • Creating the ViewModel in body. Every re-evaluation creates a new Kotlin ViewModel; create it once.

  • Assuming the main thread. Use @MainActor or .receive(on: DispatchQueue.main).

  • Upgrading Kotlin without SKIE. The framework build fails on a mismatch; update them together.

  • Treating Swift export (Alpha) as production-ready, or relying on onCleared() timing on iOS.

Summary

  • The default export gives completion handlers and an opaque Flow with no dependable cancellation.

  • KMP-NativeCoroutines 1.0.6 adds a cancellable layer via @NativeCoroutines and @NativeCoroutinesState.

  • SKIE 0.10.15 gives native async and AsyncSequence with cancellation, tied to the Kotlin version.

  • Swift export (Alpha) maps suspend and Flow natively and is the direction to watch.

  • In SwiftUI, own the ViewModel in a @MainActor wrapper and collect with .task {} so SwiftUI cancels it.