| name | compose-ui |
| description | Jetpack Compose UI best practices for AI agents building Android apps. Use this skill
whenever writing any Composable function, building screens, handling UI state, working with
Scaffold, LazyColumn, ModalBottomSheet, BottomSheet, edge-to-edge, IME keyboard insets,
recomposition, side effects, LaunchedEffect, DisposableEffect, remember, collectAsStateWithLifecycle,
StateFlow, SharedFlow, UiState, loading/error/empty states, accessibility semantics,
Material 3 components, Coil images, TextField, animations, modifiers, or any Compose layout.
Always apply this skill before writing any @Composable function.
|
Compose UI
24 rules that fix what AI agents consistently get wrong in Jetpack Compose.
CRITICAL rules — get these wrong and the app is broken
1. Edge-to-edge + Scaffold innerPadding
Scaffold(
topBar = { TopAppBar(title = { Text("Screen") }) },
bottomBar = { BottomNavBar() }
) { innerPadding ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = innerPadding
) { ... }
}
Scaffold { _ ->
LazyColumn { ... }
}
2. ModalBottomSheet navigation bar padding
ModalBottomSheet(onDismissRequest = { ... }) {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(16.dp)
) { ... }
}
ModalBottomSheet(onDismissRequest = { ... }) {
Column(modifier = Modifier.padding(16.dp)) { ... }
}
3. Single UiState sealed class per screen
sealed interface HomeUiState {
data object Loading : HomeUiState
data class Success(val items: List<Item>) : HomeUiState
data class Error(val message: String) : HomeUiState
data object Empty : HomeUiState
}
@Composable
fun HomeScreen(uiState: HomeUiState) {
when (uiState) {
is HomeUiState.Loading -> LoadingIndicator()
is HomeUiState.Success -> ItemList(uiState.items)
is HomeUiState.Error -> ErrorMessage(uiState.message)
is HomeUiState.Empty -> EmptyState()
}
}
data class HomeState(
val isLoading: Boolean = false,
val isError: Boolean = false,
val isEmpty: Boolean = false,
val items: List<Item> = emptyList()
)
4. collectAsStateWithLifecycle — never collectAsState
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsState()
5. SharedFlow for one-shot events
class HomeViewModel : ViewModel() {
private val _events = MutableSharedFlow<HomeEvent>()
val events: SharedFlow<HomeEvent> = _events.asSharedFlow()
fun onItemClick(id: String) {
viewModelScope.launch {
_events.emit(HomeEvent.NavigateToDetail(id))
}
}
}
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is HomeEvent.NavigateToDetail -> navController.navigate("detail/${event.id}")
}
}
}
private val _navigationEvent = MutableStateFlow<String?>(null)
6. LaunchedEffect key rules
LaunchedEffect(Unit) {
viewModel.loadData()
}
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
LaunchedEffect(System.currentTimeMillis()) { ... }
7. No logic in composition — only rendering
@Composable
fun ItemCard(item: Item, onLike: (String) -> Unit) {
Card(onClick = { onLike(item.id) }) {
Text(item.title)
}
}
@Composable
fun ItemCard(items: List<Item>) {
val filtered = items.filter { it.isActive }
...
}
HIGH impact rules
8. LazyColumn — stable keys and contentPadding
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items, key = { it.id }) { item ->
ItemCard(item)
}
}
LazyColumn { items(items) { item -> ItemCard(item) } }
9. Modifier order matters
Modifier
.fillMaxWidth()
.padding(16.dp)
.background(MaterialTheme.colorScheme.surface)
.clickable { ... }
Modifier
.clickable { ... }
.padding(16.dp)
.fillMaxWidth()
10. AnimatedVisibility — always specify enter/exit
AnimatedVisibility(
visible = isVisible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) { Content() }
AnimatedVisibility(visible = isVisible) { Content() }
11. Type-safe Navigation destinations
@Serializable
object HomeRoute
@Serializable
data class DetailRoute(val itemId: String)
NavHost(navController, startDestination = HomeRoute) {
composable<HomeRoute> { HomeScreen() }
composable<DetailRoute> { backStackEntry ->
val route: DetailRoute = backStackEntry.toRoute()
DetailScreen(itemId = route.itemId)
}
}
navController.navigate("detail/$itemId")
composable("detail/{itemId}") { ... }
12. Remember variants — use the right one
val scrollState = rememberScrollState()
var selectedTab by rememberSaveable { mutableStateOf(0) }
val computation = remember(inputList) { inputList.sortedBy { it.name } }
val sorted = remember { inputList.sortedBy { it.name } }
13. DisposableEffect for cleanup
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) viewModel.refreshData()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
14. Accessibility semantics
IconButton(
onClick = { onLike() },
modifier = Modifier.semantics {
contentDescription = "Like ${item.title}"
role = Role.Button
}
) { Icon(Icons.Default.Favorite, contentDescription = null) }
Card(
modifier = Modifier.semantics(mergeDescendants = true) {}
) { ... }
15. TextField security
TextField(
value = password,
onValueChange = { password = it },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
keyboardActions = KeyboardActions(onDone = { onLogin() })
)
MEDIUM impact rules
16. Multi-preview annotations
@Preview(name = "Phone", device = Devices.PHONE)
@Preview(name = "Tablet", device = Devices.TABLET)
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun HomeScreenPreview() {
MyAppTheme { HomeScreen(uiState = HomeUiState.Success(sampleItems)) }
}
17. Material 3 — no hardcoded colors
Text(text = title, color = MaterialTheme.colorScheme.onSurface)
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant))
Text(text = title, color = Color(0xFF333333))
18. Coil image loading
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(true)
.build(),
contentDescription = description,
contentScale = ContentScale.Crop,
placeholder = painterResource(R.drawable.placeholder),
error = painterResource(R.drawable.error_image),
modifier = Modifier.fillMaxWidth().aspectRatio(16f/9f)
)
19. WindowInsets — IME keyboard handling
Scaffold(
modifier = Modifier.imePadding()
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
TextField(value = text, onValueChange = { text = it })
}
}
20. Runtime permissions in Compose
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
if (!cameraPermission.status.isGranted) {
cameraPermission.launchPermissionRequest()
}
}
21. Scaffold with FAB and SnackbarHost
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
topBar = { TopAppBar(title = { Text("Home") }) },
floatingActionButton = {
FloatingActionButton(onClick = onCreate) {
Icon(Icons.Default.Add, contentDescription = "Create")
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { innerPadding ->
Content(modifier = Modifier.padding(innerPadding))
}
22. Surface vs Box — use Surface for clickable containers
Surface(
onClick = { onItemClick(item.id) },
shape = MaterialTheme.shapes.medium,
tonalElevation = 2.dp
) { CardContent(item) }
Box(modifier = Modifier.clickable { onItemClick(item.id) }) { CardContent(item) }
23. Stateless Composables — hoist state up
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(value = query, onValueChange = onQueryChange, modifier = modifier)
}
@Composable
fun SearchBar() {
var query by remember { mutableStateOf("") }
TextField(value = query, onValueChange = { query = it })
}
24. Adaptive layout with WindowSizeClass
val windowSizeClass = calculateWindowSizeClass(activity)
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> PhoneLayout()
WindowWidthSizeClass.Medium -> TabletLayout()
WindowWidthSizeClass.Expanded -> DesktopLayout()
}
Common Mistakes (Quick Reference)
| ❌ Wrong | ✅ Right |
|---|
collectAsState() | collectAsStateWithLifecycle() |
Ignoring innerPadding | contentPadding = innerPadding |
| Multiple state booleans | Single sealed interface UiState |
| String routes | @Serializable route objects |
| Hardcoded colors | MaterialTheme.colorScheme.* |
Logic in @Composable | Logic in ViewModel |
| StateFlow for events | SharedFlow for events |
No key= in LazyColumn | items(list, key = { it.id }) |
Deep-dive references
references/side-effects.md — complete LaunchedEffect / DisposableEffect / SideEffect guide
references/state-management.md — derivedStateOf, snapshotFlow, state in multi-module apps
references/compose-performance.md — stability, skippable composables, baseline profiles