SKILL.md
Android Kotlin — Compose UI Module
Parent kit: [../../SKILL.md](../../SKILL.md) · Index: [../../AGENTS.md](../../AGENTS.md) · Banned list: [../../references/00-banned-antipatterns.md](../../references/00-banned-antipatterns.md)
REVIEW MODE
Trigger phrases: "audit this file", "check this composable", "review this screen", "review mode", "a11y pass on this composable".
When triggered, silently run this 6-point checklist on the target file before responding. Cite failures by checklist number and banned-antipattern row from [../../references/00-banned-antipatterns.md](../../references/00-banned-antipatterns.md).
| # | Check | Fail if |
|---|---|---|
| 1 | Modifier param | Missing modifier: Modifier = Modifier or not last optional param |
| 2 | State hoisting | Screen/business state inside @Composable that belongs in ViewModel |
| 3 | Lazy list keys | LazyColumn/LazyRow items missing key = {} or contentType = {} |
| 4 | Lifecycle collection | Uses collectAsState() instead of collectAsStateWithLifecycle() |
| 5 | Side effects in composition | IO, DB, network, or viewModelScope work directly in composable body |
| 6 | Stability | Data class params to child composables lack @Stable or @Immutable |
Output format for REVIEW MODE:
## Compose audit — <filename>
| # | Status | Finding |
|---|--------|---------|
| 1 | PASS/FAIL | ... |
...
### Banned antipatterns hit
- Row N: <pattern> → <fix>
### Suggested diff
<code block with minimal fix>
Load [../../references/17-accessibility.md](../../references/17-accessibility.md) when audit includes TalkBack, touch targets, or contrast.
AUTHORING MODE
Default behavior when writing new Compose UI (not auditing). Follow all rules below.
Read First
| File | When |
|---|---|
[../../references/02-compose-ui.md](../../references/02-compose-ui.md) |
Stability, slots, Material 3, CompositionLocal themes |
[../../references/03-animations.md](../../references/03-animations.md) |
Springs, Canvas, gestures |
[../../references/10-performance.md](../../references/10-performance.md) |
derivedStateOf, LazyColumn keys, baseline profiles |
[../../references/16-coil-image.md](../../references/16-coil-image.md) |
AsyncImage, Coil 3.4 network dep |
[../../references/17-accessibility.md](../../references/17-accessibility.md) |
Semantics, 48dp targets, WCAG |
Edge-to-Edge (Default — AGP 9 / API 35+)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
AppTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets(0, 0, 0, 0)
) { innerPadding ->
NavHost(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
)
}
}
}
}
}
enableEdgeToEdge()on everyComponentActivity- Respect
WindowInsets— never hardcode status bar padding - Use
WindowInsets.safeDrawingfor interactive content - Test gesture nav bar + display cutout on physical device
Composable Contract
@Composable
fun AccountCard(
account: AccountUi,
onClick: () -> Unit,
modifier: Modifier = Modifier // always first param after required data
) {
Card(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
) {
Text(
text = stringResource(R.string.account_title, account.name),
style = MaterialTheme.typography.titleMedium
)
}
}
Recomposition Rules
- Extract sub-composables for separate restart scopes
- Defer scroll/animation reads:
Modifier.offset { IntOffset(...) } @Immutableon UiState / list wrappers passed to childrenremember(keys) { derivedStateOf { } }for expensive derived values- Never write state in composable body after read (backwards write)
LazyColumn Checklist
-
key = { it.id }on everyitems() -
contentTypewhen row layouts differ - Stable lambdas:
remember(id) { { onClick(id) } } - No nested
verticalScroll+LazyColumn - Sort/filter in ViewModel or
remember, not inline initems {}
Anti-Patterns (Compose)
| Banned | Fix |
|---|---|
Hardcoded Text("Save") |
stringResource(R.string.action_save) |
collectAsState() on Android |
collectAsStateWithLifecycle() |
Modifier.clickable before padding when touch target should exclude margin |
Order: padding → clip → background → clickable |
items(list) without key |
items(list, key = { it.id }) |
Raw fontSize = 16.sp |
MaterialTheme.typography.bodyLarge |
Full list: [../../references/00-banned-antipatterns.md](../../references/00-banned-antipatterns.md)