Android Compose
Bootstrap skill covers structure + UiState + lifecycle collect. This skill covers Compose UI patterns, effects, and light motion.
State reminder (don’t duplicate bootstrap)
- Screen state in ViewModel
UiState; Composables = state + event lambdas Route(VM) → statelessScreen(previewable)- Collect with
collectAsStateWithLifecycle()
Modifiers
Common chain (order matters — applied top to bottom / outside-in for layout):
Modifier
.fillMaxWidth()
.padding(16.dp)
.clip(RoundedCornerShape(12.dp))
.background(color)
.clickable(onClick = onClick)
.padding(12.dp) // inner padding after clickable = larger touch target
| Need | Modifier |
|---|---|
| Fill / fraction | fillMaxSize(), fillMaxWidth(), fillMaxHeight(0.5f) |
| Row/Column flex | Modifier.weight(1f) (only inside Row/Column scope) |
| Click | clickable / combinedClickable; prefer on row, not only on Text |
| Keyboard insets | imePadding() on scroll/content near fields |
| System bars | systemBarsPadding() / navigationBarsPadding() as needed |
| Clip + ripple | clip(shape) then clickable (or Material surface) |
Modifier.weight only exists in RowScope/ColumnScope — it won’t compile
elsewhere, so don’t design layouts assuming it.
Lists
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items, key = { it.id }, contentType = { "item" }) { item ->
ItemRow(item, onItemClick(item.id) })
}
}
- Always pass stable
key contentTypewhen row shapes differ (helps recycling)- Avoid nested
LazyColumninside another vertical scroll without nested scroll fix - Sticky headers only if the prompt needs them (
stickyHeader { }) - Paging:
collectAsLazyPagingItems()— see bootstrap skill
TextFields
Controlled from state (no uncontrolled field state in production screens):
OutlinedTextField(
value = state.query,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSubmit(); focusManager.clearFocus() }),
)
- Keep value in
UiState; VM validates if needed (emailErrorseparate from screenerror) - Use
KeyboardOptions/ImeActionfor Done/Search/Next - Hide keyboard on submit / navigate (
LocalFocusManager/LocalSoftwareKeyboardController)
Scaffold
Scaffold(
topBar = { TopAppBar(title = { Text(title) }) },
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = { /* optional */ },
) { innerPadding ->
Content(modifier = Modifier.padding(innerPadding))
}
- Always apply
innerPaddingso content isn’t under the app bar - Snackbar host + one-shot effects (below) for ephemeral messages
- Scroll-aware top bars only if polish time remains
Previews
@Preview(showBackground = true)
@Composable
private fun HomeScreenPreview() {
AppTheme {
HomeScreen(
state = HomeUiState(items = listOf(/* fake */)),
)
}
}
- Preview the stateless
Screen, not the HiltRoute - Multiple previews for loading / empty / error / content when those states matter
Performance (practical)
- Prefer immutable
UiStatedata classes; replace whole state withcopy - Don’t allocate heavy objects or read disk/network during composition
- Hoist stable lambdas from list rows when easy (
onItemClickfrom parent) - Use
key { }in Lazy lists - Skip Compose Compiler stability deep-dives /
@Stableannotations unless profiling shows a real issue derivedStateOfwhen deriving expensive values from frequently changing state
Navigation + Compose
- Keep
NavControllerin the Composition (Activity / NavHost level) - Pass
onNavigateToDetail: (String) -> Unitinto screens — don’t putNavControllerin the ViewModel - Prefer typed routes (Navigation 2.8+) for arguments; add the Kotlin serialization plugin
- Restore state flags only if multi-back-stack tabs appear
@Serializable data class Detail(val id: String)
composable<Detail> { entry ->
val route = entry.toRoute<Detail>()
DetailRoute(id = route.id, navController.popBackStack() })
}
Dialogs / sheets
Drive from state, not fire-and-forget:
if (state.showDialog) {
AlertDialog(
title = { Text("Confirm") },
text = { Text(state.dialogMessage) },
confirmButton = { TextButton(onClick = onConfirm) { Text("OK") } },
dismissButton = { TextButton(onClick = onDismissDialog) { Text("Cancel") } },
)
}
showDialog: Boolean(or sealed dialog type) onUiState- Modal bottom sheet: same idea — visibility from state; clear on dismiss
- Don’t spawn dialogs only inside click handlers without state. ViewModel state
handles configuration changes; use
SavedStateHandleif it must survive process recreation.
Focus / keyboard
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
fun dismissInput() {
focusManager.clearFocus()
keyboard?.hide()
}
FocusRequester+Modifier.focusRequester+LaunchedEffectto focus on open when the prompt wants autofocus- Clear focus when navigating away or submitting
Effect APIs
| API | Use for |
|---|---|
LaunchedEffect(key) |
Suspend side effects tied to composition (collect Channel, debounce) |
DisposableEffect(key) |
Listeners; must onDispose { } |
rememberCoroutineScope() |
Launch from callbacks (clicks) |
SideEffect |
Rare — sync to non-Compose code |
Rules
- Keys = values that should restart the effect when changed
- Don’t use
LaunchedEffect(Unit)for VM work (refresh()→ VMinit/ event) - Prefer
StateFlowin VM over long-lived collectors in Composables - Ephemeral UI (Snackbar): VM
Channel/SharedFlow→ UI collects once
// VM
private val _effects = Channel<UiEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()
// UI
LaunchedEffect(Unit) {
effects.collect { effect ->
when (effect) {
is UiEffect.Snackbar -> snackbarHostState.showSnackbar(effect.message)
}
}
}
Sticky errors stay on UiState.error; effects are for one-shot polish.
Animations (basic)
Readability > spectacle. Use lightly in interviews.
| API | Use |
|---|---|
AnimatedVisibility |
Show/hide blocks (error banner, extra fields) |
animate*AsState |
Simple color/size/alpha toward a target |
Crossfade(targetState) |
Switch loading ↔ content |
Modifier.animateItem() |
Lazy list reorder/item changes |
AnimatedVisibility(visible = state.error != null) {
// Exit animation may still compose after error becomes null; never use !!
Text(state.error.orEmpty(), color = MaterialTheme.colorScheme.error)
}
val alpha by animateFloatAsState(if (state.isLoading) 0.5f else 1f, label = "alpha")
Always pass label for animate* APIs (Studio tooling).
Skip unless asked: custom Transition graphs, shared element, complex
gesture-driven animation.
Related
- App structure / MVVM / Room / Paging: ../android-interview-bootstrap/SKILL.md