compose-multiplatform
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
Install
npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/compose-multiplatform
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
git clone https://github.com/ericrisco/rsc-harness.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole ericrisco/rsc-harness collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Compose Multiplatform
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
jpackagepackaging).
Where does this code go?
This is the question you answer dozens of times a day. Default to the leftmost column that compiles.
| Source set | Put here | Concrete example | Never here |
|---|---|---|---|
commonMain |
Shared @Composables, ViewModels, business logic, common interfaces, expect declarations |
@Composable fun GreetingScreen(), expect fun platformName(): String |
android.*, platform.UIKit, java.awt, androidx.activity |
androidMain |
Activity, actual using Android Context/Build |
class MainActivity : ComponentActivity |
iOS/desktop-only APIs |
iosMain |
ComposeUIViewController factory, actual via cinterop/platform.* |
fun MainViewController() = ComposeUIViewController { App() } |
android.* |
desktopMain |
application {} window, Swing interop |
application { Window(::exitApplication) { App() } } |
mobile-only APIs |
wasmJsMain (Beta) |
Web entry point | ComposeViewport(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
expectneeds anactualin every compiled target. An orphanexpectis not a warning — it is a hard build failure (often only surfacing on the iOS target), so add theactualper target or remove the target. - Keep the common surface tiny. Each
expectsymbol multiplies into Nactuals you maintain; expose the smallest function, not a fat class. - Prefer a common
interface+ DI over deepexpecttrees for anything you want to test or fake.expect classcan'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/UIKitViewControllerwith a factory lambda. - iOS — shared Compose inside SwiftUI: wrap
ComposeUIViewControllerin aUIViewControllerRepresentable. - Android:
AndroidViewfor native views; host the tree viasetContent { App() }in anActivity. - Desktop:
application { Window { App() } }; Swing interop viaSwingPanel.
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.ViewModelworks incommonMain. Obtain instances withkoin-compose-viewmodel'skoinViewModel { }so they survive recomposition. iOS has no built-inViewModelStoreOwner— 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 AndroidApplicationand from iOS app init:
// commonMain
fun initKoin(config: KoinAppDeclaration? = null) = startKoin {
config?.invoke(this)
modules(appModule, platformModule)
}
- Navigation:
androidx.navigationprovides type-safe nav + deep links incommonMain. - Resources:
compose.components.resourcesgeneratesResaccessors —Res.string.app_name,Res.drawable.logo, fonts — shared across all platforms.
Running & packaging
- Android: run the
androidApprun config (hosts viasetContent). - iOS: open
iosAppin Xcode, or use the KMP iOS run config in the IDE. - Desktop:
./gradlew :desktopApp:run; package with./gradlew :desktopApp:packageDistributionForCurrentOS(needs JDK 17+ forjpackage). - Web (Beta):
./gradlew :webApp:wasmJsBrowserDevelopmentRun.
Anti-patterns
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
android.* / platform.UIKit / java.awt import in commonMain |
Breaks the build for every other target, error surfaces far away | expect/actual or inject via a common interface |
expect with no actual for a target |
Hard build failure on that target | Add an actual per compiled target or drop the target |
Recreating a ViewModel each recomposition (remember { VM() } wrong) |
State loss on every recompose | koinViewModel { } / hoist state |
| Treating Compose Web as production | Web is Beta (1.9), not Stable | Ship Android/iOS/desktop; pilot Web only |
| Kotlin < 2.1.0 with CMP 1.8+ | K2 incompatibility — deps fail to link | Bump to Kotlin 2.2.x |
Deep expect class for testable logic |
Can't fake in commonTest |
Common interface + Koin-injected platform impl |
Pre-splitting into sharedLogic/sharedUI on day one |
Premature complexity, extra Gradle wiring | Start 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
commonMainsource set exists; - every
expectincommonMainhas a matchingactualin 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.
Files (rsc-harness)
-
evals
-
cases.yaml 3 KB
skill: compose-multiplatform should_trigger: - prompt: "Set up a Kotlin Multiplatform project that shares one Compose UI across Android and iOS." why: Core use — shared commonMain @Composables across platforms, the center of the skill. - prompt: "My `expect fun` compiles fine but the iOS build complains there's no `actual` — how do I fix it?" why: Non-obvious — orphaned expect/actual is a CMP build trap surfacing only on the iOS target. - prompt: "How do I embed a native MapKit/SwiftUI view inside my shared Compose screen using UIKitView?" why: Non-obvious native interop — the iOS UIKitView factory / injected-interface pattern. - prompt: "Compartir la UI entre Android i iOS amb Kotlin, una sola base de codi." why: Catalan phrasing for sharing UI across Android/iOS with Kotlin — the core CMP use. - prompt: "Where should the platform-specific date formatter live — commonMain or androidMain/iosMain?" why: Source-set placement decision — the 'where does this code go' table is the skill's daily question. - prompt: "Add a desktop target to my Compose Multiplatform app and package it with jpackage." why: Desktop breadth — application {} window + packageDistributionForCurrentOS, JDK 17+ floor. should_not_trigger: - prompt: "Build a native Android-only app with Jetpack Compose and publish it to the Play Store." route_to: kotlin-android why: Single-platform native Android — no shared multiplatform module, Play Store specifics. - prompt: "I'm writing a SwiftUI app in Xcode and need help with App Store code signing." route_to: swift-ios why: Single-platform native iOS — SwiftUI/Xcode/signing, not shared Kotlin Compose. - prompt: "Build a cross-platform mobile UI with Flutter widgets and the flutter CLI." route_to: flutter why: Dart/Flutter cross-platform, not Kotlin/Compose Multiplatform. - prompt: "Create a cross-platform desktop app with a Rust core and a web-tech UI." route_to: tauri why: Rust desktop shell with web UI, not a Kotlin/CMP shared tree. capability: - scenario: "Scaffold a shared-UI KMP project (a `shared` module plus Android and iOS app hosts), add an `expect`/`actual` `platformName()`, share one @Composable greeting screen across platforms, and embed a native map via `UIKitView` injected through a common interface." must_include: - "shared module whose commonMain holds the shared @Composable greeting screen" - "Compose Multiplatform plugin (org.jetbrains.compose) and Kotlin 2.1.0+ in the build (2.2.x recommended; K2 floor)" - "expect fun platformName() in commonMain with an actual in BOTH androidMain and iosMain" - "no platform import (android.*, platform.UIKit) leaking into commonMain" - "native view embedded via UIKitView fed by an injected common interface, not a raw expect @Composable" - "host wiring named: ComposeUIViewController for iOS and setContent for Android" - "notes iOS is Stable since CMP 1.8.0 and Web is only Beta (1.9), not production" -
README.md 686 B
# Evals — compose-multiplatform `cases.yaml` is a trigger/routing harness, not an executable test suite. Run it through the repo's eval runner (or read it by hand) to confirm three things: the `should_trigger` prompts — including the non-obvious orphaned-`expect` case and the Catalan phrasing — actually fire this skill on its description; each `should_not_trigger` prompt routes to the named sibling (`kotlin-android`, `swift-ios`, `flutter`, `tauri`) instead of here; and a real skill response to the `capability` scenario satisfies every item in its `must_include` rubric. There is nothing to compile or execute — judgment is against the description and the SKILL.md body.
-
-
references
-
ios-interop.md 3.2 KB
# iOS interop — bridge patterns Read this before writing any iOS-specific Compose interop. iOS is **Stable** in Compose Multiplatform since 1.8.0 (May 2025). Two directions: native views inside shared Compose, and shared Compose inside SwiftUI. ## 1. Native UIKit view inside shared Compose — `UIKitView` `UIKitView` (and `UIKitViewController` for a whole controller) takes a `factory` lambda that builds the native object. Run on the main thread; size it with the Compose `modifier`. ```kotlin // iosMain import androidx.compose.ui.viewinterop.UIKitView import platform.MapKit.MKMapView @Composable fun NativeMap(modifier: Modifier = Modifier) { UIKitView( factory = { MKMapView() }, modifier = modifier.fillMaxSize(), update = { mapView -> /* mutate mapView on state change */ }, ) } ``` ## 2. Native view factory via a common interface + Koin (preferred) Keep the shared screen platform-agnostic. Define the contract in `commonMain`, implement it in `iosMain`, inject with Koin. The common `@Composable` never imports `platform.*`. ```kotlin // commonMain interface NativeViewFactory { @Composable fun MapView(modifier: Modifier) } @Composable fun MapScreen(factory: NativeViewFactory = koinInject()) { factory.MapView(Modifier.fillMaxSize()) } ``` ```kotlin // iosMain class IosNativeViewFactory : NativeViewFactory { @Composable override fun MapView(modifier: Modifier) { UIKitView(factory = { MKMapView() }, modifier = modifier) } } // bind in a platformModule: single<NativeViewFactory> { IosNativeViewFactory() } ``` Why an interface over a raw `expect @Composable`: the Android/desktop targets get their own implementation (or a no-op), and you can fake it in `commonTest`. ## 3. Shared Compose inside a SwiftUI app — `ComposeUIViewController` Expose a factory from `iosMain`, then wrap it in a SwiftUI `UIViewControllerRepresentable`. ```kotlin // iosMain import androidx.compose.ui.window.ComposeUIViewController fun MainViewController() = ComposeUIViewController { App() } ``` ```swift // iosApp (SwiftUI) import SwiftUI import shared struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { MainViewControllerKt.MainViewController() } func updateUIViewController(_ vc: UIViewController, context: Context) {} } struct ContentView: View { var body: some View { ComposeView().ignoresSafeArea(.all) } } ``` ## 4. ViewModel lifecycle on iOS `androidx.lifecycle.ViewModel` runs in `commonMain`, but iOS has **no built-in `ViewModelStoreOwner`**, so nothing clears the VM for you. - Inside a Compose-hosted screen, obtain VMs with `koinViewModel { }` (from `koin-compose-viewmodel`) — they are scoped to the Compose nav entry and cleared correctly. - When a Kotlin VM must be observed from *SwiftUI* (not a Compose host), use **KMP-ObservableViewModel** so SwiftUI sees `@Published`-style updates, and clear it from the SwiftUI view's lifecycle. ## 5. Camera / system APIs Same pattern: a common `interface` (`CameraController`, `SecureStorage`) with an `iosMain` impl using `platform.AVFoundation` / Keychain, injected via Koin. Reserve raw `expect`/`actual` for tiny, untestable leaves (device name, locale). -
project-setup.md 3.9 KB
# Project setup — Gradle, source sets, module layouts Full setup for the 2026 default KMP structure. Floors: CMP 1.11.0 (Jetpack Compose 1.11.1), Kotlin 2.1.0+ (2.2.20 recommended), K2 mandatory since CMP 1.8.0, AGP 9.0. ## Version catalog (`gradle/libs.versions.toml`) ```toml [versions] kotlin = "2.2.20" compose = "1.11.0" agp = "9.0.0" androidx-lifecycle = "2.9.0" koin = "4.0.0" navigation = "2.9.0" [plugins] kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } androidLibrary = { id = "com.android.library", version.ref = "agp" } [libraries] lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } ``` ## `shared/build.gradle.kts` ```kotlin plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.androidLibrary) } kotlin { androidTarget() jvm("desktop") listOf(iosArm64(), iosSimulatorArm64()).forEach { it.binaries.framework { baseName = "shared" } } // wasmJs { browser() } // Beta — enable to pilot Web only sourceSets { commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.components.resources) // generated Res accessors implementation(libs.lifecycle.viewmodel) implementation(libs.navigation.compose) implementation(libs.koin.core) implementation(libs.koin.compose.viewmodel) } androidMain.dependencies { implementation(compose.preview) } } } ``` ## Source-set hierarchy ```text commonMain ├── androidMain ├── desktopMain (jvm) ├── wasmJsMain (Beta) └── iosMain (intermediate) ├── iosArm64 └── iosSimulatorArm64 ``` Use the intermediate `iosMain` for code shared by both iOS targets; only drop to `iosArm64`/`iosSimulatorArm64` for arch-specific cinterop. ## Module-layout variants - **Mobile-only:** `shared` + `androidApp` + `iosApp`. - **+ Desktop:** add `desktopApp` (`./gradlew :desktopApp:run`). - **+ Web (Beta):** add `webApp` with the `wasmJs` target; do not promise parity. - **Mixed native UI:** split `shared` into **`sharedLogic`** (all platforms, no Compose UI) + **`sharedUI`** (CMP platforms only). Native screens depend on `sharedLogic`; shared screens live in `sharedUI`. - **+ Server:** add a root **`core`** module consumed by both clients and a JVM/Ktor server. ## Scaffolding - New project: **kmp.new** or the Kotlin Multiplatform wizard (IntelliJ IDEA 2025.2.2+ / Android Studio Otter 2025.2.1+ with the KMP plugin). - Existing Android app: Android Studio **Shared Module Template** (added May 2025) adds a KMP `shared` module in place. ## AGP 9.0 migration notes The new default structure aligns with AGP 9.0. When migrating from the old single `composeApp` module: extract shared `@Composable`s/VMs into a `shared` library module, turn `composeApp` into a thin `androidApp` host that calls `setContent { App() }`, and move the iOS framework export into `shared`. ## Packaging - Desktop: `./gradlew :desktopApp:packageDistributionForCurrentOS` (dmg/msi/deb). Needs **JDK 17+** for `jpackage`; desktop runtime floor is JDK 11+. - iOS: archive the `iosApp` Xcode project (App Store signing is `swift-ios` territory). - Web (Beta): `./gradlew :webApp:wasmJsBrowserDistribution`.
-
-
scripts
-
verify.sh 4.4 KB
#!/usr/bin/env bash # verify.sh — static, read-only checks for a Compose Multiplatform / KMP project. # No Gradle/Xcode run required. Exits 0 on a clean OR empty target (no false failure). # # Usage: verify.sh [PROJECT_DIR] (default: current directory) # # Hard failures (exit 1): missing commonMain when KMP is present, orphaned `expect` # (no matching `actual`), platform import leaking into commonMain. # Warnings (still exit 0): Kotlin < 2.1.0, missing Compose plugin. set -uo pipefail ROOT="${1:-.}" FAIL=0 WARN=0 note() { printf ' %s\n' "$1"; } ok() { printf 'PASS %s\n' "$1"; } bad() { printf 'FAIL %s\n' "$1"; FAIL=1; } warn() { printf 'WARN %s\n' "$1"; WARN=1; } if [ ! -d "$ROOT" ]; then echo "verify.sh: '$ROOT' is not a directory" >&2 exit 2 fi # Locate commonMain source dirs (KMP marker). Ignore build output dirs. # Portable to bash 3.2 (macOS) — no mapfile. COMMON_DIRS=() while IFS= read -r d; do [ -n "$d" ] && COMMON_DIRS+=("$d") done < <(find "$ROOT" -type d -name commonMain \ -not -path '*/build/*' -not -path '*/.gradle/*' 2>/dev/null) # Detect whether this looks like a KMP/CMP project at all. HAS_KMP=0 if [ "${#COMMON_DIRS[@]}" -gt 0 ]; then HAS_KMP=1; fi if grep -rqsl 'org.jetbrains.kotlin.multiplatform\|org.jetbrains.compose' \ "$ROOT" --include='*.kts' --include='*.toml' 2>/dev/null; then HAS_KMP=1; fi if [ "$HAS_KMP" -eq 0 ]; then echo "verify.sh: no KMP/CMP project detected under '$ROOT' — nothing to check." exit 0 fi # 1) commonMain must exist. if [ "${#COMMON_DIRS[@]}" -eq 0 ]; then bad "no commonMain source set found (a shared module is the core of CMP)" else ok "commonMain present (${#COMMON_DIRS[@]} found)" fi # 2) Every `expect` in commonMain needs a matching `actual` somewhere. # Match on the declared symbol name to catch orphans cheaply. ORPHANS=0 for cm in ${COMMON_DIRS[@]+"${COMMON_DIRS[@]}"}; do # collect expect declarations: fun/class/val/var/object/interface while IFS= read -r decl; do [ -z "$decl" ] && continue name="$decl" if grep -rqs "actual[[:space:]].*\b${name}\b" "$ROOT" \ --include='*.kt' 2>/dev/null \ | grep -qv "commonMain" 2>/dev/null; then : elif grep -rs "actual[[:space:]]" "$ROOT" --include='*.kt' 2>/dev/null \ | grep -q "\b${name}\b"; then : else bad "orphaned expect '${name}' has no matching actual in any platform source set" ORPHANS=$((ORPHANS+1)) fi done < <(grep -rhsoE '^[[:space:]]*expect[[:space:]]+(fun|class|val|var|object|interface)[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' \ "$cm" --include='*.kt' 2>/dev/null \ | sed -E 's/.*(fun|class|val|var|object|interface)[[:space:]]+([A-Za-z_][A-Za-z0-9_]*).*/\2/') done [ "$ORPHANS" -eq 0 ] && ok "no orphaned expect declarations" # 3) Compose plugin + Kotlin version. if grep -rqs 'org.jetbrains.compose' "$ROOT" \ --include='*.kts' --include='*.toml' 2>/dev/null; then ok "Compose Multiplatform plugin (org.jetbrains.compose) present" else warn "Compose Multiplatform plugin (org.jetbrains.compose) not found" fi KVER=$(grep -rhsoE 'kotlin[^=]*=[[:space:]]*"?([0-9]+\.[0-9]+\.[0-9]+)' \ "$ROOT" --include='*.toml' --include='*.kts' 2>/dev/null \ | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -V | tail -1) if [ -n "$KVER" ]; then MAJ=${KVER%%.*}; REST=${KVER#*.}; MIN=${REST%%.*} if [ "$MAJ" -gt 2 ] || { [ "$MAJ" -eq 2 ] && [ "$MIN" -ge 1 ]; }; then ok "Kotlin $KVER (>= 2.1.0 K2 floor)" else warn "Kotlin $KVER is below 2.1.0 — CMP 1.8+ requires K2 (Kotlin 2.1.0+); bump to 2.2.x" fi else warn "could not detect a Kotlin version in version catalog / build scripts" fi # 4) No platform-only imports leaking into commonMain. FORBIDDEN='^import[[:space:]]+(android\.|androidx\.activity|platform\.UIKit|platform\.Foundation|java\.awt|javax\.swing|org\.w3c\.dom)' LEAKS=0 for cm in ${COMMON_DIRS[@]+"${COMMON_DIRS[@]}"}; do while IFS= read -r line; do [ -z "$line" ] && continue bad "platform import in commonMain: $line" LEAKS=$((LEAKS+1)) done < <(grep -rhsnE "$FORBIDDEN" "$cm" --include='*.kt' 2>/dev/null) done [ "$LEAKS" -eq 0 ] && ok "commonMain free of platform-only imports" echo "----" if [ "$FAIL" -ne 0 ]; then echo "RESULT: FAIL" exit 1 fi if [ "$WARN" -ne 0 ]; then echo "RESULT: PASS (with warnings)" else echo "RESULT: PASS" fi echo "(Optional: if ./gradlew exists, ':shared:compileKotlinMetadata' compiles the common metadata.)" exit 0
-
-
SKILL.md 10.5 KB
--- name: compose-multiplatform description: "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)." tags: [kotlin, kmp, compose-multiplatform, cross-platform, shared-ui, expect-actual, ios, android] recommends: [kotlin-android, swift-ios, flutter, tauri] origin: risco --- # Compose Multiplatform 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 set | Put here | Concrete example | Never here | |---|---|---|---| | `commonMain` | Shared `@Composable`s, `ViewModel`s, business logic, common `interface`s, `expect` declarations | `@Composable fun GreetingScreen()`, `expect fun platformName(): String` | `android.*`, `platform.UIKit`, `java.awt`, `androidx.activity` | | `androidMain` | `Activity`, `actual` using Android `Context`/`Build` | `class MainActivity : ComponentActivity` | iOS/desktop-only APIs | | `iosMain` | `ComposeUIViewController` factory, `actual` via cinterop/`platform.*` | `fun MainViewController() = ComposeUIViewController { App() }` | `android.*` | | `desktopMain` | `application {}` window, Swing interop | `application { Window(::exitApplication) { App() } }` | mobile-only APIs | | `wasmJsMain` (Beta) | Web entry point | `ComposeViewport(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`: ```text 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: ```text 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`): ```kotlin // 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. ```kotlin // commonMain expect fun platformName(): String ``` ```kotlin // androidMain import android.os.Build actual fun platformName(): String = "Android ${Build.VERSION.SDK_INT}" ``` ```kotlin // 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 `actual`s 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. ```kotlin // Bad: deep expect class — N actuals, untestable in commonTest expect class Database { fun query(sql: String): List<Row> fun close() } ``` ```kotlin // 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: ```kotlin // 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: ```kotlin // 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-pattern | Why it bites | Do instead | |---|---|---| | `android.*` / `platform.UIKit` / `java.awt` import in `commonMain` | Breaks the build for every other target, error surfaces far away | `expect`/`actual` or inject via a common interface | | `expect` with no `actual` for a target | Hard build failure on that target | Add an `actual` per compiled target or drop the target | | Recreating a `ViewModel` each recomposition (`remember { VM() }` wrong) | State loss on every recompose | `koinViewModel { }` / hoist state | | Treating Compose Web as production | Web is Beta (1.9), not Stable | Ship Android/iOS/desktop; pilot Web only | | Kotlin < 2.1.0 with CMP 1.8+ | K2 incompatibility — deps fail to link | Bump to Kotlin 2.2.x | | Deep `expect class` for testable logic | Can't fake in `commonTest` | Common `interface` + Koin-injected platform impl | | Pre-splitting into `sharedLogic`/`sharedUI` on day one | Premature complexity, extra Gradle wiring | Start 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.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.