Back to portfolio

Guide · Updated 2026-06-08

Compose Multiplatform iOS: UI Guide

A tutorial on sharing UI across Android and iOS with Compose Multiplatform. Covers platform patterns and performance tips.

What is Compose Multiplatform?

Compose Multiplatform is JetBrains' extension of Jetpack Compose that lets you share UI code across Android, iOS, desktop, and web. Unlike Kotlin Multiplatform (KMP) alone — which only shares business logic — Compose Multiplatform pushes shared code all the way up to the screen layer.

On Android, it uses the standard Jetpack Compose runtime. On iOS, it renders Compose into a native UIView hierarchy using Skia under the hood. The result feels native: 60 fps scrolling, proper text rendering, and access to platform gestures.

iOS support is currently in Beta. It is production-viable for internal tools, MVPs, and teams willing to ship a shared UI layer. For conservative iOS projects, the safer pattern is KMP for logic + SwiftUI for UI.

Project setup

Start with the JetBrains KMP wizard and select Compose Multiplatform for iOS. The resulting structure looks like this:

composeApp/
  commonMain/
    App.kt              # shared root composable
  androidMain/
    MainActivity.kt     # setContent { App() }
  iosMain/
    MainViewController.kt # ComposeUIViewController { App() }

The commonMain module holds your shared UI. Platform entry points are thin: Android calls setContent and iOS returns a ComposeUIViewController.

Writing shared UI that respects both platforms

The biggest mistake is pretending iOS and Android are identical. Shared composables should be adaptive, not identical. Here's a pattern I use for platform branching:

// commonMain
expect interface Platform {
    val name: String
    val isIos: Boolean
}

@Composable
expect fun currentPlatform(): Platform

@Composable
fun AdaptiveTopBar(title: String) {
    val platform = currentPlatform()
    if (platform.isIos) {
        IosTopBar(title)   // smaller, centered title + back chevron
    } else {
        AndroidTopBar(title) // Material3 TopAppBar
    }
}

Keep branching shallow: share the layout structure, but swap the leaf components that users actually touch. Buttons, navigation bars, and pickers are where platform conventions differ most.

Handling iOS-specific UI patterns

  • Safe areas and notches. Use WindowInsets on Android and wrap your root iOS composable with a platform-specific modifier that reads UIApplication.shared.keyWindow?.safeAreaInsets and applies padding.
  • Navigation. iOS prefers edge-swipe back gestures and stacked navigation. On iOS, I use a thin wrapper around UINavigationController or a custom transition that mimics the iOS push/pop feel. On Android, Jetpack Navigation with Compose handles the back stack natively.
  • Lists. iOS users expect rubber-band overscroll and swipe-to-delete with a reveal action. Compose Multiplatform's LazyColumn works on both platforms, but I inject iOS overscroll behavior via an expect/actual modifier.
  • Pickers and sheets. iOS date pickers are typically bottom sheets with wheels. Rather than forcing a Material3 date picker on iOS, I branch the component and show a native iOS bottom sheet via interop when the user triggers date selection.

Interop: embedding native iOS views

When Compose Multiplatform can't do what you need — maps, camera previews, complex charts — drop down to native UI with UIKitView (or AndroidView on Android):

// iosMain
@Composable
actual fun NativeMapView(modifier: Modifier) {
    UIKitView(
        factory = { MKMapView() },
        modifier = modifier,
        update = { mapView ->
            mapView.showsUserLocation = true
        }
    )
}

Interop is powerful but expensive: every UIKitView is a separate native view in the hierarchy, so compositing overhead adds up. Use it for large, self-contained surfaces (maps, video, WebRTC) and prefer pure Compose for lists, forms, and text.

Performance on iOS

  • Startup time. Compose Multiplatform on iOS initializes Skia and the Compose runtime on first frame. Keep your root composable small and defer heavy view models until after the first composition.
  • Binary size. Skia adds several MB to the iOS bundle. Strip debug symbols and use app thinning / bitcode stripping to minimize the impact.
  • Text rendering. Skia handles text shaping, but complex scripts and dynamic type can lag. Test with large paragraphs and accessibility font sizes early.
  • Recomposition. The same rules apply as on Android: hoist state, use remember and derivedStateOf aggressively, and avoid reading rapidly changing state in the body of large composables.

State management: shared ViewModels

The cleanest architecture I've shipped pairs Compose Multiplatform UI with KMP ViewModels in commonMain. The UI layer stays thin; all business logic lives in shared Kotlin:

// commonMain — shared ViewModel
class ProfileViewModel : ViewModel() {
    private val _state = MutableStateFlow(ProfileState())
    val state: StateFlow<ProfileState> = _state.asStateFlow()

    fun load(userId: String) {
        viewModelScope.launch {
            _state.value = repo.fetch(userId)
        }
    }
}

// commonMain — shared composable
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = koinViewModel()) {
    val state by viewModel.state.collectAsState()
    // UI is identical on Android and iOS
}

For iOS, expose the StateFlow via a thin observable wrapper or useSKIE to generate Swift-friendly async/await and Flow bindings.

Real-world UI sharing strategies

There is a spectrum from "fully shared" to "fully native." Here is how I think about each point on that spectrum:

  • Design system + shared screens (80% shared). Colors, typography, spacing, and most screens live in commonMain. Platform teams only customize navigation, top bars, and a handful of platform-specific components. Best for teams with strong Kotlin skills and product managers who want consistent branding.
  • Shared design system, native screens (50% shared). Shared tokens and low-level components (buttons, cards, inputs), but each platform builds its own screen composition. Best when each platform has dedicated designers who insist on HIG / Material fidelity.
  • Shared logic, native UI (0% shared UI). KMP only. You still get the article benefit — just not this one. This is the safest production choice today if your iOS team is large or risk-averse.

When to choose Compose Multiplatform over native UI

Choose Compose Multiplatform when: you have more Kotlin engineers than iOS engineers, your app is UI-heavy but not deeply tied to Apple-specific frameworks, you want to prototype fast on both platforms, or your brand demands pixel-perfect consistency across Android and iOS.

Stick with native UI when: your iOS team is larger and prefers SwiftUI, you rely heavily on iOS SDK features (Live Activities, Widgets, App Clips), or you need Apple Design Award-level polish that only native controls provide.

Further reading