Files
vnidrop/.codex/skills/compose-skill/references/ios-swift-interop.md
Hammed Abass fd4fff2c86 docs: add compose development guidelines and skills
Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory.

Co-authored-by: Junie <junie@jetbrains.com>
2026-07-07 16:55:43 +02:00

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 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., ItemListState not ListState<Item>)
  • Nested sealed hierarchies — SKIE flattens names: UiState.Error.Network.errorNetwork
  • Opt out — annotate with @SealedInterop.Disabled to skip SKIE conversion for a specific class

iOS API Design Rules

  • Keep the public API surface small — use internal visibility + @HiddenFromObjC to 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 = true in 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 suspend functions that return Unit — Swift receives KotlinUnit, 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 the UIView instance once (like AndroidView's factory)
  • update — called on recomposition to sync Compose state into the native view
  • modifier — 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 like ItemListResult
  • Observing StateFlow without cancellation cleanup — memory leak when the view controller is deallocated
  • Returning Unit from public API — becomes KotlinUnit in 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 recompositionfactory in UIKitView runs once; put state-dependent updates in update, not factory
  • Skipping update in UIKitView — Compose state changes won't propagate to the native view; always implement update to sync mutable properties