Compose Multiplatform
Irbisa · cheatsheetSeptember 13, 2026

Compose Multiplatform

Compose for iOS/desktop/web, resources, navigation, platform UI bridging

Senior Developer20 itemscompressed for a skim
  1. 01

    What is Compose Multiplatform and how does it relate to Jetpack Compose?

    Medium

    Compose Multiplatform (CMP) is JetBrains' build of Jetpack Compose for non-Android targets — iOS, desktop on the JVM, and web on Wasm.

    // shared/src/commonMain/kotlin/App.kt — one composable, every target
    @Composable
    fun App() {
      var count by remember { mutableStateOf(0) }
      MaterialTheme {
        Column(Modifier.padding(24.dp)) {
    …
  2. 02

    Where do images, strings and fonts live in a Compose Multiplatform project, and how do you reference them?

    Medium

    CMP ships its own resource system that generates a typed Res object from files in your source sets — it uses neither Android's R class nor iOS asset catalogs.

    @Composable
    fun Header(name: String) {
      Image(painter = painterResource(Res.drawable.logo), contentDescription = null)
      Text(stringResource(Res.string.hello, name))
      Text("Branded", fontFamily = FontFamily(Font(Res.font.sora_bold)))
    }
    …
  3. 03

    How do you embed a real native view (MapKit, AVPlayer, a WebView) inside a Compose Multiplatform screen?

    Medium

    Every CMP target has an interop composable that hosts a real platform view inside the Compose tree, so 'we need MapKit here' is never a reason to hand a whole screen back to the platform.

    // Android — host a real MapView inside the Compose tree
    @Composable
    fun MapWidget(center: LatLng) {
      AndroidView(
        factory = { ctx -> MapView(ctx).apply { onCreate(null) } },
        update = { view -> view.moveCamera(center) }
    …
  4. 04

    Which navigation library do you use in Compose Multiplatform, and what must navigation handle on iOS and web that it does not on Android?

    Medium

    AndroidX Navigation Compose is published for every Compose Multiplatform target now and is the default answer — the old advice to reach for a third-party library first is out of date.

    // Type-safe multiplatform navigation — org.jetbrains.androidx.navigation:navigation-compose
    @Serializable object Home
    @Serializable data class Profile(val id: Long)
    
    @Composable
    fun App() {
    …
  5. 05

    CMP draws its own widgets on iOS instead of using UIKit. What does that cost, and when is it a reason not to use CMP?

    Medium

    CMP does not wrap UIKit on iOS — it draws its own widgets with Skia onto a Metal surface, so the app looks like your Compose design system rather than like iOS.

    // The iOS entry point — a plain UIViewController Swift can present anywhere
    fun MainViewController(): UIViewController = ComposeUIViewController { App() }
    
    fun SettingsViewController(): UIViewController =
      ComposeUIViewController { SettingsScreen() }      // adopt screen by screen, not whole-app
    …
  6. 06

    What does adopting Compose Multiplatform actually cost you in tests, binary size and day-to-day tooling?

    Hard

    Shared Compose UI tests really do run on every target, the iOS binary grows by roughly a Skia-sized framework, and the remaining tooling gap is the iOS build loop.

    // commonTest — one UI test, runs on Android, desktop and iOS
    @OptIn(ExperimentalTestApi::class)
    class GreetingTest {
      @Test fun counter_increments() = runComposeUiTest {
        setContent { App() }
        onNodeWithText("Tapped 0 times").performClick()
    …
  7. 07

    A CMP project has one App() composable in common code. What does each platform need in order to put it on screen?

    Medium

    Every target keeps a thin entry point whose only job is to host the shared root composable.

    // commonMain
    @Composable
    fun App(startRoute: String = "home") {
      AppTheme { AppNavHost(startRoute) }
    }
    …
  8. 08

    How do you deal with the notch, the home indicator and the keyboard in shared Compose UI — and how does one layout serve a phone, a tablet and a desktop window?

    Medium

    Compose Multiplatform maps every platform's safe-area and keyboard information onto the same WindowInsets API you already use on Android.

    @Composable
    fun HomeScaffold(content: @Composable () -> Unit) {
      val info = currentWindowAdaptiveInfo()
      val wide = info.windowSizeClass.windowWidthSizeClass != WindowWidthSizeClass.COMPACT
    
      Scaffold(
    …
  9. 09

    What can you realistically ship with Compose Multiplatform on the web, and what still hurts?

    Medium

    Compose for web compiles to WebAssembly and paints the entire page onto a single canvas, which explains both what it is good at and what it is bad at.

    // wasmJsMain/kotlin/main.kt
    fun main() {
      ComposeViewport(document.body!!) {
        App()
      }
    }
    …
  10. 10

    The same shared screen scrolls smoothly on Android and stutters on iOS. How do you approach that?

    Hard

    Start by ruling out the ordinary Compose causes, because most of these turn out not to be iOS-specific at all.

    // ❌ Recomposes the whole list on every scroll pixel
    @Composable
    fun Header(state: LazyListState) {
      val offset = state.firstVisibleItemScrollOffset
      Box(Modifier.offset(y = (-offset / 3).dp)) { Title() }
    }
    …
  11. 11

    You have a shipping SwiftUI app. How do you introduce one Compose Multiplatform screen without rewriting it?

    Hard

    The unit of adoption is a single screen wrapped in a ComposeUIViewController and presented like any other view controller.

    // iosMain — the entire Compose surface the Swift app sees
    fun SettingsViewController(
      onDone: () -> Unit,
      userId: String
    ): UIViewController = ComposeUIViewController {
      AppTheme { SettingsScreen(userId = userId, onDone = onDone) }
    …
  12. 12

    Which Gradle plugins does a Compose Multiplatform module apply, and why can a Kotlin version bump break the build?

    Medium

    Three plugins do the work, and the second one is the reason versions matter so much.

    plugins {
      alias(libs.plugins.kotlinMultiplatform)     // org.jetbrains.kotlin.multiplatform
      alias(libs.plugins.composeCompiler)         // org.jetbrains.kotlin.plugin.compose
      alias(libs.plugins.composeMultiplatform)    // org.jetbrains.compose
      alias(libs.plugins.androidLibrary)
    }
    …
  13. 13

    VoiceOver reads your shared screen as a pile of unlabelled fragments and skips a Canvas-drawn rating entirely — what do you fix?

    Medium

    Compose puts one host view on screen and exports its semantics tree to the platform accessibility API, so anything missing from that tree does not exist for a screen reader.

    // ❌ Drawn, therefore invisible to VoiceOver and TalkBack
    @Composable
    fun Rating(stars: Int) {
      Canvas(Modifier.size(120.dp, 24.dp)) { drawStars(stars) }
    }
    …
  14. 14

    A shared screen needs a real WebView on Android and iOS and a link on desktop — how do you express that without an if (isIOS) in common code?

    Medium

    Two mechanisms, and the choice is about how fixed your target list is: expect/actual on a composable function, or a composable slot the platform entry point fills in.

    // commonMain — the expectation is one function, not a platform type
    @Composable
    expect fun PlatformWebView(url: String, modifier: Modifier = Modifier)
    
    // androidMain — actual repeats no default values
    @Composable
    …
  15. 15

    Your @Preview import does not resolve in commonMain and every tweak to a shared screen costs an Xcode rebuild — how do you set up the iteration loop?

    Medium

    There are two different @Preview annotations, and the fast loop for shared UI runs on the desktop target, not on a device.

    // build.gradle.kts
    plugins {
      id("org.jetbrains.compose.hot-reload")     // desktop/JVM hot reload
    }
    
    kotlin {
    …
  16. 16

    The desktop app renders a black window on a Windows VM and the UI test task hangs on the Linux CI runner — where do you look first?

    Medium

    Compose Desktop draws through Skiko — Skia bound to a GPU pipeline picked at start-up on the user's machine — so both symptoms are the rendering backend, not your layout code.

    // build.gradle.kts — the flag has to reach the app process, not just Gradle
    compose.desktop {
      application {
        mainClass = "MainKt"
        jvmArgs += listOf("-Dskiko.renderApi=OPENGL")   // SOFTWARE_COMPAT on VMs and RDP
        nativeDistributions {
    …
  17. 17

    Shared screen state survives rotation on Android but resets every time SwiftUI shows the Compose screen — where does that state actually live?

    Hard

    A ViewModel lives exactly as long as the ViewModelStoreOwner that created it, and on iOS that owner is the ComposeUIViewController your Swift code builds — rebuild the controller and you get a new store, onCleared() on the old one, and fresh state.

    // commonMain — nothing here is platform-specific
    class OrderViewModel(private val repo: OrderRepo) : ViewModel() {
      val state = repo.orders
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Orders.Empty)
    }
    …
  18. 18

    You have to hand testers a signed .dmg and an .msi of the desktop target — what does the Compose packaging pipeline do, and where does it usually break?

    Hard

    The Gradle plugin drives jlink and jpackage: it builds a trimmed JDK runtime containing only the modules your app declares, bundles it with your jars, and produces a native installer for the OS you are running on.

    compose.desktop {
      application {
        mainClass = "MainKt"
    
        buildTypes.release.proguard {
          obfuscate.set(false)                                   // keep readable stack traces
    …
  19. 19

    Jetpack Compose is described as an Android UI toolkit, so how can the same source render on iOS at all — what are the layers?

    Hard

    Because only one layer of Compose is Android-specific.

    // Proof that the runtime is not a UI toolkit: compose a plain tree, no compose.ui at all.
    class Node(val name: String) {
      val children = mutableListOf<Node>()
    }
    
    class NodeApplier(root: Node) : AbstractApplier<Node>(root) {
    …
  20. 20

    Memory climbs every time a user opens and closes one Compose screen in your iOS app, and Instruments reports no leaks — what is happening?

    Hard

    A reference cycle that crosses the Kotlin/Swift boundary is collected by neither side: Kotlin/Native's GC traces Kotlin objects and can free Kotlin-only cycles, Objective-C and Swift use ARC and cannot, and to ARC every retain in the cycle looks legitimate — so Leaks shows nothing while the screen is immortal.

    // ❌ Registers with the platform, never unregisters — and hands a UIView to a Kotlin singleton
    @Composable
    fun MapPanel(state: MapState) {
      UIKitView(factory = {
        MKMapView().apply {
          setDelegate(MapDelegate(state))   // UIKit retains a Kotlin object...
    …