Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory. Co-authored-by: Junie <junie@jetbrains.com>
8.0 KiB
iOS Swift Interop
Kotlin → Swift Naming
| 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()
Nullability & Type Bridging
| 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.
Coroutines → Swift Async
| 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 (recommended)
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
Flow → Swift Observation
This is how iOS observes StateFlow<UiState> — the critical MVI bridge.
SKIE: Flow → AsyncSequence
SKIE converts Flow to AsyncSequence:
func observeState() async {
for await state in viewModel.state { self.uiState = state }
}
Manual StateFlow wrapper
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() }
}
}
Sealed Classes in Swift
Without SKIE — non-exhaustive
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
With SKIE — exhaustive Swift enum
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
Edge cases
- Generic sealed classes — SKIE cannot convert generics to Swift enums; use concrete types at the iOS boundary (e.g.,
ItemListStatenotListState<Item>) - Nested sealed hierarchies — SKIE flattens names:
UiState.Error.Network→.errorNetwork - Opt out — annotate with
@SealedInterop.Disabledto skip SKIE conversion for a specific class
iOS API Design Rules
- Keep the public API surface small — use
internalvisibility +@HiddenFromObjCto exclude Kotlin internals from the generated ObjC header - Avoid generics in public iOS-facing API — ObjC/Swift interop erases or boxes them unpredictably
- Prefer data classes over deep class hierarchies at the boundary — simpler Swift mapping
- Set
isStatic = truein framework configuration for static linkage (smaller binary, faster startup) - Minimize Kotlin↔Swift boundary crossings in hot paths — batch data, don't iterate across the boundary
- Avoid
suspendfunctions that returnUnit— Swift receivesKotlinUnit, requiring callers to discard it explicitly - Expose sealed classes with concrete (non-generic) type parameters for SKIE compatibility
Compose in SwiftUI App
Use 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.
Kotlin entry point
// iosMain
fun MainViewController(): UIViewController = ComposeUIViewController { App() }
Swift bridge
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.
When to use
| 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 |
Native iOS Views in Compose
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 basics
UIKitView(
factory = { MKMapView() },
modifier = Modifier.size(300.dp),
update = { mapView -> mapView.setRegion(region, animated = true) }
)
factory— creates theUIViewinstance once (likeAndroidView's factory)update— called on recomposition to sync Compose state into the native viewmodifier— standard Compose modifier for sizing and layout
Embedding SwiftUI views
SwiftUI 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())
}
Decision table
| 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 |
Anti-Patterns
- Generic
Resource<T>sealed class exposed to Swift — SKIE can't convert it; use concrete result types likeItemListResult - Observing StateFlow without cancellation cleanup — memory leak when the view controller is deallocated
- Returning
Unitfrom public API — becomesKotlinUnitin Swift; use a callback or return a meaningful type - Crossing ObjC boundary in a loop — each call has marshaling overhead; collect results in Kotlin, return the batch
- Exposing mutable Kotlin collections to Swift — mutations won't reflect; return immutable snapshots
- Skipping
@HiddenFromObjC— pollutes the Swift API surface with internal helpers - Recreating UIKit views on every recomposition —
factoryinUIKitViewruns once; put state-dependent updates inupdate, notfactory - Skipping
updateinUIKitView— Compose state changes won't propagate to the native view; always implementupdateto sync mutable properties