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.

  • Mobile & Desktop
  • $android-jetpack-compose
  • 204 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- android-jetpack-compose

Installs to ~/.agents/skills/android-jetpack-compose/ for every project on this machine.

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

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.

~/.agents/skills/android-jetpack-compose/SKILL.md

--- name: android-jetpack-compose description: Builds Android UI with Jetpack Compose and Material 3 - stateless composables with state hoisting, remember, rememberSaveable and derivedStateOf,… license: MIT ---

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 of composeOptions.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 --offline only 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 = Modifier and applies it to its root, once.
  • collectAsStateWithLifecycle() (lifecycle-runtime-compose) stops collecting when the app is in the background; plain collectAsState() keeps flows running.

State APIs

NeedAPI
UI-only value that may reset on rotationremember { mutableStateOf(x) }
Survive rotation and process death (text input, selected tab)rememberSaveable { mutableStateOf(x) }
Value computed from other state that changes less oftenremember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
Screen/business stateViewModel StateFlow, collected with lifecycle
Reading scroll/animation state frequentlyRead in a lambda modifier (Modifier.offset { }, graphicsLayer { }) so only layout/draw reruns

Use mutableIntStateOf, mutableFloatStateOf for primitives to avoid boxing.

Side effects

SituationAPI
Run a suspend function when a key changes (load, scroll to item)LaunchedEffect(key) { ... }
Register and unregister a listener/observerDisposableEffect(key) { ...; onDispose { ... } }
Launch a coroutine from a click handlerval scope = rememberCoroutineScope() then scope.launch { }
Use the latest lambda inside a long-running effect without restarting itval current by rememberUpdatedState(onTimeout)
Turn Compose state into a FlowsnapshotFlow { listState.firstVisibleItemIndex } inside LaunchedEffect
Push Compose state to non-Compose code after every successful recompositionSideEffect { }
  • 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 a LaunchedEffect; do not trigger navigation from inside composition.

Performance

  • Strong skipping makes most composables skippable, but unstable lambdas and List/Map parameters from non-Compose modules can still force recomposition. Prefer immutable collections (kotlinx.collections.immutable) or mark wrappers @Immutable only 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 key from stable IDs and contentType for mixed rows; never nest a vertically scrolling lazy list inside a verticalScroll column.
  • 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 or sp sizes in screens.
  • Use staticCompositionLocalOf for values that rarely change (tokens), compositionLocalOf for values that change at runtime.
  • Text sizes in sp, layout in dp; 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 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.
  • remember inside items { } is lost when the item scrolls away; hoist per-item state into the model keyed by ID.
  • rememberSaveable only stores Bundle-compatible types; use @Parcelize or a custom Saver for 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem