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
@Throwsdoes not become a SwiftError; it terminates the app. Asuspendfunction needs@Throws(Exception::class)(or a narrower type) for the completion handler to receive anNSError.CancellationExceptionis 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 synthesizedasyncform does not give you the guarantee a Kotlin developer expects either: do not assume that cancelling the surrounding SwiftTaskstops the Kotlin coroutine and releases its resources. Treat the default export as fire-and-wait. -
Results are boxed.
BooleanbecomesKotlinBoolean,IntbecomesKotlinInt, and aList<Trail>becomesNSArraywith generics only partly preserved. -
A
Flowis not a sequence. The default export gives Swift aKotlin_coroutinesFlow— a Kotlin interface with acollect(collector:completionHandler:)method. You must implement a KotlinFlowCollectorclass in Swift, andStateFlow<T>loses its type argument (thevalueproperty is anAny?).
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
@NativeCoroutinesgenerates extra, Swift-friendly properties and functions beside it. A small Swift package turns those intoasynccalls,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
suspendbecomes a nativeasyncfunction andFlowbecomes a nativeAsyncSequencewith no annotations in the shared code. -
Swift export is JetBrains' own Alpha replacement for the Objective-C header: it generates Swift directly and maps
suspendandFlowtoasyncandAsyncSequencenatively.
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
}
}
}
See SKIE documentation.
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
}
}
See SKIE documentation.
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) |
|---|---|---|---|---|
|
Completion handler, plus a synthesized |
|
Native |
Native |
|
Opaque Kotlin interface; you write a |
|
|
|
|
|
Generated |
Synchronous typed |
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 |
No. |
No (needs SKIE or Swift export). |
Yes, with |
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")
}
}
}
See SKIE documentation.
.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.
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 modelmakes SwiftUI keep theTrailListModel, 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-sideViewModelStoreOwnerthat callsviewModelStore.clear(). See Android Developers — ViewModel in Kotlin Multiplatform. -
Do not create a ViewModel in
body. SwiftUI re-evaluatesbodyoften, so create it once throughState(initialValue:),@StateObjector an injected parent (see the KoindoInitKoinpattern 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 |
The loop ends; the Kotlin |
The SwiftUI task ends, but the Kotlin |
|
The Kotlin coroutine receives a |
Do not rely on cancellation; the coroutine may run to completion. |
Kotlin throws a |
Swift sees a cancellation, not an app failure. |
Delivered as an |
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 forviewModelScope) delivers on the main queue. AStateFlowproduced from a background dispatcher withflowOnmay deliver wherever the collector’s context says, so never assume the main thread from the Swift side. -
SwiftUI wants the main actor.
@Observablemutations that drive the UI must happen on@MainActor, which is whyTrailListModelis annotated. A SwiftTaskcreated in.task {}inherits the main actor from the view, and thefor awaitloop resumes on it, so assigninguiStateis safe even if Kotlin emitted from another thread. With Combine you add.receive(on: DispatchQueue.main). -
Swift to Kotlin. A
suspendcall 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 inTrailListModel). See Swift Concurrency in Apps.
Designing the Shared API for Swift
Some problems are best solved in the shared code.
-
Expose
StateFlow, not a coldFlow, for state. Swift then has a current value, the first render needs noLoadingflash, andstateIn(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
SharedFlowwhile the SwiftUI view is not collecting is lost; a field inuiStatethat 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
Flowexport 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
StateFlowsilently uses the default export. -
Collecting without an owner. A
Taskstarted ininitand 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
@MainActoror.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
Flowwith no dependable cancellation. -
KMP-NativeCoroutines 1.0.6 adds a cancellable layer via
@NativeCoroutinesand@NativeCoroutinesState. -
SKIE 0.10.15 gives native
asyncandAsyncSequencewith cancellation, tied to the Kotlin version. -
Swift export (Alpha) maps
suspendandFlownatively and is the direction to watch. -
In SwiftUI, own the ViewModel in a
@MainActorwrapper and collect with.task {}so SwiftUI cancels it.