mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
300 lines
7.9 KiB
Markdown
300 lines
7.9 KiB
Markdown
---
|
|
name: compose-multiplatform-patterns
|
|
description: Compose Multiplatform and Jetpack Compose patterns for KMP projects — state management, navigation, theming, performance, and platform-specific UI.
|
|
origin: ECC
|
|
---
|
|
|
|
# Compose Multiplatform Patterns
|
|
|
|
Patterns for building shared UI across Android, iOS, Desktop, and Web using Compose Multiplatform and Jetpack Compose. Covers state management, navigation, theming, and performance.
|
|
|
|
## When to Activate
|
|
|
|
- Building Compose UI (Jetpack Compose or Compose Multiplatform)
|
|
- Managing UI state with ViewModels and Compose state
|
|
- Implementing navigation in KMP or Android projects
|
|
- Designing reusable composables and design systems
|
|
- Optimizing recomposition and rendering performance
|
|
|
|
## State Management
|
|
|
|
### ViewModel + Single State Object
|
|
|
|
Use a single data class for screen state. Expose it as `StateFlow` and collect in Compose:
|
|
|
|
```kotlin
|
|
data 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) } }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Collecting State in Compose
|
|
|
|
```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
|
|
}
|
|
```
|
|
|
|
### Event Sink Pattern
|
|
|
|
For complex screens, use a sealed interface for events instead of multiple callback lambdas:
|
|
|
|
```kotlin
|
|
sealed 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
|
|
)
|
|
```
|
|
|
|
## Navigation
|
|
|
|
### Type-Safe Navigation (Compose Navigation 2.8+)
|
|
|
|
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() }
|
|
}
|
|
}
|
|
```
|
|
|
|
### Dialog and Bottom Sheet Navigation
|
|
|
|
Use `dialog()` and overlay patterns instead of imperative show/hide:
|
|
|
|
```kotlin
|
|
NavHost(navController, startDestination = HomeRoute) {
|
|
composable<HomeRoute> { /* ... */ }
|
|
dialog<ConfirmDeleteRoute> { backStackEntry ->
|
|
val route = backStackEntry.toRoute<ConfirmDeleteRoute>()
|
|
ConfirmDeleteDialog(
|
|
itemId = route.itemId,
|
|
onConfirm = { navController.popBackStack() },
|
|
onDismiss = { navController.popBackStack() }
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
## Composable Design
|
|
|
|
### Slot-Based APIs
|
|
|
|
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 Ordering
|
|
|
|
Modifier order matters — apply in this sequence:
|
|
|
|
```kotlin
|
|
Text(
|
|
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
|
|
)
|
|
```
|
|
|
|
## KMP Platform-Specific UI
|
|
|
|
### expect/actual for Platform Composables
|
|
|
|
```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
|
|
}
|
|
```
|
|
|
|
## Performance
|
|
|
|
### Stable Types for Skippable Recomposition
|
|
|
|
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
|
|
)
|
|
```
|
|
|
|
### Use `key()` and Lazy Lists Correctly
|
|
|
|
```kotlin
|
|
LazyColumn {
|
|
items(
|
|
items = items,
|
|
key = { it.id } // Stable keys enable item reuse and animations
|
|
) { item ->
|
|
ItemRow(item = item)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Defer Reads with `derivedStateOf`
|
|
|
|
```kotlin
|
|
val listState = rememberLazyListState()
|
|
val showScrollToTop by remember {
|
|
derivedStateOf { listState.firstVisibleItemIndex > 5 }
|
|
}
|
|
```
|
|
|
|
### Avoid Allocations in Recomposition
|
|
|
|
```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) })
|
|
}
|
|
}
|
|
```
|
|
|
|
## Theming
|
|
|
|
### Material 3 Dynamic Theming
|
|
|
|
```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)
|
|
}
|
|
```
|
|
|
|
## Anti-Patterns to Avoid
|
|
|
|
- Using `mutableStateOf` in ViewModels when `MutableStateFlow` with `collectAsStateWithLifecycle` is safer for lifecycle
|
|
- Passing `NavController` deep into composables — pass lambda callbacks instead
|
|
- Heavy computation inside `@Composable` functions — move to ViewModel or `remember {}`
|
|
- Using `LaunchedEffect(Unit)` as a substitute for ViewModel init — it re-runs on configuration change in some setups
|
|
- Creating new object instances in composable parameters — causes unnecessary recomposition
|
|
|
|
## References
|
|
|
|
See skill: `android-clean-architecture` for module structure and layering.
|
|
See skill: `kotlin-coroutines-flows` for coroutine and Flow patterns.
|