Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Compose Multiplatform and Jetpack Compose patterns for KMP projects — state management, navigation, theming, performance, and platform-specific UI.
.claude/skills/loulanyue-compose-multiplatform-patterns/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-21 | ✗→✓ | ▲ Improved | 73% | 0% |
| case-17 | ✓→✗ | ▼ Worse | 99% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 103% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 34% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 80% | 0% |
Patterns for building shared UI across Android, iOS, Desktop, and Web using Compose Multiplatform and Jetpack Compose. Covers state management, navigation, theming, and performance.
Use a single data class for screen state. Expose it as StateFlow and collect in Compose:
kotlindata class ItemListState( val items: List<Item> = emptyList(), val isLoading: Boolean = false, val error: String? = null, val searchQuery: String = "" ) class ItemListViewModel( private val getItems: GetItemsUseCase ) : ViewModel() { private val _state = MutableStateFlow(ItemListState()) val state: StateFlow<ItemListState> = _state.asStateFlow() fun onSearch(query: String) { _state.update { it.copy(searchQuery = query) } loadItems(query) } private fun loadItems(query: String) { viewModelScope.launch { _state.update { it.copy(isLoading = true) } getItems(query).fold( onSuccess = { items -> _state.update { it.copy(items = items, isLoading = false) } }, onFailure = { e -> _state.update { it.copy(error = e.message, isLoading = false) } } ) } } }
kotlin@Composable fun ItemListScreen(viewModel: ItemListViewModel = koinViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() ItemListContent( state = state, onSearch = viewModel::onSearch ) } @Composable private fun ItemListContent( state: ItemListState, onSearch: (String) -> Unit ) { // Stateless composable — easy to preview and test }
For complex screens, use a sealed interface for events instead of multiple callback lambdas:
kotlinsealed interface ItemListEvent { data class Search(val query: String) : ItemListEvent data class Delete(val itemId: String) : ItemListEvent data object Refresh : ItemListEvent } // In ViewModel fun onEvent(event: ItemListEvent) { when (event) { is ItemListEvent.Search -> onSearch(event.query) is ItemListEvent.Delete -> deleteItem(event.itemId) is ItemListEvent.Refresh -> loadItems(_state.value.searchQuery) } } // In Composable — single lambda instead of many ItemListContent( state = state, onEvent = viewModel::onEvent )
Define routes as @Serializable objects:
kotlin@Serializable data object HomeRoute @Serializable data class DetailRoute(val id: String) @Serializable data object SettingsRoute @Composable fun AppNavHost(navController: NavHostController = rememberNavController()) { NavHost(navController, startDestination = HomeRoute) { composable<HomeRoute> { HomeScreen(onNavigateToDetail = { id -> navController.navigate(DetailRoute(id)) }) } composable<DetailRoute> { backStackEntry -> val route = backStackEntry.toRoute<DetailRoute>() DetailScreen(id = route.id) } composable<SettingsRoute> { SettingsScreen() } } }
Use dialog() and overlay patterns instead of imperative show/hide:
kotlinNavHost(navController, startDestination = HomeRoute) { composable<HomeRoute> { /* ... */ } dialog<ConfirmDeleteRoute> { backStackEntry -> val route = backStackEntry.toRoute<ConfirmDeleteRoute>() ConfirmDeleteDialog( itemId = route.itemId, onConfirm = { navController.popBackStack() }, onDismiss = { navController.popBackStack() } ) } }
Design composables with slot parameters for flexibility:
kotlin@Composable fun AppCard( modifier: Modifier = Modifier, header: @Composable () -> Unit = {}, content: @Composable ColumnScope.() -> Unit, actions: @Composable RowScope.() -> Unit = {} ) { Card(modifier = modifier) { Column { header() Column(content = content) Row(horizontalArrangement = Arrangement.End, content = actions) } } }
Modifier order matters — apply in this sequence:
kotlinText( text = "Hello", modifier = Modifier .padding(16.dp) // 1. Layout (padding, size) .clip(RoundedCornerShape(8.dp)) // 2. Shape .background(Color.White) // 3. Drawing (background, border) .clickable { } // 4. Interaction )
kotlin// commonMain @Composable expect fun PlatformStatusBar(darkIcons: Boolean) // androidMain @Composable actual fun PlatformStatusBar(darkIcons: Boolean) { val systemUiController = rememberSystemUiController() SideEffect { systemUiController.setStatusBarColor(Color.Transparent, darkIcons) } } // iosMain @Composable actual fun PlatformStatusBar(darkIcons: Boolean) { // iOS handles this via UIKit interop or Info.plist }
Mark classes as @Stable or @Immutable when all properties are stable:
kotlin@Immutable data class ItemUiModel( val id: String, val title: String, val description: String, val progress: Float )
key() and Lazy Lists CorrectlykotlinLazyColumn { items( items = items, key = { it.id } // Stable keys enable item reuse and animations ) { item -> ItemRow(item = item) } }
derivedStateOfkotlinval listState = rememberLazyListState() val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 5 } }
kotlin// BAD — new lambda and list every recomposition items.filter { it.isActive }.forEach { ActiveItem(it, onClick = { handle(it) }) } // GOOD — key each item so callbacks stay attached to the right row val activeItems = remember(items) { items.filter { it.isActive } } activeItems.forEach { item -> key(item.id) { ActiveItem(item, onClick = { handle(item) }) } }
kotlin@Composable fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { if (darkTheme) dynamicDarkColorScheme(LocalContext.current) else dynamicLightColorScheme(LocalContext.current) } darkTheme -> darkColorScheme() else -> lightColorScheme() } MaterialTheme(colorScheme = colorScheme, content = content) }
mutableStateOf in ViewModels when MutableStateFlow with collectAsStateWithLifecycle is safer for lifecycleNavController deep into composables — pass lambda callbacks instead@Composable functions — move to ViewModel or remember {}LaunchedEffect(Unit) as a substitute for ViewModel init — it re-runs on configuration change in some setupsSee skill: android-clean-architecture for module structure and layering. See skill: kotlin-coroutines-flows for coroutine and Flow patterns.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-03 | pass→pass | 12,896 | 12,705 | -1% | 1 | 1 | 0% | 2,206 | 4,486 | +103% | 0 | 0 | — |
case-01 | pass→pass | 20,886 | 17,027 | -18% | 1 | 1 | 0% | 4,394 | 5,896 | +34% | 0 | 0 | — |
case-02 | pass→pass | 12,346 | 10,861 | -12% | 1 | 1 | 0% | 2,262 | 4,071 | +80% | 0 | 0 | — |
case-04 | pass→pass | 11,703 | 6,145 | -47% | 1 | 1 | 0% | 2,065 | 3,061 | +48% | 0 | 0 | — |
case-05 | pass→pass | 14,615 | 11,546 | -21% | 1 | 1 | 0% | 2,666 | 4,448 | +67% | 0 | 0 | — |
case-06 | pass→pass | 14,555 | 11,461 | -21% | 1 | 1 | 0% | 2,428 | 4,038 | +66% | 0 | 0 | — |
case-07 | pass→pass | 11,134 | 7,861 | -29% | 1 | 1 | 0% | 1,665 | 3,347 | +101% | 0 | 0 | — |
case-08 | pass→pass | 10,486 | 8,217 | -22% | 1 | 1 | 0% | 1,927 | 3,630 | +88% | 0 | 0 | — |
case-09 | pass→pass | 11,158 | 9,169 | -18% | 1 | 1 | 0% | 1,900 | 3,576 | +88% | 0 | 0 | — |
case-10 | pass→pass | 11,209 | 8,241 | -26% | 1 | 1 | 0% | 2,122 | 3,638 | +71% | 0 | 0 | — |
case-11 | pass→pass | 13,212 | 8,431 | -36% | 1 | 1 | 0% | 2,223 | 3,543 | +59% | 0 | 0 | — |
case-12 | pass→pass | 12,397 | 8,094 | -35% | 1 | 1 | 0% | 2,037 | 3,368 | +65% | 0 | 0 | — |
case-13 | pass→pass | 14,945 | 7,180 | -52% | 1 | 1 | 0% | 2,450 | 3,274 | +34% | 0 | 0 | — |
case-14 | pass→pass | 14,023 | 8,628 | -38% | 1 | 1 | 0% | 2,403 | 3,686 | +53% | 0 | 0 | — |
case-15 | pass→pass | 14,120 | 7,639 | -46% | 1 | 1 | 0% | 2,507 | 3,507 | +40% | 0 | 0 | — |
case-16 | pass→pass | 13,675 | 15,036 | +10% | 1 | 1 | 0% | 2,385 | 4,837 | +103% | 0 | 0 | — |
case-17 | pass→fail | 12,357 | 12,946 | +5% | 1 | 1 | 0% | 2,136 | 4,240 | +99% | 0 | 0 | — |
case-18 | pass→pass | 14,932 | 10,442 | -30% | 1 | 1 | 0% | 2,469 | 3,799 | +54% | 0 | 0 | — |
case-19 | fail→fail | 19,340 | 15,680 | -19% | 1 | 1 | 0% | 3,671 | 4,993 | +36% | 0 | 0 | — |
case-20 | pass→pass | 13,953 | 10,669 | -24% | 1 | 1 | 0% | 2,554 | 4,085 | +60% | 0 | 0 | — |
case-21 | fail→pass | 9,945 | 5,879 | -41% | 1 | 1 | 0% | 1,771 | 3,057 | +73% | 0 | 0 | — |
case-22 | pass→pass | 11,347 | 12,236 | +8% | 1 | 1 | 0% | 2,155 | 4,402 | +104% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of 0 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.