From Shared Logic to Shared UI
If you’ve been following the Kotlin Multiplatform story, you’ve probably already shared networking with Ktor in a KMP shared module and persistence with SQLDelight across platforms. The next logical question is: can you share the UI too? With Compose Multiplatform, the answer in 2026 is a confident yes.
JetBrains’ Compose Multiplatform builds on top of Jetpack Compose and extends it to iOS, desktop, and web. For Android developers, the learning curve is nearly zero — you already know the API. The difference is that your Composables now render natively on iOS through a Skia-based rendering engine, and you can share 80-90% of your UI code across both platforms.
Setting Up a Compose Multiplatform Project
The fastest way to start is the Kotlin Multiplatform Wizard from JetBrains. Select Android and iOS targets, check the “Share UI with Compose Multiplatform” option, and download the project. You’ll get a structure like this:
project/
composeApp/
src/
commonMain/ # Shared Compose UI
androidMain/ # Android-specific code
iosMain/ # iOS-specific code
iosApp/ # Xcode project wrapper
The key directory is commonMain. Everything you put there compiles for both platforms. Platform-specific code goes into androidMain or iosMain, using the expect/actual mechanism you’re already familiar with.
Your build.gradle.kts for the shared module looks like this:
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
androidTarget()
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
}
}
}
Writing Your First Shared Screen
Let’s build a simple profile screen that runs on both platforms. In commonMain, create a Composable just like you would for a pure Android app:
@Composable
fun ProfileScreen(user: User) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AsyncImage(
model = user.avatarUrl,
contentDescription = "Profile photo",
modifier = Modifier
.size(120.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = user.displayName,
style = MaterialTheme.typography.headlineMedium
)
Text(
text = user.bio,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { /* navigate to edit */ }) {
Text("Edit Profile")
}
}
}
This is standard Compose code. The MaterialTheme, Column, Text, Button — all of it works identically on iOS. For image loading, the Compose Multiplatform ecosystem offers libraries like Coil 3, which has first-class KMP support and works with AsyncImage across all targets.
Handling Platform Differences
Not everything can be shared. iOS has its own status bar behavior, navigation patterns, and system dialogs. Compose Multiplatform handles this through the same expect/actual pattern. Here’s a common example — getting the platform-specific status bar padding:
// In commonMain expect fun getPlatformInsets(): PaddingValues
// In androidMain
actual fun getPlatformInsets(): PaddingValues {
return WindowInsets.systemBars.asPaddingValues()
}
// In iosMain
actual fun getPlatformInsets(): PaddingValues {
return WindowInsets.safeArea.asPaddingValues()
}
In practice, you’ll find that about 10-20% of your UI needs these platform-specific adjustments. Navigation is the biggest one. While you can use Voyager or Decompose for cross-platform navigation, each library has trade-offs around lifecycle handling and deep linking that are worth evaluating for your specific app.
Resources and Images Across Platforms
Compose Multiplatform includes a resource system that works across targets. Place images, strings, and fonts in commonMain/composeResources and access them with the generated Res object:
@Composable
fun AppLogo() {
Image(
painter = painterResource(Res.drawable.app_logo),
contentDescription = "App logo"
)
Text(text = stringResource(Res.string.welcome_message))
}
This replaces the Android-specific R class with a multiplatform equivalent. String localization, drawable variants, and font loading all work through this system. It’s one of the areas where Compose Multiplatform has matured significantly — early versions required much more manual wiring.
State Management in Shared UI
The remember function, mutableStateOf, derivedStateOf, and snapshotFlow are all part of the shared Compose runtime.
For ViewModel-level state, the community has converged on a few approaches. You can use KMP-ViewModel by Rickclephas, which wraps Android’s ViewModel for use in shared code, or you can go with a simpler pattern using plain classes with StateFlow:
class ProfileViewModel(
private val userRepository: UserRepository
) {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow = _uiState.asStateFlow()
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun loadProfile(userId: String) {
scope.launch {
_uiState.update { it.copy(isLoading = true) }
val user = userRepository.getUser(userId)
_uiState.update { it.copy(user = user, isLoading = false) }
}
}
fun onCleared() {
scope.cancel()
}
}
This pattern gives you full control over the coroutine scope and doesn’t depend on Android’s ViewModel lifecycle. On Android, you can wrap it in an actual ViewModel for lifecycle awareness. On iOS, you manage the scope manually or tie it to the SwiftUI view lifecycle.
What’s Production-Ready and What’s Not
After shipping a Compose Multiplatform feature to production, here’s an honest assessment of where things stand in 2026:
Production-ready: basic UI components (text, buttons, lists, images), Material 3 theming, resource management, navigation with third-party libraries, state management, and animations. Most of what you build day-to-day in Compose works ok.
Needs care: accessibility on iOS is improving but not yet at parity with native SwiftUI. Complex text input (rich text editors, multi-language IME) can have edge cases. Performance on older iOS devices with heavy lists may need profiling — the Skia rendering path is fast but not free.
Still emerging: deep platform integrations (widgets, App Clips, watchOS) still require native code. If your app is heavily tied to platform-specific APIs, you’ll have a thinner shared layer.
The sweet spot is apps where the core UI is forms, lists, detail screens, and dashboards — which describes most business apps. If you’re already sharing logic with KMP, adding shared UI with Compose Multiplatform is the natural next step. Start with one feature screen, prove it works, and expand from there.
This post was written by a human with the help of Claude, an AI assistant by Anthropic.
