In the previous tutorials, we shared business logic between Android and iOS. The UI stayed separate — Compose on Android, SwiftUI on iOS.

But what if you could share the UI too?

That is what Compose Multiplatform (CMP) does. Write your Compose code once. Run it on Android, iOS, Desktop, and Web. Same @Composable functions. Same Modifier chains. Same MaterialTheme.

If you followed our Jetpack Compose tutorial series, you already know how to build Compose UI. CMP uses the exact same API. No new framework to learn.

What is Compose Multiplatform?

Compose Multiplatform is a UI framework by JetBrains that extends Jetpack Compose beyond Android. It lets you write Compose code in commonMain that compiles and renders on every platform.

Traditional KMP:
  commonMain    → shared logic (networking, database, models)
  androidMain   → Jetpack Compose UI (Android only)
  iosApp/       → SwiftUI (iOS only)

With Compose Multiplatform:
  commonMain    → shared logic AND shared UI
  androidMain   → Android entry point
  iosMain       → iOS entry point (renders the same Compose UI)
  desktopMain   → Desktop entry point (renders the same Compose UI)

The key insight: your @Composable functions in commonMain work everywhere. Column, Row, Text, Button, LazyColumn, MaterialTheme — all of them.

Platform Status in 2026

PlatformStatusRendering
AndroidStableNative (same as Jetpack Compose)
iOSStableSkia/Canvas (looks and feels native)
DesktopStableJVM + Skia
WebBetaKotlin/Wasm + Canvas

iOS used to be experimental. In 2026, it is stable and production-ready. Companies like Cash App and others ship CMP to iOS.

How Does It Render on iOS?

This is the question everyone asks. On Android, Compose renders natively — it IS Android’s UI framework. But on iOS?

CMP on iOS uses a canvas-based renderer (Skia). It draws every pixel itself, similar to how Flutter works. This means:

  • Your UI looks the same on every platform — pixel-identical if you want
  • Animations are smooth — 60fps rendering
  • Some iOS conventions differ — scrolling physics, navigation gestures might feel slightly different from native SwiftUI
  • Platform integration is possible — you can embed native iOS views inside CMP and vice versa

For most apps, the difference is not noticeable. For apps that need to feel 100% native iOS (following every Apple HIG detail), you might want SwiftUI for the iOS UI and share only logic with KMP.

Setting Up Compose Multiplatform

If you created your project with the KMP Wizard and selected “Share UI with Compose Multiplatform,” you already have CMP set up. If not, here is what you need:

Gradle Configuration

In your composeApp/build.gradle.kts:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.android.application)
    alias(libs.plugins.compose.multiplatform)
    alias(libs.plugins.compose.compiler)
}

kotlin {
    androidTarget()
    iosX64()
    iosArm64()
    iosSimulatorArm64()
    jvm("desktop")

    sourceSets {
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.ui)
            implementation(compose.components.resources)
        }

        androidMain.dependencies {
            implementation(libs.androidx.activity.compose)
        }

        val desktopMain by getting
        desktopMain.dependencies {
            implementation(compose.desktop.currentOs)
        }
    }
}

The compose.runtime, compose.foundation, compose.material3 — these are the multiplatform versions of the same Compose libraries you already use on Android.

Your First Shared Composable

Create composeApp/src/commonMain/kotlin/App.kt:

// commonMain — this runs on Android, iOS, AND Desktop

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun App() {
    MaterialTheme {
        var count by remember { mutableStateOf(0) }

        Column(
            modifier = Modifier.fillMaxSize().padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Text(
                text = "Count: $count",
                style = MaterialTheme.typography.headlineLarge
            )

            Spacer(modifier = Modifier.height(16.dp))

            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                Button(onClick = { count-- }) { Text("-") }
                Button(onClick = { count++ }) { Text("+") }
            }

            Spacer(modifier = Modifier.height(8.dp))

            OutlinedButton(onClick = { count = 0 }) {
                Text("Reset")
            }
        }
    }
}

This is a regular Compose function. Column, Row, Button, Text, MaterialTheme — everything you already know from our Jetpack Compose tutorials. The only difference: this code is in commonMain, so it compiles for every platform.

Android Entry Point

// composeApp/src/androidMain/kotlin/MainActivity.kt

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            App() // The shared Composable
        }
    }
}

