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
// ✅ Always consume Scaffold's innerPadding
Scaffold(
topBar = { TopAppBar(title = { Text("Screen") }) },
bottomBar = { BottomNavBar() }
) { innerPadding ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = innerPadding // ← pass to content, never ignore
) { ... }
}
// ❌ Never ignore innerPadding — content hides under bars
Scaffold { _ ->
LazyColumn { ... } // content hidden under top/bottom bars
}
2. ModalBottomSheet navigation bar padding
// ✅ Always add navigationBarsPadding inside BottomSheet content
ModalBottomSheet(onDismissRequest = { ... }) {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding() // ← required, content shifts above nav bar
.padding(16.dp)
) { ... }
}
// ❌ Missing navigationBarsPadding — content hidden behind gesture nav bar
ModalBottomSheet(onDismissRequest = { ... }) {
Column(modifier = Modifier.padding(16.dp)) { ... }
}
3. Single UiState sealed class per screen
// ✅ One sealed class represents all screen states
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
}
// ✅ Always handle all states in the Composable
@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()
}
}
// ❌ Never use multiple booleans for state — causes impossible states
data class HomeState(
val isLoading: Boolean = false,
val isError: Boolean = false,
val isEmpty: Boolean = false,
val items: List<Item> = emptyList()
)
4. collectAsStateWithLifecycle — never collectAsState
// ✅ Lifecycle-aware collection — pauses when app is in background
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// ❌ collectAsState — keeps collecting even when app is backgrounded
val uiState by viewModel.uiState.collectAsState()
5. SharedFlow for one-shot events
// ✅ SharedFlow for navigation events, toasts, dialogs
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))
}
}
}
// ✅ Collect events with LaunchedEffect
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is HomeEvent.NavigateToDetail -> navController.navigate("detail/${event.id}")
}
}
}
// ❌ Never use StateFlow for one-shot events — events replay on recomposition
private val _navigationEvent = MutableStateFlow<String?>(null)
6. LaunchedEffect key rules
// ✅ Use Unit key when effect runs once on composition
LaunchedEffect(Unit) {
viewModel.loadData()
}
// ✅ Use value key when effect should rerun when value changes
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
// ❌ Never use constantly-changing keys — causes infinite re-execution
LaunchedEffect(System.currentTimeMillis()) { ... }
7. No logic in composition — only rendering
// ✅ Logic in ViewModel, only UI state in Composable
@Composable
fun ItemCard(item: Item, onLike: (String) -> Unit) {
Card(onClick = { onLike(item.id) }) {
Text(item.title)
}
}
// ❌ Never compute in composition — triggers recomposition loops
@Composable
fun ItemCard(items: List<Item>) {
val filtered = items.filter { it.isActive } // computation in composition!
...
}
HIGH impact rules
8. LazyColumn — stable keys and contentPadding
// ✅ Always provide stable key= for list items
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items, key = { it.id }) { item -> // ← stable key prevents recompose
ItemCard(item)
}
}
// ❌ No key — full list recomposition on any change
LazyColumn { items(items) { item -> ItemCard(item) } }
9. Modifier order matters
// ✅ Correct order: size → padding → background → clickable
Modifier
.fillMaxWidth()
.padding(16.dp)
.background(MaterialTheme.colorScheme.surface)
.clickable { ... }
// ❌ Wrong order changes visual result and hit area
Modifier
.clickable { ... } // clickable area doesn't include padding
.padding(16.dp)
.fillMaxWidth()
10. AnimatedVisibility — always specify enter/exit
// ✅ Explicit animation specs
AnimatedVisibility(
visible = isVisible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) { Content() }
// ❌ Default enter/exit looks janky
AnimatedVisibility(visible = isVisible) { Content() }
11. Type-safe Navigation destinations
// ✅ Kotlin Serializable destinations — no string routes
@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)
}
}
// ❌ String routes — fragile, no type safety, typos compile
navController.navigate("detail/$itemId")
composable("detail/{itemId}") { ... }
12. Remember variants — use the right one
// ✅ remember for values that survive recomposition but not config change
val scrollState = rememberScrollState()
// ✅ rememberSaveable for values that survive config change (rotation)
var selectedTab by rememberSaveable { mutableStateOf(0) }
// ✅ remember with key — recalculate when key changes
val computation = remember(inputList) { inputList.sortedBy { it.name } }
// ❌ remember without key for derived state — stale value
val sorted = remember { inputList.sortedBy { it.name } } // never updates
13. DisposableEffect for cleanup
// ✅ DisposableEffect cleans up when composable leaves
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
// ✅ Custom actions and descriptions for non-text clickables
IconButton(
onLike() },
modifier = Modifier.semantics {
contentDescription = "Like ${item.title}"
role = Role.Button
}
) { Icon(Icons.Default.Favorite, contentDescription = null) }
// ✅ mergeDescendants for card accessibility
Card(
modifier = Modifier.semantics(mergeDescendants = true) {}
) { ... }
15. TextField security
// ✅ Password field with correct input type
TextField(
value = password,
password = it },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
keyboardActions = KeyboardActions(onDone = { onLogin() })
)
MEDIUM impact rules
16. Multi-preview annotations
// ✅ Test across phones, large screens, and dark mode
@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
// ✅ Always use theme color roles
Text(text = title, color = MaterialTheme.colorScheme.onSurface)
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant))
// ❌ Never hardcode colors — breaks dark mode and dynamic color
Text(text = title, color = Color(0xFF333333))
18. Coil image loading
// ✅ AsyncImage with contentScale and placeholder
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
// ✅ Shift content above keyboard automatically
Scaffold(
modifier = Modifier.imePadding()
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
TextField(value = text, text = it })
}
}
20. Runtime permissions in Compose
// ✅ rememberPermissionState from Accompanist/Compose Permissions
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
if (!cameraPermission.status.isGranted) {
cameraPermission.launchPermissionRequest()
}
}
21. Scaffold with FAB and SnackbarHost
// ✅ Complete Scaffold setup
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 handles elevation, ripple, shape, and color role
Surface(
onItemClick(item.id) },
shape = MaterialTheme.shapes.medium,
tonalElevation = 2.dp
) { CardContent(item) }
// ❌ Box has no elevation or ripple semantics
Box(modifier = Modifier.clickable { onItemClick(item.id) }) { CardContent(item) }
23. Stateless Composables — hoist state up
// ✅ Stateless composable — testable and reusable
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(value = query, modifier = modifier)
}
// ❌ Stateful — owns state internally, hard to test, not reusable
@Composable
fun SearchBar() {
var query by remember { mutableStateOf("") }
TextField(value = query, query = it })
}
24. Adaptive layout with WindowSizeClass
// ✅ Respond to window size — see adaptive-ui skill for full rules
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 guidereferences/state-management.md— derivedStateOf, snapshotFlow, state in multi-module appsreferences/compose-performance.md— stability, skippable composables, baseline profiles