Kotlin Multiplatform: 7 Patterns for Truly Shared UIs
Practical Compose Multiplatform techniques to ship one UI codebase across Android, iOS, and Desktop—without papering over platform polish.
Shared UI means shared behavior, state, and components with small, explicit platform seams so every surface still feels native.
Cross-platform UI usually fails by a thousand “just this one platform thing” cuts. These patterns keep one mental model while embracing platform polish.
1) Unidirectional State, Everywhere
Keep an immutable UiState, stream updates, and route all user intent through explicit Events. It reads the same on Android and iOS and keeps the UI a pure function of state.
Predictable state kills “it works on Android” drift and makes previews or harnesses trivial.
// shared/commonMain/kotlin/ui/SearchScreenModel.ktdata class UiState(val query: String = "",val results: List<Item> = emptyList(),val isLoading: Boolean = false,val error: String? = null)sealed interface Event {data class QueryChanged(val value: String) : Eventdata object Search : Event}class SearchViewModel(private val repo: Repository) : ViewModel() {private val _state = MutableStateFlow(UiState())val state = _state.asStateFlow()fun on(event: Event) {when (event) {is Event.QueryChanged -> _state.update { it.copy(query = event.value) }Event.Search -> performSearch()}}private fun performSearch() = viewModelScope.launch {_state.update { it.copy(isLoading = true, error = null) }try {val results = repo.search(_state.value.query)_state.update { it.copy(results = results, isLoading = false) }} catch (e: Exception) {_state.update { it.copy(error = e.message, isLoading = false) }}}}
2) The Platform Adapter (expect/actual UI)
When Compose alone is not enough—maps, webviews, camera feeds—keep your main layout pure Kotlin and wrap the messy bridge behind expect/actual.
You keep one layout file and isolate native interop in tiny, well-defined seams.
// commonMain@Composableexpect fun NativeWebView(url: String,modifier: Modifier = Modifier)// androidMain@Composableactual fun NativeWebView(url: String, modifier: Modifier) {AndroidView(modifier = modifier,factory = { context ->WebView(context).apply { settings.javaScriptEnabled = true }},update = { it.loadUrl(url) })}// iosMain@Composableactual fun NativeWebView(url: String, modifier: Modifier) {val request = remember(url) { NSURLRequest(NSURL(string = url)) }UIKitView(modifier = modifier,factory = {WKWebView(frame = .zero).apply { loadRequest(request) }})}
3) Adaptive Layouts (not platform checks)
Don’t branch on platform; branch on capability. Window size classes make iPad feel closer to Desktop than to iPhone, and foldables just work.
Layout by size keeps you resilient to new device categories without new code paths.
@Composablefun AdaptiveFeed(posts: List<Post>,windowSizeClass: WindowSizeClass) {if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded) {Row(Modifier.fillMaxSize()) {Sidebar(Modifier.weight(1f))PostGrid(posts, Modifier.weight(3f))}} else {PostList(posts, Modifier.fillMaxSize())}}
5) The Design System Bridge
Share tokens like colors and spacing, but let typography stay platform-aware. iOS wants San Francisco; Android expects Roboto/Product Sans.
Fonts are the biggest tell. Bridge them intentionally so the app feels native instead of “almost right.”
object AppTheme {val colors = lightColorScheme(...)val typography: Typography@Composable get() = getPlatformTypography()}@Composableexpect fun getPlatformTypography(): Typography// androidMain@Composableactual fun getPlatformTypography(): Typography {val roboto = GoogleFont("Roboto")return Typography(/* Android families using roboto */)}// iosMain@Composableactual fun getPlatformTypography(): Typography {val sfPro = FontFamily.Defaultreturn Typography(/* Uses San Francisco under the hood */)}
6) Resources as Code (Compose Resources)
Strings, images, and files become type-safe accessors generated at build time. Drop an asset into commonMain/composeResources and it works everywhere.
import your_app.generated.resources.Resimport your_app.generated.resources.ic_cartimport your_app.generated.resources.welcome_message@Composablefun WelcomeHeader() {Column {Image(painter = painterResource(Res.drawable.ic_cart),contentDescription = null)Text(stringResource(Res.string.welcome_message))}}
7) Native Interactions (Back Handler & Gestures)
Respect Android predictive back and iOS swipe-to-go-back. Shared UI still needs to honor each system’s muscle memory.
@Composablefun DetailScreen(onBack: () -> Unit) {BackHandler { onBack() }Scaffold(topBar = {TopAppBar(title = { Text("Details") },navigationIcon = {IconButton(onClick = onBack) {Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")}})}) {/* content */}}
The 100/90/10 rule: 100% shared logic, ~90% shared UI, ~10% platform-specific. Embrace the seams instead of hiding them and you keep platform dignity while shipping once.