Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download ericrisco-rsc-harness-skills_compose-multiplatform-953fef5.zip · 11 KB
Part of ericrisco/rsc-harness — 46 skills

Install

skills CLI npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/compose-multiplatform
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
Git 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 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 @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 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-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.

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.

No comments yet.

Reviews (0)

No reviews yet.

Related