iOS Entry Point

// composeApp/src/iosMain/kotlin/MainViewController.kt

import androidx.compose.ui.window.ComposeUIViewController

fun MainViewController() = ComposeUIViewController { App() }

The iOS app calls ComposeUIViewController which wraps your Compose UI in a UIKit view controller that Xcode can display.

Desktop Entry Point

// composeApp/src/desktopMain/kotlin/Main.kt

import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application

fun main() = application {
    Window(
        onCloseRequest = ::exitApplication,
        title = "My KMP App"
    ) {
        App() // Same shared Composable
    }
}

Three entry points. One shared UI. Same App() function everywhere.

Building Real Shared Screens

Shared User List Screen

// commonMain — works on Android, iOS, Desktop

data class User(val id: String, val name: String, val email: String)

@Composable
fun UserListScreen() {
    val users = remember {
        listOf(
            User("1", "Alex", "alex@example.com"),
            User("2", "Sam", "sam@example.com"),
            User("3", "Jordan", "jordan@example.com"),
            User("4", "Taylor", "taylor@example.com"),
        )
    }
    var searchQuery by remember { mutableStateOf("") }

    val filtered = users.filter {
        it.name.contains(searchQuery, ignoreCase = true) ||
            it.email.contains(searchQuery, ignoreCase = true)
    }

    Column(modifier = Modifier.fillMaxSize()) {
        Text(
            "Users",
            style = MaterialTheme.typography.headlineLarge,
            modifier = Modifier.padding(16.dp)
        )

        OutlinedTextField(
            value = searchQuery,
            onValueChange = { searchQuery = it },
            label = { Text("Search") },
            modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
        )

        Spacer(modifier = Modifier.height(8.dp))

        LazyColumn(
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(filtered.size) { index ->
                val user = filtered[index]
                UserCard(user)
            }
        }
    }
}

@Composable
fun UserCard(user: User) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        shape = RoundedCornerShape(12.dp),
        color = MaterialTheme.colorScheme.surfaceVariant
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(user.name, style = MaterialTheme.typography.titleMedium)
            Text(
                user.email,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }
}

This screen — search bar, user list, cards — runs identically on Android, iOS, and Desktop. One codebase.

Multiplatform Resources

Your app needs images, strings, and fonts. CMP has a built-in resource system:

Adding Images

Put images in composeApp/src/commonMain/composeResources/drawable/:

composeApp/src/commonMain/composeResources/
├── drawable/
│   ├── logo.png
│   └── background.webp
├── values/
│   └── strings.xml
└── font/
    └── custom_font.ttf

Use them in code:

import kmptutorial.composeapp.generated.resources.Res
import kmptutorial.composeapp.generated.resources.logo
import org.jetbrains.compose.resources.painterResource

@Composable
fun LogoImage() {
    Image(
        painter = painterResource(Res.drawable.logo),
        contentDescription = "App logo",
        modifier = Modifier.size(100.dp)
    )
}

The Res class is auto-generated. It provides type-safe access to your resources on every platform.

Adding Strings

Create composeApp/src/commonMain/composeResources/values/strings.xml:

<resources>
    <string name="app_name">My KMP App</string>
    <string name="welcome_message">Welcome to Compose Multiplatform</string>
    <string name="search_hint">Search users...</string>
</resources>

Use them:

import kmptutorial.composeapp.generated.resources.Res
import kmptutorial.composeapp.generated.resources.welcome_message
import org.jetbrains.compose.resources.stringResource

@Composable
fun WelcomeText() {
    Text(stringResource(Res.string.welcome_message))
}

Platform-Specific UI in CMP

Sometimes you need slightly different UI per platform. Use expect/actual for Composables:

// commonMain — the shared interface
@Composable
expect fun PlatformSpecificButton(text: String, onClick: () -> Unit)

// androidMain — Material button
@Composable
actual fun PlatformSpecificButton(text: String, onClick: () -> Unit) {
    Button(onClick = onClick) { Text(text) }
}

// iosMain — could use a different style
@Composable
actual fun PlatformSpecificButton(text: String, onClick: () -> Unit) {
    // Use a different button style for iOS if needed
    Button(
        onClick = onClick,
        shape = RoundedCornerShape(50)  // More iOS-like rounded button
    ) { Text(text) }
}

