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. Rather than forcing a single lowest-common-denominator widget set, it renders through Skia on iOS and desktop while delegating to the native Compose runtime on Android.
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. The patterns below reflect how production teams typically stage adoption, not a green-field rewrite.
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 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)
}
}
}
Sharing UI with Kotlin Multiplatform Compose UI
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. In practice, teams keep stateless composables in commonMain and push anything that touches a platform widget — a system share sheet, a date picker, a map view — behind an interface.
// 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
)
}
}
}
}
State Management and the Shared ViewModel
The single biggest source of shared value is not the UI tree but the state that drives it. A common pattern is to model each screen as an immutable sealed state and expose it through a StateFlow, so that Android, iOS, and desktop all observe the same source of truth. Because collectAsState works identically on every target, the rendering layer becomes a thin function of state.
JetBrains’ androidx.lifecycle ViewModel artifacts are now multiplatform, so a single ViewModel subclass can live in commonMain. Consequently, you write the loading, retry, and error transitions once and never reimplement them per platform. The example below shows a ViewModel that survives configuration changes on Android and maps cleanly onto a SwiftUI @StateObject wrapper on iOS.
// commonMain — one ViewModel, every platform
class ProductListViewModel(
private val repository: ProductRepository
) : ViewModel() {
private val _state = MutableStateFlow<ProductListState>(ProductListState.Loading)
val state: StateFlow<ProductListState> = _state.asStateFlow()
init { refresh() }
fun refresh() {
viewModelScope.launch {
_state.value = ProductListState.Loading
_state.value = try {
ProductListState.Success(repository.getProducts())
} catch (e: IOException) {
ProductListState.Error("Network unavailable. Pull to retry.")
}
}
}
}
sealed interface ProductListState {
data object Loading : ProductListState
data class Success(val products: List<Product>) : ProductListState
data class Error(val message: String) : ProductListState
}
Offline-First Data with SQLDelight and Ktor
Production apps rarely tolerate a blank screen on a flaky network. A robust pattern is to treat the local SQLDelight database as the single read source and let the network layer write into it. The repository emits from the database as a Flow, so the UI updates automatically whenever a sync completes. This keeps the screen responsive even when the API call is slow or fails entirely.
// commonMain — offline-first repository
class ProductRepository(
private val api: ProductApi,
private val db: AppDatabase
) {
// UI observes the cache; network refreshes it in the background
fun observeProducts(): Flow<List<Product>> =
db.productQueries.selectAll().asFlow().mapToList(Dispatchers.IO)
suspend fun sync() {
val remote = api.fetchProducts() // Ktor call, suspends
db.transaction {
remote.forEach { db.productQueries.upsert(it.id, it.name, it.price) }
}
}
}
Notice that none of this code is platform-aware. The SQLite driver differs per target — AndroidSqliteDriver, NativeSqliteDriver, a JDBC driver on desktop — but those are wired up once in the dependency-injection module. Therefore the repository, queries, and Flow operators are written a single time and exercised by your shared tests.
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)
}
}
}
}
Interop and the iOS Developer Experience
Sharing UI is only half the integration story; the other half is how the Kotlin framework is consumed from Swift. Compose Multiplatform exposes a UIViewController through ComposeUIViewController { App() }, which you embed in SwiftUI with a thin UIViewControllerRepresentable wrapper. As a result, an iOS team can host a fully shared screen inside an otherwise native SwiftUI app, or mix native and shared screens within the same navigation stack.
There are real seams to manage, however. Kotlin suspend functions surface to Swift as completion-handler callbacks rather than async/await unless you wrap them, and Kotlin Flow needs a bridge such as SKIE or a small adapter to feel idiomatic in Swift. Plan for a thin Swift-side adapter layer; without it, the interop surface leaks Kotlin idioms into your Swift codebase and erodes the polish you were trying to preserve.
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. In those situations, sharing only the domain and networking layers — and writing each UI natively — is often the more honest trade-off.
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, and the new memory manager, while a major improvement, still behaves differently from ARC. The debugging experience on iOS also lags behind native Xcode development, and binary size grows because the Kotlin runtime ships inside the framework. For a small marketing app or a heavily ARKit-driven experience, that overhead rarely pays for itself.
Key Takeaways
Kotlin Multiplatform 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, the incremental adoption model means you can start with shared business logic and gradually expand to shared UI as your team gains confidence.
Key Takeaways
- Start with a solid foundation and build incrementally based on your requirements
- Test thoroughly in staging before deploying to production environments
- Monitor performance metrics and iterate based on real-world data
- Follow security best practices and keep dependencies up to date
- Document architectural decisions for future team members
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, Kotlin Multiplatform mobile, and Flutter Impeller rendering provide additional mobile development perspectives.
In conclusion, Kotlin Multiplatform Compose UI is an essential topic for modern software development. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.