Android Jetpack Compose skill for Codex
Stateless screens, keyed effects, stable lists. Codex splits each Compose screen into a route that talks to the ViewModel and a stateless screen that previews and tests cleanly. It picks remember, rememberSaveable or derivedStateOf from a decision table, keys every LaunchedEffect on what should restart it, gives lazy lists stable keys, and routes colours and spacing through MaterialTheme and CompositionLocals.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- android-jetpack-compose Installs to ~/.agents/skills/android-jetpack-compose/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install android-jetpack-compose The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project android-jetpack-compose Installs to .agents/skills/android-jetpack-compose/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/android-jetpack-compose.zipunzip -o android-jetpack-compose.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) android-jetpack-compose PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $android-jetpack-compose, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds Android UI with Jetpack Compose and Material 3 - stateless composables with state hoisting, remember, rememberSaveable and derivedStateOf, collectAsStateWithLifecycle, side effects with LaunchedEffect, DisposableEffect and rememberUpdatedState, stable keys in LazyColumn, theming with dynamic color and CompositionLocals, edge-to-edge insets, type-safe navigation, and Compose UI tests. Use when writing or refactoring a Compose screen, fixing recomposition storms or state lost on rotation, choosing the right effect API, theming an app, or converting XML layouts to Compose.
Use it when
- Writing or refactoring Compose screens and components
- State is lost on rotation or recomposition is excessive
- Choosing between LaunchedEffect, DisposableEffect and coroutine scopes
- Setting up a Material 3 theme with dynamic colour and dark mode
Use something else when
- The work is ViewModels, Hilt, Room or module boundaries: use android-architecture
- Sharing Compose UI with iOS and desktop: use kotlin-multiplatform
How to check the result
Compose UI tests and lint pass, previews exist for light, dark and large font, and rotation plus the Don't keep activities developer option keep user-entered state.
The mistake it prevents
Collecting a ViewModel flow with collectAsState instead of collectAsStateWithLifecycle, so work keeps running while the app sits in the background.
Done well means: screens are split into a stateful route and a stateless content
composable, state survives rotation and process death where users expect it, effects are
keyed correctly, lists have stable keys, colours and type come from MaterialTheme, and
every screen has a preview and a UI test.
Inspect first
cat AGENTS.md 2>/dev/null
cat gradle/libs.versions.toml 2>/dev/null | grep -iE 'compose|kotlin|material3|navigation|lifecycle'
grep -rn 'org.jetbrains.kotlin.plugin.compose\|composeOptions' --include='*.kts' .
grep -rn 'targetSdk' --include='*.kts' .
- Kotlin 2.x uses the Compose Compiler Gradle plugin (
org.jetbrains.kotlin.plugin.compose) instead ofcomposeOptions.kotlinCompilerExtensionVersion. Strong skipping is on by default. - Use the Compose BOM (
platform(libs.androidx.compose.bom)) so UI, foundation and Material 3 versions stay aligned. - Gradle builds need dependencies; in an offline sandbox use
--offlineonly if the cache is warm, otherwise ask for network approval.
Screen structure
@Composable
fun OrdersRoute(
onOpenOrder: (String) -> Unit,
viewModel: OrdersViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
OrdersScreen(state = state, onRefresh = viewModel::refresh, onOpenOrder = onOpenOrder)
}
@Composable
fun OrdersScreen(
state: OrdersUiState,
onRefresh: () -> Unit,
onOpenOrder: (String) -> Unit,
modifier: Modifier = Modifier,
) {
when (state) {
OrdersUiState.Loading -> LoadingIndicator(modifier)
is OrdersUiState.Error -> ErrorMessage(state.message, onRetry = onRefresh, modifier = modifier)
is OrdersUiState.Success -> LazyColumn(modifier.fillMaxSize()) {
items(state.orders, key = { it.id }, contentType = { "order" }) { order ->
OrderRow(order, onClick = { onOpenOrder(order.id) })
}
}
}
}
- Only the route touches the ViewModel; the screen takes state and lambdas, so previews and tests need no DI.
- Every public composable that emits UI accepts
modifier: Modifier = Modifierand applies it to its root, once. collectAsStateWithLifecycle()(lifecycle-runtime-compose) stops collecting when the app is in the background; plaincollectAsState()keeps flows running.
State APIs
| Need | API |
|---|---|
| UI-only value that may reset on rotation | remember { mutableStateOf(x) } |
| Survive rotation and process death (text input, selected tab) | rememberSaveable { mutableStateOf(x) } |
| Value computed from other state that changes less often | remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } |
| Screen/business state | ViewModel StateFlow, collected with lifecycle |
| Reading scroll/animation state frequently | Read in a lambda modifier (Modifier.offset { }, graphicsLayer { }) so only layout/draw reruns |
Use mutableIntStateOf, mutableFloatStateOf for primitives to avoid boxing.
Side effects
| Situation | API |
|---|---|
| Run a suspend function when a key changes (load, scroll to item) | LaunchedEffect(key) { ... } |
| Register and unregister a listener/observer | DisposableEffect(key) { ...; onDispose { ... } } |
| Launch a coroutine from a click handler | val scope = rememberCoroutineScope() then scope.launch { } |
| Use the latest lambda inside a long-running effect without restarting it | val current by rememberUpdatedState(onTimeout) |
| Turn Compose state into a Flow | snapshotFlow { listState.firstVisibleItemIndex } inside LaunchedEffect |
| Push Compose state to non-Compose code after every successful recomposition | SideEffect { } |
- Key effects on exactly what should restart them.
LaunchedEffect(Unit)runs once per entry into composition; it reruns after rotation unless the work is in the ViewModel. - One-off events (navigate, show snackbar): expose them as state the UI consumes and
acknowledges, or as a
Channel-backed flow collected in aLaunchedEffect; do not trigger navigation from inside composition.
Performance
- Strong skipping makes most composables skippable, but unstable lambdas and
List/Mapparameters from non-Compose modules can still force recomposition. Prefer immutable collections (kotlinx.collections.immutable) or mark wrappers@Immutableonly when true. - Inspect with Layout Inspector recomposition counts; generate compiler reports when needed:
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
- Lazy lists: always pass
keyfrom stable IDs andcontentTypefor mixed rows; never nest a vertically scrolling lazy list inside averticalScrollcolumn. - Measure in release builds with R8 and a Baseline Profile; debug Compose is much slower.
Theming
@Composable
fun AcmeTheme(darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit) {
val context = LocalContext.current
val colors = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
darkTheme -> DarkColors
else -> LightColors
}
CompositionLocalProvider(LocalSpacing provides Spacing()) {
MaterialTheme(colorScheme = colors, typography = AcmeTypography, content = content)
}
}
@Immutable data class Spacing(val small: Dp = 8.dp, val medium: Dp = 16.dp, val large: Dp = 24.dp)
val LocalSpacing = staticCompositionLocalOf { Spacing() }
- Widgets read
MaterialTheme.colorScheme.primary,MaterialTheme.typography.titleMedium; no hard-coded colours orspsizes in screens. - Use
staticCompositionLocalOffor values that rarely change (tokens),compositionLocalOffor values that change at runtime. - Text sizes in
sp, layout indp; test with font scale 200%.
Edge to edge and insets
Apps targeting API 35+ are edge-to-edge by default. Call enableEdgeToEdge() in
onCreate for consistent behaviour on older versions, pass Scaffold’s innerPadding
to content, and use Modifier.windowInsetsPadding(WindowInsets.safeDrawing) or
imePadding() for screens without a Scaffold. Check that the last list item is not
hidden behind the navigation bar.
Navigation
Navigation Compose 2.8+ supports type-safe routes with @Serializable classes:
@Serializable data object OrdersList
@Serializable data class OrderDetail(val id: String)
NavHost(navController, startDestination = OrdersList) {
composable<OrdersList> { OrdersRoute(onOpenOrder = { navController.navigate(OrderDetail(it)) }) }
composable<OrderDetail> { entry -> OrderDetailRoute(entry.toRoute<OrderDetail>().id) }
}
Navigation 3 is a newer, state-based alternative; follow whatever the project already uses.
Gotchas
- Creating objects in composition without
remember(formatters,Paint, lists) reallocates on every recomposition. - Reading a frequently changing state (scroll offset) high in the tree recomposes the whole screen; read it as low as possible or in a lambda modifier.
rememberinsideitems { }is lost when the item scrolls away; hoist per-item state into the model keyed by ID.rememberSaveableonly stores Bundle-compatible types; use@Parcelizeor a customSaverfor others.- Modifier order matters:
padding().clickable()makes the padding unclickable;clickable().padding()includes it.
Verification
@get:Rule val composeRule = createComposeRule()
@Test fun showsErrorAndRetries() {
var retried = false
composeRule.setContent {
AcmeTheme { OrdersScreen(OrdersUiState.Error("Offline"), onRefresh = { retried = true }, onOpenOrder = {}) }
}
composeRule.onNodeWithText("Offline").assertIsDisplayed()
composeRule.onNodeWithText("Retry").performClick()
assertTrue(retried)
}
./gradlew :app:testDebugUnitTest # Robolectric-based Compose tests if configured
./gradlew :app:connectedDebugAndroidTest # needs an emulator or device
./gradlew :app:lintDebug
Done when tests and lint pass, each screen has @Previews for light, dark and large font,
and rotation plus “Don’t keep activities” keep the state the user expects.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Android Jetpack Compose"
short_description: "Stateless screens, keyed effects, stable lists"
default_prompt: "$android-jetpack-compose Convert this XML layout screen to Jetpack Compose with a stateless content composable" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.