Compose Multiplatform Development for Cross-Platform Apps
Compose Multiplatform development extends Jetpack Compose beyond Android to iOS, desktop, and web platforms with a single shared UI codebase. Therefore, teams write composable functions once and deploy them across every major platform without sacrificing native performance. As a result, development velocity improves substantially while maintaining platform-specific experiences where they matter most. Moreover, because the rendering engine is Skia rather than a JavaScript bridge, the visual result is pixel-identical across targets.
Shared UI Architecture
The framework builds on Kotlin Multiplatform to share not just business logic but the entire UI layer. Specifically, composable functions use the same declarative API regardless of the target platform, with platform-specific rendering handled by the framework. Moreover, the compiler generates optimized code for each platform rather than relying on a cross-platform rendering bridge at runtime.
This approach differs fundamentally from React Native and Flutter. However, unlike React Native, it does not marshal UI updates across a JavaScript-to-native bridge. Instead, on Android it compiles to JVM bytecode, on iOS it compiles to native ARM through Kotlin/Native, and on desktop it runs on the JVM. Consequently, UI performance closely matches hand-written native implementations while the developer experience stays unified.
Shared composable functions render natively on each target platform
Setting Up a Multiplatform Module
A practical project starts with the Kotlin Multiplatform Gradle plugin and a Compose plugin applied to a shared module. Crucially, the source set hierarchy defines what code is common and what is platform-specific. Below is a representative build.gradle.kts that targets Android, iOS, and desktop simultaneously.
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
id("com.android.library")
}
kotlin {
androidTarget()
jvm("desktop")
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "shared"
isStatic = true
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}
}
val androidMain by getting
val iosMain by creating { dependsOn(commonMain) }
val desktopMain by getting
}
}
Notice that commonMain declares the Compose dependencies once. Therefore, the same Material 3 components compile against every target without per-platform duplication. Additionally, the iOS framework is produced as a static binary that Xcode consumes directly, so the Swift side simply imports shared and embeds a Compose view controller.
Building Shared Composables with Expect/Actual
The expect/actual mechanism bridges platform differences while keeping the API surface consistent. Additionally, expect declarations define the interface in common code while actual implementations provide platform-specific behavior. For example, file system access, camera integration, and biometric authentication use this pattern extensively.
// commonMain — shared composable
@Composable
fun ProductListScreen(viewModel: ProductViewModel) {
val products by viewModel.products.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text(
text = "Products",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 12.dp)
)
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(products) { product ->
ProductCard(product = product, onClick = { viewModel.select(product) })
}
}
}
}
}
// commonMain — expect declaration
expect class PlatformContext
expect fun getPlatformName(): String
// androidMain — actual implementation
actual typealias PlatformContext = android.content.Context
actual fun getPlatformName(): String = "Android " + android.os.Build.VERSION.SDK_INT
// iosMain — actual implementation
actual class PlatformContext
actual fun getPlatformName(): String = "iOS " + UIDevice.currentDevice.systemVersion
This pattern keeps platform logic isolated. Therefore, common code remains testable and platform-agnostic while actual declarations handle native integration points. As a rule of thumb, keep the expect surface small: expose a clean interface and push as much logic as possible into common code so you write platform glue only where the OS genuinely differs.
Navigation and State Management
Cross-platform navigation requires careful abstraction since each platform has different navigation paradigms. Furthermore, libraries like Voyager and Decompose provide multiplatform navigation with back stack management, deep linking, and lifecycle awareness. Specifically, Decompose uses a component-based architecture that separates navigation logic from UI rendering, which makes the back stack survive process death on Android and integrate with the iOS lifecycle.
State management follows the standard Kotlin Flow and StateFlow patterns across all platforms. Meanwhile, ViewModel-like components hold UI state and survive configuration changes on Android while providing consistent behavior on iOS and desktop. Notably, JetBrains now ships a multiplatform ViewModel in the Lifecycle libraries, so the same component contract works everywhere.
Shared navigation and state management across Android, iOS, and desktop
Compose Multiplatform Platform Integration and Native APIs
Real-world applications need access to platform-specific APIs for cameras, sensors, notifications, and storage. Additionally, Kotlin Multiplatform’s interoperability with Swift and Objective-C allows calling iOS frameworks directly from shared code. For example, Core Location on iOS and FusedLocationProvider on Android integrate through expect/actual declarations behind a common LocationProvider interface.
Desktop targets access JVM libraries and native system APIs through the standard Java ecosystem. Moreover, web targets compile to either WebAssembly through Kotlin/Wasm or to JavaScript through Kotlin/JS, enabling the same composables to render in the browser with a canvas-based pipeline. The Wasm path is the faster-moving option and increasingly the recommended one for new web work.
Platform-specific APIs integrate through expect/actual declarations
Interoperating with SwiftUI and Existing Native Screens
Adoption is rarely all-or-nothing. In practice, teams embed a single Compose screen inside an existing SwiftUI or UIKit app and expand from there. On iOS, a Compose hierarchy is wrapped in a UIViewController that SwiftUI hosts through UIViewControllerRepresentable.
import SwiftUI
import shared // the Kotlin framework
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
// MainViewControllerKt is generated from a Kotlin function
// that returns ComposeUIViewController { ProductListScreen(...) }
return MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ vc: UIViewController, context: Context) {}
}
struct ContentView: View {
var body: some View {
ComposeView()
.ignoresSafeArea(.keyboard) // let Compose handle insets
}
}
This bidirectional interop matters because it lets you keep platform-critical screens native — payment sheets, map views, or anything with strict Human Interface Guidelines expectations — while sharing the bulk of the application. Consequently, migration becomes incremental rather than a risky big-bang rewrite.
When NOT to Use It and Honest Trade-offs
Despite its strengths, this framework is not the right answer for every project. First, iOS support reached stable status only recently, so the ecosystem of third-party multiplatform libraries is younger than Flutter’s or React Native’s; you will sometimes write expect/actual glue for things that ship out of the box elsewhere. Second, because the UI is drawn with Skia rather than using native UIKit widgets, fine-grained iOS platform behaviors — text selection menus, accessibility nuances, system-level animations — can require extra attention to feel fully native.
Additionally, app binary size grows because the Skia renderer and Kotlin/Native runtime ship with the app, which can matter for size-sensitive markets. If your team is primarily web-focused with deep React expertise, the learning curve of Kotlin, Gradle, and the Compose mental model may outweigh the sharing benefits. Likewise, an app that is mostly platform-specific screens with little shared UI gains little from a shared rendering layer. By contrast, the sweet spot is a content-and-forms-heavy product with substantial shared business logic across Android and iOS, where a single team can own the whole stack.
Related Reading:
Further Resources:
In conclusion, Compose Multiplatform development enables true cross-platform UI sharing with native-grade performance on every target. Therefore, adopt this framework when building applications that target Android, iOS, desktop, and web from a single Kotlin codebase, while staying realistic about ecosystem maturity and binary size on the platforms that matter most to you.