Kotlin Multiplatform with Compose UI
Kotlin Multiplatform Compose UI development has reached production maturity in 2026, enabling teams to share not just business logic but also UI code across Android, iOS, desktop, and web platforms. JetBrains’ Compose Multiplatform 1.7+ provides a unified declarative UI framework built on the same Compose foundation that Android developers already know, with platform-specific rendering on each target.
This guide covers building a real-world cross-platform application with KMP and Compose Multiplatform, from project setup through shared networking, state management, and platform-specific integrations. Moreover, you will learn when to share UI versus going native, and how to structure your codebase for maximum reuse without sacrificing platform-specific polish.
Why Kotlin Multiplatform in 2026
KMP differs from other cross-platform solutions like Flutter and React Native in a fundamental way: it does not replace native development — it enhances it. You can share as much or as little code as makes sense. Start with shared business logic and networking, then gradually adopt shared UI where it provides value. Furthermore, existing native Android apps can adopt KMP incrementally without rewriting.
Google’s official endorsement of KMP for Android development, combined with JetBrains’ continued investment in Compose Multiplatform, has made this the recommended path for new cross-platform Kotlin projects. Additionally, the KMP ecosystem now includes mature libraries for networking (Ktor), serialization (kotlinx.serialization), database (SQLDelight), and dependency injection (Koin).
Kotlin Multiplatform Compose: Project Structure
my-kmp-app/
├── composeApp/
│ ├── src/
│ │ ├── commonMain/ # Shared code (all platforms)
│ │ │ ├── kotlin/
│ │ │ │ ├── App.kt # Root composable
│ │ │ │ ├── di/ # Dependency injection
│ │ │ │ ├── data/ # Repositories, API clients
│ │ │ │ ├── domain/ # Business logic, models
│ │ │ │ └── ui/ # Shared Compose UI
│ │ │ │ ├── screens/
│ │ │ │ ├── components/
│ │ │ │ └── theme/
│ │ │ └── resources/ # Shared resources
│ │ ├── androidMain/ # Android-specific
│ │ ├── iosMain/ # iOS-specific
│ │ ├── desktopMain/ # Desktop (JVM)
│ │ └── wasmJsMain/ # Web (Wasm)
│ └── build.gradle.kts
├── iosApp/ # iOS Xcode project
├── gradle/
└── build.gradle.kts// composeApp/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.sqldelight)
}
kotlin {
androidTarget()
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
jvm("desktop")
wasmJs { browser() }
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.components.resources)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.sqldelight.coroutines)
}
androidMain.dependencies {
implementation(libs.ktor.client.android)
implementation(libs.sqldelight.android.driver)
implementation(libs.koin.android)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
implementation(libs.sqldelight.native.driver)
}
}
}Shared UI Components
Therefore, Compose Multiplatform lets you write UI once and render it natively on each platform. The key is designing components that adapt to platform conventions while maintaining a consistent look and feel.
// commonMain/kotlin/ui/screens/ProductListScreen.kt
@Composable
fun ProductListScreen(
viewModel: ProductListViewModel = koinViewModel(),
onProductClick: (String) -> Unit
) {
val state by viewModel.state.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Products") },
actions = {
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, "Refresh")
}
}
)
}
) { padding ->
when (val current = state) {
is ProductListState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is ProductListState.Success -> {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(current.products, key = { it.id }) { product ->
ProductCard(
product = product,
onClick = { onProductClick(product.id) }
)
}
}
}
is ProductListState.Error -> {
ErrorView(
message = current.message,
onRetry = { viewModel.refresh() }
)
}
}
}
}
@Composable
fun ProductCard(product: Product, onClick: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(modifier = Modifier.padding(16.dp)) {
AsyncImage(
model = product.imageUrl,
contentDescription = product.name,
modifier = Modifier.size(80.dp).clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = product.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
)
Spacer(Modifier.height(4.dp))
Text(
text = product.category,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(8.dp))
Text(
text = "\$${product.price}",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}Platform-Specific Implementations
Additionally, KMP uses the expect/actual pattern for platform-specific code. This is essential for accessing native APIs like file systems, biometrics, and push notifications.
// commonMain — expect declaration
expect class PlatformContext
expect fun getPlatformName(): String
expect class BiometricAuth(context: PlatformContext) {
suspend fun authenticate(reason: String): BiometricResult
}
// androidMain — actual implementation
actual typealias PlatformContext = android.content.Context
actual fun getPlatformName(): String = "Android"
actual class BiometricAuth actual constructor(
private val context: PlatformContext
) {
actual suspend fun authenticate(reason: String): BiometricResult {
return suspendCancellableCoroutine { continuation ->
val executor = ContextCompat.getMainExecutor(context)
val biometricPrompt = BiometricPrompt(
context as FragmentActivity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: AuthenticationResult) {
continuation.resume(BiometricResult.Success)
}
override fun onAuthenticationFailed() {
continuation.resume(BiometricResult.Failed)
}
}
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Authentication Required")
.setSubtitle(reason)
.setNegativeButtonText("Cancel")
.build()
biometricPrompt.authenticate(promptInfo)
}
}
}
// iosMain — actual implementation
actual typealias PlatformContext = Unit
actual fun getPlatformName(): String = "iOS"
actual class BiometricAuth actual constructor(
private val context: PlatformContext
) {
actual suspend fun authenticate(reason: String): BiometricResult {
return suspendCancellableCoroutine { continuation ->
val laContext = LAContext()
laContext.evaluatePolicy(
LAPolicyDeviceOwnerAuthenticationWithBiometrics,
localizedReason = reason
) { success, error ->
if (success) continuation.resume(BiometricResult.Success)
else continuation.resume(BiometricResult.Failed)
}
}
}
}When NOT to Use Kotlin Multiplatform
If your app is heavily platform-specific — using ARKit on iOS, custom Android widgets, or deep OS integration — KMP adds a layer of abstraction without significant code sharing benefits. Furthermore, teams with strong native iOS expertise (Swift/SwiftUI) may find the Kotlin learning curve and tooling overhead counterproductive for iOS development.
KMP’s iOS compilation uses Kotlin/Native which has different performance characteristics than JVM Kotlin. Memory-intensive operations and real-time processing may require careful optimization. The debugging experience on iOS also lags behind native Xcode development.
Key Takeaways
Kotlin Multiplatform Compose UI enables pragmatic code sharing across Android, iOS, desktop, and web without abandoning native development practices. The expect/actual pattern provides clean platform abstraction, while Compose Multiplatform delivers a unified UI framework. Furthermore, KMP’s incremental adoption model means you can start with shared business logic and gradually expand to shared UI as your team gains confidence.
Start with a new feature module rather than rewriting an existing app. For comprehensive documentation, visit the Kotlin Multiplatform official site and the Compose Multiplatform documentation. Our guides on Jetpack Compose performance and Flutter Impeller rendering provide additional mobile development perspectives.