name: jetpack-compose description: Write idiomatic Jetpack Compose UI — stateless composables, state hoisting, slot API, Modifier ordering, side effects, previews, accessibility, and custom layouts argument-hint: "" user-invocable: true
// Stateless — preferred: reusable, testable, previewable
@Composable
fun WordCard(
word: Word,
isFlipped: Boolean,
onFlip: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) { ... }
// Stateful — only at ViewModel boundary
// viewModel.state() is a project-level extension:
// fun <S> ViewModel.state(): State<S> = uiState.collectAsStateWithLifecycle()
@Composable
fun WordCardScreen() {
val viewModel = koinViewModel<WordCardViewModel>()
val state by viewModel.state()
WordCard(
word = state.word,
isFlipped = state.isFlipped,
onFlip = viewModel::flip,
onDelete = viewModel::delete,
)
}
State hoisting rule: hoist state to the lowest common ancestor of all composables that need to read or write it.
Design containers with content slots instead of bloated parameter lists:
@Composable
fun SectionCard(
modifier: Modifier = Modifier,
header: @Composable (() -> Unit)? = null,
footer: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Card(modifier = modifier) {
Column(Modifier.padding(16.dp)) {
header?.invoke()
content()
footer?.invoke()
}
}
}
// Usage — flexible without combinatorial parameter explosion
SectionCard(
header = { Text("Recent Words", style = MaterialTheme.typography.titleMedium) },
footer = { TextButton(onClick = { }) { Text("See all") } },
) {
words.forEach { WordRow(it) }
}
modifier: Modifier = ModifierEvery composable that renders UI must accept and forward a modifier parameter:
@Composable
fun PrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier, // always last named param before content
enabled: Boolean = true,
) {
Button(onClick = onClick, modifier = modifier, enabled = enabled) {
Text(text)
}
}
Applied left-to-right — order changes visual and touch behaviour:
// Clickable area INCLUDES the padding
Modifier
.clickable { onClick() }
.padding(16.dp)
// Padding is OUTSIDE the clickable area
Modifier
.padding(16.dp)
.clickable { onClick() }
// Canonical ordering for a card-like component
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(containerColor)
.clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 12.dp)
Always supply key and contentType to help the Compose runtime skip unnecessary work:
LazyColumn {
items(
items = words,
key = { it.id }, // stable identity — enables animated reordering
contentType = { "word" }, // same type → runtime reuses composition nodes
) { word ->
WordRow(
word = word,
modifier = Modifier.animateItem(), // smooth add/remove animations for free
)
}
}
// key() in non-lazy forEach — same principle
Column {
words.forEach { word ->
key(word.id) {
WordRow(word)
}
}
}
Rules:
key must be stable and unique — avoid array indices as keyskey forces the runtime to diff by position; inserting at the top recomposes everythingcontentType matters when a list mixes heterogeneous item types (headers, ads, rows)// No key — computed once
val painter = remember { loadPainter(url) }
// Key — recomputed when id changes
val formatted = remember(id) { expensiveFormat(id) }
// Multiple keys
val result = remember(a, b) { compute(a, b) }
// Only recomposes when the boolean flips, not on every scroll delta
val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
// Multiple conditions
val isFormValid by remember {
derivedStateOf { name.isNotBlank() && email.contains("@") && password.length >= 8 }
}
// Survives recomposition AND process death (uses Bundle)
var searchQuery by rememberSaveable { mutableStateOf("") }
// Custom saver for complex types
var selection by rememberSaveable(stateSaver = SelectionSaver) { mutableStateOf(Selection.None) }
Reading state inside a lambda modifier defers the read to the draw/layout phase, bypassing recomposition entirely for continuous animations:
// BAD — state read triggers recomposition on every frame
val offsetY by animateFloatAsState(if (expanded) 0f else -100f)
Box(Modifier.offset(y = offsetY.dp))
// GOOD — state read deferred to layout phase, no recomposition
val offsetY by animateFloatAsState(if (expanded) 0f else -100f)
Box(Modifier.offset { IntOffset(0, offsetY.roundToInt()) })
// Same pattern for graphicsLayer
Box(Modifier.graphicsLayer {
alpha = alphaState.value
scaleX = scaleState.value
scaleY = scaleState.value
translationY = translationState.value
})
The Compose compiler skips recomposition only if it can prove all parameters are stable. Unstable parameters force recomposition even when values haven't changed.
// @Immutable — all public properties are deeply immutable; compiler treats as stable
@Immutable
data class Word(val id: String, val text: String, val definition: String)
// @Stable — mutable but notifies Compose when values change (e.g., custom observable)
@Stable
class WordSelection {
var selectedId by mutableStateOf<String?>(null)
}
// ImmutableList — List<T> is unstable because its interface allows mutation
// Use kotlinx.collections.immutable
@Immutable
data class WordListState(
val words: ImmutableList<Word>, // stable ✓
val isLoading: Boolean,
)
// Building ImmutableList
val state = WordListState(
words = words.toImmutableList(),
isLoading = false,
)
Rules:
data class with only val primitive/@Immutable properties is automatically stable — no annotation neededList<T>, Map<K, V> are never stable — always wrap with ImmutableList / ImmutableMap in state classes./gradlew assembleRelease -PcomposeCompilerReports=true) to verify stability| Effect | Use case |
|---|---|
LaunchedEffect(key) |
Launch coroutine, re-launch when key changes |
DisposableEffect(key) |
Register + cleanup (listeners, observers) |
SideEffect |
Sync Compose state to non-Compose system every recomposition |
rememberUpdatedState |
Capture latest value inside long-lived effect |
produceState |
Convert callback/non-Compose source to State |
snapshotFlow |
Convert Compose State to Flow |
// LaunchedEffect — load data when id changes
LaunchedEffect(wordId) {
viewModel.loadWord(wordId)
}
// DisposableEffect — register/unregister lifecycle observer
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event -> onEvent(event) }
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
// rememberUpdatedState — always use latest callback in a running effect
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(5_000)
currentOnTimeout() // calls latest lambda, not the one captured at launch
}
// produceState — wrap callback API
val bitmap by produceState<Bitmap?>(initialValue = null, url) {
value = loadBitmapAsync(url)
}
Never use LaunchedEffect to observe a navigation or snackbar event — it can fire multiple times on recomposition. Use an OnEvents pattern that consumes each event exactly once:
// In ViewModel — emit to SharedFlow
class WordCardViewModel : ViewModel() {
private val _effects = MutableSharedFlow<WordEffect>(extraBufferCapacity = 1)
val effects: SharedFlow<WordEffect> = _effects.asSharedFlow()
fun delete() {
viewModelScope.launch {
repository.delete(state.word.id)
_effects.emit(WordEffect.NavigateBack)
}
}
}
sealed interface WordEffect {
data object NavigateBack : WordEffect
data class ShowError(val message: String) : WordEffect
}
// In composable — collect effects exactly once
@Composable
fun WordCardScreen(
onNavigateBack: () -> Unit,
snackbarHostState: SnackbarHostState,
) {
val viewModel = koinViewModel<WordCardViewModel>()
// OnEvents: collect SharedFlow without a key so it never re-subscribes
val effects = viewModel.effects
LaunchedEffect(effects) {
effects.collect { effect ->
when (effect) {
WordEffect.NavigateBack -> onNavigateBack()
is WordEffect.ShowError -> snackbarHostState.showSnackbar(effect.message)
}
}
}
val state by viewModel.state()
WordCard(state = state, onDelete = viewModel::delete)
}
The canonical screen skeleton — always wire SnackbarHostState through the call stack, not via CompositionLocal:
@Composable
fun WordListScreen(onWordClick: (String) -> Unit) {
val snackbarHostState = remember { SnackbarHostState() }
val viewModel = koinViewModel<WordListViewModel>()
// Consume effects (see ViewModel Effects section)
LaunchedEffect(viewModel.effects) {
viewModel.effects.collect { effect ->
when (effect) {
is WordListEffect.ShowError -> snackbarHostState.showSnackbar(effect.message)
}
}
}
Scaffold(
topBar = {
TopAppBar(title = { Text("Words") })
},
floatingActionButton = {
FloatingActionButton(onClick = viewModel::addWord) {
Icon(Icons.Default.Add, contentDescription = "Add word")
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
val state by viewModel.state()
WordList(
words = state.words,
onWordClick = onWordClick,
modifier = Modifier.padding(innerPadding),
)
}
}
Prefer high-level animation APIs before reaching for Animatable or Transition:
// AnimatedVisibility — show/hide with enter/exit transitions
AnimatedVisibility(
visible = showBanner,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
ErrorBanner(message)
}
// animateContentSize — smooth size changes without measuring manually
Column(Modifier.animateContentSize()) {
Text(text, maxLines = if (expanded) Int.MAX_VALUE else 2)
TextButton(onClick = { expanded = !expanded }) {
Text(if (expanded) "Show less" else "Show more")
}
}
// animateFloatAsState — single value animation driven by state
val alpha by animateFloatAsState(
targetValue = if (isLoading) 0.4f else 1f,
label = "content alpha",
)
Box(Modifier.graphicsLayer { this.alpha = alpha }) { ... }
// Crossfade — swap between two composables
Crossfade(targetState = currentScreen, label = "screen") { screen ->
when (screen) {
Screen.List -> WordListScreen()
Screen.Detail -> WordDetailScreen()
}
}
Every public composable needs at minimum a light + dark preview:
@Preview(showBackground = true, name = "Light")
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark")
@Composable
private fun WordCardPreview() {
AppTheme {
WordCard(
word = previewWord(),
isFlipped = false,
onFlip = {},
onDelete = {},
)
}
}
// Device-size previews for adaptive layouts
@Preview(name = "Phone", device = Devices.PHONE)
@Preview(name = "Tablet", device = Devices.TABLET)
@Composable
private fun WordListPreview() { ... }
// @PreviewParameter — multiple data variants from a single preview function
class WordPreviewProvider : PreviewParameterProvider<Word> {
override val values = sequenceOf(
Word(id = "1", text = "Ephemeral", definition = "Lasting for a very short time"),
Word(id = "2", text = "X", definition = ""), // edge case: short text
Word(id = "3", text = "A".repeat(40), definition = "A".repeat(200)), // overflow
)
}
@Preview(showBackground = true)
@Composable
private fun WordRowPreview(@PreviewParameter(WordPreviewProvider::class) word: Word) {
AppTheme { WordRow(word) }
}
Rules:
private visibility — previews are not API@PreviewParameter for edge cases (empty, overflow, RTL) rather than duplicating preview functionsFor cross-cutting concerns that would require threading through many composable levels.
Choose the right variant:
staticCompositionLocalOf — value is expected to never (or rarely) change; any change invalidates the entire subtree. Best for stable dependencies (analytics, feature flags).compositionLocalOf — tracks reads and only recomposes consumers when the value changes. Use when the value updates at runtime (e.g., a dynamic theme override).
// staticCompositionLocalOf — stable dependency, never changes after app start
val LocalAnalytics = staticCompositionLocalOf<Analytics> {
error("No Analytics provided — wrap with CompositionLocalProvider")
}
// compositionLocalOf — changes at runtime (only recomposes readers)
val LocalContentAlpha = compositionLocalOf { 1f }
// Provide (at app root or feature entry)
CompositionLocalProvider(LocalAnalytics provides analytics) {
AppContent()
}
// Consume (anywhere in subtree)
val analytics = LocalAnalytics.current
Good candidates: theme, navigation, snackbar host, analytics, feature flags. Bad candidates: screen-specific state, business data — pass those explicitly.
// Role for custom clickable
Box(
modifier = Modifier
.semantics { role = Role.Button }
.clickable(onClickLabel = "Delete word") { onDelete() }
) { ... }
// contentDescription rules:
// - Interactive icons (standalone Icon button): always set a description
// - Decorative icons inside a labeled component: set null so TalkBack skips the icon
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete", // standalone — screen reader announces this
)
// Merge semantics for compound components (icon + label)
// Set null on the icon so TalkBack reads "Learned" once, not "check mark, Learned"
Row(Modifier.semantics(mergeDescendants = true) {}) {
Icon(Icons.Default.Check, contentDescription = null)
Text("Learned")
}
// Custom actions for swipeable items — expose actions to TalkBack without swiping
Box(Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Mark as learned") { viewModel.markLearned(); true },
CustomAccessibilityAction("Delete") { viewModel.delete(); true },
)
}) { ... }
For non-standard arrangements not achievable with Row/Column/Box:
@Composable
fun StaggeredRow(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val width = constraints.maxWidth
val height = placeables.maxOf { it.height }
layout(width, height) {
var x = 0
placeables.forEachIndexed { index, placeable ->
val y = if (index % 2 == 0) 0 else placeable.height / 2
placeable.placeRelative(x, y)
x += placeable.width
}
}
}
}
modifier: Modifier = Modifier — see Modifierremember with keys for expensive computations — see State ManagementderivedStateOf for boolean flags derived from other state — see State Managementkey and contentType in every LazyColumn/LazyRow — see Lazy Listskey() in non-lazy forEach loops — see Lazy Listsrememberoffset {}, graphicsLayer {}) — see Deferred State Reads@Stable or @Immutable where needed — see StabilityImmutableList / ImmutableMap for collections in state classes — see StabilitySharedFlow, not Channel or LaunchedEffect — see ViewModel Effects