Android Compose UI
Expert practices for building high-quality, performant Jetpack Compose screens targeting Android Automotive OS (AAOS) and standard Android, following the official Compose guidelines and automotive HMI constraints.
Standards baseline: Compose BOM 2024.x · Material Design 3 · Kotlin 1.9.
When to Use This Skill
- Writing a new Compose screen, component, or theme from scratch.
- Reviewing a PR for recomposition issues, state hoisting violations, or incorrect side-effect placement.
- Migrating XML layouts to Compose.
- Optimizing Compose performance (composition, layout, draw phases).
- Designing automotive-compliant UI (touch target sizes, distraction restrictions).
Composable Design Principles
Stateless vs. Stateful Composables
Always prefer stateless composables. Hoist state to the nearest common owner.
// BAD: stateful composable — hard to test and reuse
@Composable
fun CounterButton() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("Count: $count") }
}
// GOOD: stateless composable
@Composable
fun CounterButton(
count: Int,
onIncrement: () -> Unit,
modifier: Modifier = Modifier,
) {
Button(onClick = onIncrement, modifier = modifier) {
Text("Count: $count")
}
}
// State owner (ViewModel or parent composable)
@Composable
fun CounterScreen(viewModel: CounterViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
CounterButton(count = uiState.count,
}
Rules
- Composables that accept
onX: () -> Unitlambdas are stateless — prefer them. - State should live in ViewModel (
StateFlow) or be remembered at the highest needed scope. - Pass
Modifieras a parameter with defaultModifier— always last before content lambdas.
State Management
Reading ViewModel state
// Lifecycle-safe collection (stops in STOPPED state)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Local UI state
var expanded by remember { mutableStateOf(false) } // survives recomposition
var text by rememberSaveable { mutableStateOf("") } // survives config change
val color by remember { derivedStateOf { if (expanded) Red else Blue } } // derived
When to use each
| Holder | Survives recomposition | Survives config change | Description |
|---|---|---|---|
remember |
✓ | ✗ | Ephemeral UI state (expanded, focus) |
rememberSaveable |
✓ | ✓ | User input, scroll position |
ViewModel StateFlow |
✓ | ✓ | Screen-level business state |
Side Effects
| Effect | When to Use |
|---|---|
LaunchedEffect(key) |
Start coroutine tied to composable lifetime; re-launch on key change |
DisposableEffect(key) |
Register/unregister listeners; cleanup in onDispose |
SideEffect |
Push Compose state to non-Compose objects (analytics, etc.) |
rememberCoroutineScope |
Trigger coroutines from event handlers (button click, etc.) |
produceState |
Convert non-Compose async data into State |
// LaunchedEffect — scroll to top when list reloads
LaunchedEffect(uiState.items) {
if (uiState.items.isNotEmpty()) listState.animateScrollToItem(0)
}
// DisposableEffect — register lifecycle observer
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) viewModel.refresh()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// rememberCoroutineScope — coroutine from button click
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { scrollState.animateScrollTo(0) } }) { ... }
Material Design 3 Theming
Theme setup
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = MyTypography,
shapes = MyShapes,
content = content,
)
}
Using theme tokens — never hardcode colors
// BAD
Text(text = "Hello", color = Color(0xFF6200EE))
// GOOD
Text(text = "Hello", color = MaterialTheme.colorScheme.primary)
// Custom color extension (avoid raw Color() in screens)
val MyAppColors.danger: Color get() = Color(0xFFB00020)
Performance: Avoiding Recomposition
Stability rules
data classwith only immutable fields is stable — Compose skips recomposition if inputs unchanged.- Mutable collections (
List,Map) are unstable — use@Stableorkotlinx.collections.immutable. - Lambdas captured from non-stable objects cause extra recompositions.
// BAD — unstable list causes recomposition
@Composable
fun MediaList(items: List<MediaItem>) { ... }
// GOOD — use ImmutableList
@Composable
fun MediaList(items: ImmutableList<MediaItem>) { ... }
key() in LazyColumn
LazyColumn {
items(items = mediaItems, key = { it.id }) { item ->
MediaItemRow(item = item)
}
}
Heavy computation — remember it
// BAD — computed every recomposition
val sorted = items.sortedBy { it.title }
// GOOD
val sorted = remember(items) { items.sortedBy { it.title } }
Automotive UI Constraints
| Constraint | Rule |
|---|---|
| Touch target | Minimum 76 dp × 76 dp (Android Automotive HMI guidelines) |
| Interaction depth while driving | Maximum 2 interactions to complete a task |
| Text input | Not allowed while driving — use Car UX Restrictions |
| List items | Minimum 72 dp height; limit visible items |
| Animations | Keep under 200 ms; no looping animations in foreground |
@Composable
fun AutoButton(text: String, onClick: () -> Unit) {
Button(
modifier = Modifier.defaultMinSize(minWidth = 76.dp, minHeight = 76.dp),
) {
Text(text = text, style = MaterialTheme.typography.labelLarge)
}
}
Navigation in Compose
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController = navController, startDestination = "home") {
composable("home") {
HomeScreen(onNavigateToDetail = { id ->
navController.navigate("detail/$id")
})
}
composable(
route = "detail/{itemId}",
arguments = listOf(navArgument("itemId") { type = NavType.StringType }),
) { backStackEntry ->
DetailScreen(
itemId = backStackEntry.arguments!!.getString("itemId")!!,
navController.navigateUp() },
)
}
}
}
Rules
- Use
rememberNavController()in the top-level composable. - Pass
NavControlleronly to the top-level screen composable — pass lambdas downward. - For ViewModel-triggered navigation use a
SharedFlow<NavigationEvent>and collect inLaunchedEffect.
Animations
// Animated visibility
AnimatedVisibility(visible = showPanel) {
PanelContent()
}
// Animated value
val elevation by animateDpAsState(
targetValue = if (isElevated) 8.dp else 0.dp,
label = "elevation animation",
)
// Content size change
Box(modifier = Modifier.animateContentSize()) {
if (expanded) ExpandedContent() else CollapsedContent()
}
XML / View Interop
// Embed an XML View in Compose
AndroidView(
factory = { context -> MapView(context).apply { onCreate(null) } },
update = { mapView -> mapView.getMapAsync { it.moveCamera(...) } },
)
// Embed Compose in XML Activity/Fragment
// In layout XML:
// <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" />
// In Fragment:
binding.composeView.setContent {
MyAppTheme { MyScreen() }
}
Prerequisites
- Android Studio (Flamingo or newer) or AOSP build environment set up.
- Android SDK Platform-Tools installed (
adbon PATH). - Target device or emulator running Android 11+ (API 30+).
- For AOSP modules:
repotool, AOSP source synced,lunchtarget configured.
Step-by-Step Workflows
Step 1: Create the Composable function
Annotate with @Composable; accept only UiState and lambdas — no ViewModel directly.
Step 2: Manage state
Use remember/mutableStateOf for local state; hoist shared state to the ViewModel.
Step 3: Apply theming
Use MaterialTheme.colorScheme and MaterialTheme.typography; avoid hardcoded colors.
Step 4: Add a preview
Annotate with @Preview with sample data; iterate in the Design canvas before running.
Step 5: Write Compose UI tests
Use ComposeTestRule.setContent {} and onNodeWithTag() / performClick() for assertions.
Troubleshooting
- Excessive recomposition — use the Layout Inspector's Recomposition counts; stabilize lambdas with
remember { {}}; annotate classes with@Stable. - State not persisting across configuration changes — use
rememberSaveablefor UI state that must survive rotation; use ViewModel for business data. - Compose and XML view layout fighting — when using
ComposeViewinside XML, setlayoutParamsexplicitly; avoidwrap_contenton both dimensions. @Previewcrashes — preview functions cannot usehiltViewModel(); provide a fake ViewModel viaPreviewParameterProvider.
Pre-Commit Checklist
- All stateful composables hoist state to ViewModel or parent.
-
Modifieris the last named parameter before content lambdas. - No
Color()literals in screen composables — onlyMaterialTheme.colorScheme.*. - No heavy work in composition — use
rememberorLaunchedEffect. -
LazyColumnitems have stablekeylambda. - Side effects are in the correct effect handler (not in composition body).
- Automotive touch targets ≥ 76 dp; text input blocked while driving.
- Animations complete within 200 ms.
- Screenshot/UI tests added for new screens.