Compose Multiplatform

SkillAI & models

Use when building one shared Compose UI in Kotlin across Android, iOS, and desktop — commonMain @Composables, expect/actual, source-set placement, native interop, multiplatform ViewModel/navigation/Koin. NOT a single-platform native build (that is kotlin-android / swift-ios), and NOT Dart/Flutter cross-platform UI (that is flutter).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Compose Multiplatform skill

What this skill tells your AI

The instructions your AI receives, as published by ericrisco/rsc-harness in skills/compose-multiplatform/SKILL.md and read by ahel’s review.

You write one Compose UI tree in commonMain and let each platform be a thin host. The whole discipline is one sentence: common by default, platform by exception. Every line you put in commonMain ships to Android, iOS, and desktop unchanged; every line you put in a platform source set is a deliberate exception you should be able to justify.

Versions floor (2026)

Pin these or the K2 compiler bites you. Verify against current docs before locking a project — these are the floors, not opinions.

  • Compose Multiplatform 1.11.0, bundling Jetpack Compose 1.11.1.
  • Kotlin 2.1.0+ required (2.2.20 recommended for evolving iOS/Web targets). Since CMP 1.8.0 the K2 compiler is mandatory, so every dependency must compile against Kotlin 2.1.0+.
  • iOS is Stable (production-ready since CMP 1.8.0, May 2025): feature parity for popular cases, type-safe navigation with deep linking, accessibility (VoiceOver, Full Keyboard Access).
  • Web is Beta (CMP 1.9.0, Sept 2025), runs on WasmGC browsers. Do not promise Web parity — ship Android/iOS/desktop, pilot Web.
  • Platform minimums: Android API 21, iOS 14+, macOS 13 arm64, Windows 10+, Ubuntu 20.04+, desktop JDK 11+ (17+ for jpackage packaging).

Where does this code go?

This is the question you answer dozens of times a day. Default to the leftmost column that compiles.

Source setPut hereConcrete exampleNever here
commonMainShared @Composables, ViewModels, business logic, common interfaces, expect declarations@Composable fun GreetingScreen(), expect fun platformName(): Stringandroid.*, platform.UIKit, java.awt, androidx.activity
androidMainActivity, actual using Android Context/Buildclass MainActivity : ComponentActivityiOS/desktop-only APIs
iosMainComposeUIViewController factory, actual via cinterop/platform.*fun MainViewController() = ComposeUIViewController { App() }android.*
desktopMainapplication {} window, Swing interopapplication { Window(::exitApplication) { App() } }mobile-only APIs
wasmJsMain (Beta)Web entry pointComposeViewport(document.body!!) { App() }anything you can't ship as Beta

Why this matters: a platform import in commonMain breaks the build for every other target, and the error surfaces in the iOS link step, far from the offending line. Keep commonMain import-clean.

Project structure (the 2026 default)

The current default KMP layout (announced May 2026, aligned with AGP 9.0) is a dedicated shared KMP library module + per-platform app modules, not the old single composeApp:

my-app/
  shared/            # KMP library: commonMain holds the Compose UI tree
    src/
      commonMain/    # @Composables, ViewModels, expect declarations, DI
      androidMain/   # actual impls using android.*
      iosMain/       # actual impls + ComposeUIViewController
      desktopMain/   # actual impls + application {} window
      wasmJsMain/    # web entry (Beta)
  androidApp/        # thin Android host -> setContent { App() }
  iosApp/            # Xcode project -> embeds the shared framework
  desktopApp/        # ./gradlew :desktopApp:run
  webApp/            # WasmGC entry (Beta)

Split rule: if some screens are native and only some are shared Compose, split into sharedLogic (all platforms) + sharedUI (CMP platforms only). A server-inclusive project adds a root core module. Don't pre-split — start with one shared module and split when a platform genuinely needs native UI.

Source-set hierarchy — commonMain fans out, with intermediate sets where targets share code:

commonMain
├── androidMain
├── desktopMain (jvm)
├── wasmJsMain (Beta)
└── iosMain (intermediate)
    ├── iosArm64
    └── iosSimulatorArm64

Scaffold a new project with kmp.new or the Kotlin Multiplatform wizard (IntelliJ IDEA 2025.2.2+ / Android Studio Otter 2025.2.1+ with the KMP plugin). Add a shared module to an existing Android app via Android Studio's Shared Module Template.

Minimal version-catalog plugin wiring (full Gradle in references/project-setup.md):

// gradle/libs.versions.toml
[versions]
kotlin = "2.2.20"
compose = "1.11.0"
agp = "9.0.0"

