| name | compose-editor |
| description | Writes idiomatic, maintainable, and accessible Jetpack Compose UI. Use when user asks to "create a composable", "write a Compose screen", "build a UI component", "review Compose code", or "refactor XML to Compose". |
Compose Editor
Overview
This skill provides patterns and rules for writing Compose UI that is readable, testable, stable, and accessible. It covers composable design, state management, side effects, accessibility, and preview strategies.
Composable Function Rules
Naming
@Composable fun UserProfileCard(...) { }
@Composable fun userProfileCard(...) { }
Parameter Order
@Composable
fun MyComponent(
title: String,
subtitle: String,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
content: (@Composable () -> Unit)? = null,
)
Single Responsibility
Split composables that exceed ~80 lines or handle more than one concern:
@Composable fun ProfileScreen(user: User) {
Column {
}
}
@Composable fun ProfileScreen(user: User) {
Column {
ProfileHeader(user)
ProfileBio(user.bio)
ProfilePostList(user.posts)
ProfileActions(user.id)
}
}
State Management
remember for Expensive Computations
@Composable fun SortedList(items: List<Item>) {
val sorted = items.sortedBy { it.name }
LazyColumn { items(sorted) { ItemRow(it) } }
}
@Composable fun SortedList(items: List<Item>) {
val sorted = remember(items) { items.sortedBy { it.name } }
LazyColumn { items(sorted) { ItemRow(it) } }
}
derivedStateOf for Dependent State
Use when you want to derive a value from another State<T> and avoid over-recomposition:
@Composable fun BackToTopButton(listState: LazyListState) {
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
if (showButton) FloatingActionButton(onClick = { }) { ... }
}
⚠️ derivedStateOf only tracks State<T> reads inside its lambda. Captured plain vals (already unwrapped via by) are not tracked.
remember vs rememberSaveable
| Need | Use |
|---|
| Survive recomposition | remember |
| Survive screen rotation / process death | rememberSaveable |
Complex types (not Parcelable/primitive) | rememberSaveable(stateSaver = ...) |
Side Effects
| Effect API | When to Use |
|---|
LaunchedEffect(key) | Start a coroutine when key changes (or once if Unit) |
DisposableEffect(key) | Register/unregister listeners with cleanup via onDispose |
SideEffect | Synchronize non-Compose state after every successful recomposition |
rememberCoroutineScope | Trigger a coroutine from an event handler (e.g., button tap) |
LaunchedEffect(Unit) {
viewModel.sideEffects.collect { effect ->
when (effect) {
is MySideEffect.ShowToast -> context.toast(effect.message)
is MySideEffect.Navigation -> onNavigation(effect)
}
}
}
DisposableEffect(sensor) {
sensor.register(listener)
onDispose { sensor.unregister(listener) }
}
Accessibility
Content Descriptions
Icon(imageVector = Icons.Default.Favorite, contentDescription = null)
Icon(imageVector = Icons.Default.Favorite, contentDescription = "Add to favorites")
Touch Target Size
Minimum 48dp × 48dp for interactive elements:
IconButton(
onClick = { ... },
modifier = Modifier.size(48.dp),
) {
Icon(Icons.Default.Close, contentDescription = "Close dialog")
}
Semantic Roles
Box(
modifier = Modifier.semantics {
role = Role.Button
contentDescription = "Open menu"
}
)
Screen Reader Order
Use Modifier.semantics { traversalIndex = n } to control reading order when visual order differs from logical order.
Theming
- Never hardcode colors, sizes, or typography
- Always read from
MaterialTheme.* (or your design system's theme)
Text(text = "Hello", fontSize = 16.sp, color = Color(0xFF000000))
Text(text = "Hello", style = MaterialTheme.typography.bodyLarge)
Previews
Always create @Preview functions alongside every composable:
@Preview(name = "Light")
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun OrderCardPreview() {
MyAppTheme {
Surface {
OrderCard(
order = Order(id = "1", title = "Pizza Margherita", price = 12.99),
onClick = {},
)
}
}
}
Use multiple previews to cover: loading, empty, error, and populated states.
Lazy Lists
LazyColumn {
items(items) { item -> ItemRow(item) }
}
LazyColumn {
items(items, key = { it.id }) { item -> ItemRow(item) }
}
For sticky headers use stickyHeader { }. For mixed content types use itemsIndexed or custom content types via contentType.
State Hoisting
Push state up to the lowest common ancestor that needs it. Composables should be stateless when possible:
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TextField(value = query, onValueChange = onQueryChange, modifier = modifier)
}
var query by remember { mutableStateOf("") }
SearchBar(query = query, onQueryChange = { query = it })
Common Patterns
Loading / Error / Content
when {
state.isLoading -> CircularProgressIndicator()
state.error != null -> ErrorMessage(state.error)
state.items.isEmpty() -> EmptyState()
else -> ContentList(state.items)
}
Pull-to-Refresh
val pullState = rememberPullToRefreshState()
LaunchedEffect(pullState.isRefreshing) {
if (pullState.isRefreshing) {
onRefresh()
}
}
LaunchedEffect(isLoading) {
if (!isLoading) pullState.endRefresh()
}
Keyboard Navigation (IME)
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
TextField(
...,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
TextField(
...,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
)
Checklist: New Composable
References