| Kotlin construct | Swift equivalent |
|---|---|
Top-level function fun foo() in Bar.kt |
BarKt.foo() |
object AppInit |
AppInit.shared |
companion object member |
Direct on class: MyClass.value |
sealed class UiState |
Class hierarchy (or SKIE exhaustive enum) |
suspend fun load() |
SKIE: async func load() |
// Entry point — top-level function in MainViewController.kt
let controller = MainViewControllerKt.MainViewController()
| Kotlin | Swift | Notes |
|---|---|---|
String |
String |
Non-null bridged directly |
String? |
String? |
Optional bridged directly |
Int / Long |
Int32 / Int64 |
Not Swift Int — use explicit cast |
Unit |
KotlinUnit |
Awkward return — avoid in public API |
Collections: Kotlin List<T> bridges to [T] as a read-only copy. Mutability and structural sharing are lost at the boundary. Pass collections across the boundary sparingly — batch, don't iterate.
| Approach | When to use | Trade-off |
|---|---|---|
| SKIE | Default for new CMP projects | Automatic async/AsyncSequence; adds build plugin |
| KMP-NativeCoroutines | Existing projects already using it | Annotation-driven; SKIE preferred for greenfield |
SKIE converts suspend functions to Swift async automatically:
// commonMain
suspend fun loadItems(): List<Item> = repository.getAll()
let items = try await viewModel.loadItems() // SKIE-generated async bridge
This is how iOS observes StateFlow<UiState> — the critical MVI bridge.
SKIE converts Flow to AsyncSequence:
func observeState() async {
for await state in viewModel.state { self.uiState = state }
}
Without SKIE, expose a callback-based observer from Kotlin; Swift holds the returned cancel closure and invokes it in deinit.
// iosMain
class IosStateCollector<T>(private val flow: StateFlow<T>, private val scope: CoroutineScope) {
private var job: Job? = null
fun observe(onChange: (T) -> Unit): () -> Unit {
job = scope.launch(Dispatchers.Main) { flow.collect { onChange(it) } }
return { job?.cancel() }
}
}
if let loading = state as? UiState.Loading { showSpinner() }
else if let success = state as? UiState.Success { render(items: success.items) }
else if let error = state as? UiState.Error { showError(error.message) }
// No exhaustiveness check — silent bugs when a new sealed subclass is added
switch onEnum(of: state) {
case .loading: showSpinner()
case .success(let s): render(items: s.items)
case .error(let e): showError(e.message)
} // Compiler error if a new sealed subclass is added
ItemListState not ListState<Item>)UiState.Error.Network → .errorNetwork@SealedInterop.Disabled to skip SKIE conversion for a specific classinternal visibility + @HiddenFromObjC to exclude Kotlin internals from the generated ObjC headerisStatic = true in framework configuration for static linkage (smaller binary, faster startup)suspend functions that return Unit — Swift receives KotlinUnit, requiring callers to discard it explicitlyUse ComposeUIViewController to embed a Compose screen inside an existing SwiftUI application. This is the standard path for incremental adoption — add Compose features to a SwiftUI app without rewriting native screens.
// iosMain
fun MainViewController(): UIViewController = ComposeUIViewController { App() }
Wrap the UIViewController in a UIViewControllerRepresentable for SwiftUI:
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
Use ComposeView() anywhere in SwiftUI hierarchy — NavigationStack, tab bar, sheet, or as the root view.
| Scenario | Approach |
|---|---|
| Entire app is Compose | ComposeUIViewController as the root in @main App |
| Hybrid app — some screens SwiftUI, some Compose | Embed ComposeView per-feature inside SwiftUI navigation |
| Single Compose widget in a SwiftUI screen | Embed ComposeView with a fixed frame modifier |
Use UIKitView to embed UIKit or SwiftUI components inside a Compose screen. This is how you use platform-native views (maps, camera, webview) that have no Compose equivalent on iOS.
UIKitView basicsUIKitView(
factory = { MKMapView() },
modifier = Modifier.size(300.dp),
update = { mapView -> mapView.setRegion(region, animated = true) }
)
factory — creates the UIView instance once (like AndroidView's factory)update — called on recomposition to sync Compose state into the native viewmodifier — standard Compose modifier for sizing and layoutSwiftUI views can't be used directly in UIKitView. Wrap them in a UIHostingController and pass the controller to a Kotlin factory function:
// iosMain
@OptIn(ExperimentalForeignApi::class)
fun ComposeEntryPointWithNativeView(
createViewController: () -> UIViewController
): UIViewController = ComposeUIViewController {
Column(Modifier.fillMaxSize()) {
Text("Compose content above")
UIKitViewController(
factory = createViewController,
modifier = Modifier.size(300.dp)
)
}
}
MainViewControllerKt.ComposeEntryPointWithNativeView {
UIHostingController(rootView: MySwiftUIMapView())
}
| Need | Use |
|---|---|
UIKit view (MKMapView, WKWebView, AVCaptureSession) |
UIKitView(factory = { ... }) directly in Kotlin |
SwiftUI view (Map, custom SwiftUI component) |
Wrap in UIHostingController, pass via UIKitViewController |
| Complex native screen with its own navigation | Keep it in SwiftUI/UIKit, embed Compose screens via ComposeUIViewController instead |
Resource<T> sealed class exposed to Swift — SKIE can't convert it; use concrete result types like ItemListResultUnit from public API — becomes KotlinUnit in Swift; use a callback or return a meaningful type@HiddenFromObjC — pollutes the Swift API surface with internal helpersfactory in UIKitView runs once; put state-dependent updates in update, not factoryupdate in UIKitView — Compose state changes won't propagate to the native view; always implement update to sync mutable properties