Kotlin Multiplatform with Compose UI: Sharing Logic and UI Across Platforms

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).

Mobile app development across platforms
Sharing code across Android, iOS, desktop, and web with KMP

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
                )
            }
        }
    }
}
Cross-platform mobile UI development
Shared Compose UI rendering natively on each platform

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.

Mobile devices and cross-platform apps
Choosing the right cross-platform strategy for your team

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.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top