[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose" }

expect / actual — the core mechanism

expect/actual is how you reach a platform API while keeping the call site common. Declare expect in commonMain; provide an actual in every target you compile.

// commonMain
expect fun platformName(): String
// androidMain
import android.os.Build
actual fun platformName(): String = "Android ${Build.VERSION.SDK_INT}"
// iosMain
import platform.UIKit.UIDevice
actual fun platformName(): String =
    UIDevice.currentDevice.systemName + " " + UIDevice.currentDevice.systemVersion

Rules, each with the reason it exists:

  • Every expect needs an actual in every compiled target. An orphan expect is not a warning — it is a hard build failure (often only surfacing on the iOS target), so add the actual per target or remove the target.
  • Keep the common surface tiny. Each expect symbol multiplies into N actuals you maintain; expose the smallest function, not a fat class.
  • Prefer a common interface + DI over deep expect trees for anything you want to test or fake. expect class can't be mocked in common tests.
// Bad: deep expect class — N actuals, untestable in commonTest
expect class Database {
    fun query(sql: String): List<Row>
    fun close()
}
// Good: common interface, platform impls injected via Koin (fakeable in tests)
interface Database {
    fun query(sql: String): List<Row>
    fun close()
}
// androidMain/iosMain provide SqliteDatabase implementing Database, bound in a Koin module.

Native interop

You bridge in both directions. Shared Compose embeds native views; native hosts embed shared Compose.

  • iOS — native view inside shared Compose: UIKitView / UIKitViewController with a factory lambda.
  • iOS — shared Compose inside SwiftUI: wrap ComposeUIViewController in a UIViewControllerRepresentable.
  • Android: AndroidView for native views; host the tree via setContent { App() } in an Activity.
  • Desktop: application { Window { App() } }; Swing interop via SwingPanel.

Embed a native view through an injected interface, not a raw expect — so the common screen stays platform-agnostic and testable:

// commonMain
interface MapFactory { /* returns a platform map handle */ }

@Composable
fun MapScreen(mapFactory: MapFactory = koinInject()) {
    // iosMain provides the actual UIKitView wiring around mapFactory; see references/ios-interop.md
}

Full bridge patterns (ComposeUIViewController SwiftUI wrapper, native-view-factory-via-Koin, MapKit/camera, ViewModel lifecycle) live in references/ios-interop.md — read it before writing iOS interop.

State, ViewModel, navigation, DI

  • androidx.lifecycle.ViewModel works in commonMain. Obtain instances with koin-compose-viewmodel's koinViewModel { } so they survive recomposition. iOS has no built-in ViewModelStoreOwner — tie the VM lifecycle to SwiftUI manually (KMP-ObservableViewModel lets SwiftUI observe Kotlin VMs).
  • Koin is the common DI runtime. Define a shared initKoin() and call it from the Android Application and from iOS app init:
// commonMain
fun initKoin(config: KoinAppDeclaration? = null) = startKoin {
    config?.invoke(this)
    modules(appModule, platformModule)
}
  • Navigation: androidx.navigation provides type-safe nav + deep links in commonMain.
  • Resources: compose.components.resources generates Res accessors — Res.string.app_name, Res.drawable.logo, fonts — shared across all platforms.

Running & packaging

  • Android: run the androidApp run config (hosts via setContent).
  • iOS: open iosApp in Xcode, or use the KMP iOS run config in the IDE.
  • Desktop: ./gradlew :desktopApp:run; package with ./gradlew :desktopApp:packageDistributionForCurrentOS (needs JDK 17+ for jpackage).
  • Web (Beta): ./gradlew :webApp:wasmJsBrowserDevelopmentRun.

Anti-patterns

Anti-patternWhy it bitesDo instead
android.* / platform.UIKit / java.awt import in commonMainBreaks the build for every other target, error surfaces far awayexpect/actual or inject via a common interface
expect with no actual for a targetHard build failure on that targetAdd an actual per compiled target or drop the target
Recreating a ViewModel each recomposition (remember { VM() } wrong)State loss on every recomposekoinViewModel { } / hoist state
Treating Compose Web as productionWeb is Beta (1.9), not StableShip Android/iOS/desktop; pilot Web only
Kotlin < 2.1.0 with CMP 1.8+K2 incompatibility — deps fail to linkBump to Kotlin 2.2.x
Deep expect class for testable logicCan't fake in commonTestCommon interface + Koin-injected platform impl
Pre-splitting into sharedLogic/sharedUI on day onePremature complexity, extra Gradle wiringStart with one shared module; split when a platform needs native UI

Verify

After scaffolding or editing, run scripts/verify.sh <project-dir> (read-only, no Gradle/Xcode needed). It statically checks the structural invariants:

  • a commonMain source set exists;
  • every expect in commonMain has a matching actual in some platform source set (catches orphans);
  • the Compose Multiplatform plugin (org.jetbrains.compose) and a Kotlin version are present, and Kotlin is >= 2.1.0 (K2 floor);
  • no forbidden platform imports leak into commonMain.

It exits 0 on a clean or empty target and non-zero only on hard failures.

Signals

GitHub stars
82
Forks
3
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
compose-multiplatform-ericrisco
Source
github.com/ericrisco/rsc-harness