Or check the platform at runtime:

enum class Platform { Android, IOS, Desktop }

expect val currentPlatform: Platform

@Composable
fun AdaptiveLayout(content: @Composable () -> Unit) {
    when (currentPlatform) {
        Platform.Android -> {
            // Android-specific layout adjustments
            Scaffold { padding ->
                Box(modifier = Modifier.padding(padding)) { content() }
            }
        }
        Platform.IOS -> {
            // iOS might need different safe area handling
            Box(modifier = Modifier.padding(top = 44.dp)) { content() }
        }
        Platform.Desktop -> {
            // Desktop gets wider layout
            Box(modifier = Modifier.widthIn(max = 800.dp)) { content() }
        }
    }
}

CMP vs Native UI: When to Use Which

Compose MultiplatformNative UI per Platform
Code sharing90-100% (including UI)40-60% (logic only)
Development speedFaster (one UI codebase)Slower (two UI codebases)
Native look and feelGood (Material 3)Perfect (SwiftUI follows Apple HIG)
iOS-specific featuresPossible with expect/actualNative
Team skills neededKotlin onlyKotlin + Swift
Best forMVPs, internal tools, cross-platform consistencyConsumer apps needing 100% native feel

Use CMP When:

  • You want to ship fast with one team
  • Visual consistency across platforms matters more than native feel
  • Your app is data-driven (lists, forms, dashboards)
  • You are building an MVP or internal tool
  • Your team only knows Kotlin

Use Native UI (Compose + SwiftUI) When:

  • Your iOS app must follow Apple HIG exactly
  • You need complex iOS-specific features (widgets, app clips, ShareSheet)
  • You have both Kotlin and Swift developers
  • Your users expect a platform-native experience
  • You are building a consumer-facing app in a competitive market

The Hybrid Approach

Many teams use both. Share 80% of the UI with CMP and override 20% with native views for platform-specific screens (settings, permissions, native pickers).

Common Mistakes

Mistake 1: Using Android-Only Compose APIs

// BAD — this only exists in Android Compose
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.activity.compose.BackHandler  // Android only!

// GOOD — check if the API exists in CMP
// Most Material 3 APIs work in CMP
// BackHandler has a CMP equivalent

Not all Jetpack Compose APIs are available in CMP. Most Material 3 components work. Some Android-specific APIs (like BackHandler, some navigation APIs) need CMP alternatives.

Mistake 2: Hardcoding Screen Sizes

// BAD — assumes phone screen
Box(modifier = Modifier.width(360.dp))

// GOOD — adapts to any screen
Box(modifier = Modifier.fillMaxWidth().widthIn(max = 600.dp))

CMP runs on phones, tablets, and desktops. Design for flexibility.

Mistake 3: Ignoring Platform Differences

// BAD — scrollbar visible on desktop, hidden on mobile
LazyColumn { ... }

// GOOD — adjust for platform differences
// On desktop: add mouse scrollbar support
// Note: never use verticalScroll on LazyColumn — it causes a crash
val lazyListState = rememberLazyListState()
LazyColumn(state = lazyListState) {
    // Desktop scrollbar can be added via VerticalScrollbar composable
    // Mobile handles scrolling natively
}

Desktop users expect scrollbars, keyboard shortcuts, and mouse hover states. Mobile users expect swipe gestures and touch targets.

Quick Summary

ConceptWhat It Means
Compose MultiplatformWrite Compose UI once, run on Android/iOS/Desktop/Web
commonMainShared Composables that compile everywhere
composeResourcesShared images, strings, fonts across platforms
Platform entriesMainActivity (Android), MainViewController (iOS), main() (Desktop)
Same APIColumn, Row, LazyColumn, MaterialTheme — all work
StatusStable for Android, iOS, Desktop. Beta for Web

Source Code

Note: The base kmp-tutorial repo uses shared logic only (no shared UI). To follow this tutorial, create a new project with the KMP Wizard and select “Share UI with Compose Multiplatform”.

View base project on GitHub → (shared logic setup — tutorials #1-3)

What’s Next?

In the next tutorial, we will compare KMP vs Flutter vs React Native in detail — architecture, performance, ecosystem, and when to choose each. This will help you explain your technology choice to your team or manager.

See you there.