Composables

Android UI Engineering

Community

GuillemRoca

Use when building UI with Jetpack Compose. Covers component design, state hoisting, recomposition optimization, Material 3 theming, Navigation 3, adaptive layouts for large screens, previews, screenshot verification, and XML interop.

Overview

Repository
GuillemRoca/agent-skills-android
Publisher
GuillemRoca
Stars
6
Forks
3
Bundled files
0
License
MIT
Updated
Oct 3, 2026

Installation

tmp=$(mktemp -d)
git clone --depth 1 https://github.com/GuillemRoca/agent-skills-android.git "$tmp"
mkdir -p ~/.claude/skills
cp -R "$tmp/skills/android-ui-engineering" ~/.claude/skills/android-ui-engineering
rm -rf "$tmp"

Instructions

Android UI Engineering

Overview

Build production-quality Android UI with Jetpack Compose. This skill covers component architecture, state management, Material 3 theming, navigation, performance optimization (recomposition), accessibility, and interoperability with legacy XML views.

When to Use

  • Building new screens or UI components with Compose
  • Refactoring XML layouts to Compose
  • Debugging recomposition or performance issues in Compose
  • Implementing Material 3 design system
  • Setting up navigation between screens
  • Adapting layouts for tablets, foldables, and desktop windows

Skip when: Modifying non-UI code (repositories, use cases, data layer).

Core Process

Step 1: Component Architecture
  1. Stateless composables with state hoisting:
// Stateless — reusable, testable, previewable
@Composable
fun TaskItem(
    task: Task,
    onToggle: (String) -> Unit,
    onDelete: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    ListItem(
        headlineContent = { Text(task.title) },
        leadingContent = {
            Checkbox(
                checked = task.completed,
                onCheckedChange = { onToggle(task.id) }
            )
        },
        trailingContent = {
            IconButton(onClick = { onDelete(task.id) }) {
                Icon(Icons.Default.Delete, contentDescription = "Delete task")
            }
        },
        modifier = modifier,
    )
}

// Stateful wrapper — connects to ViewModel
@Composable
fun TaskListScreen(
    viewModel: TaskListViewModel = hiltViewModel(),
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    TaskListContent(
        uiState = uiState,
        onToggle = viewModel::toggleTask,
        onDelete = viewModel::deleteTask,
    )
}
  1. Component rules:
    • Single responsibility — one composable does one thing
    • Accept Modifier parameter — always last with default Modifier
    • Hoist state — push state up, push events down
    • Stateless by default — only use remember when necessary
    • Composition over configuration — slots and lambdas over boolean flags
Step 2: State Management in Compose
  1. Collect state lifecycle-aware:
// Always use collectAsStateWithLifecycle (not collectAsState)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
  1. Handle all states:
@Composable
fun TaskListContent(
    uiState: TaskListUiState,
    onToggle: (String) -> Unit,
    onDelete: (String) -> Unit,
) {
    when (uiState) {
        is TaskListUiState.Loading -> LoadingIndicator()
        is TaskListUiState.Success -> {
            if (uiState.tasks.isEmpty()) {
                EmptyState(message = "No tasks yet")
            } else {
                TaskList(
                    tasks = uiState.tasks,
                    onToggle = onToggle,
                    onDelete = onDelete,
                )
            }
        }
        is TaskListUiState.Error -> ErrorState(
            message = uiState.message,
            onRetry = { /* trigger refresh */ },
        )
    }
}
Step 3: Material 3 Theming
  1. Set up the theme:
@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit,
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        darkTheme -> darkColorScheme()
        else -> lightColorScheme()
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        content = content,
    )
}
  1. Use Material tokens, not hardcoded values:
// GOOD: uses theme tokens
Text(
    text = "Title",
    style = MaterialTheme.typography.headlineMedium,
    color = MaterialTheme.colorScheme.onSurface,
)

// BAD: hardcoded values
Text(
    text = "Title",
    fontSize = 24.sp,
    color = Color(0xFF000000),
)
Step 4: Navigation (Navigation 3)

Use Navigation 3 (androidx.navigation3:navigation3-runtime + navigation3-ui, stable) for new Compose apps. The back stack is state you own — no opaque NavController. For deep links, multiple back stacks, scenes, conditional flows, and Nav2→Nav3 migration, see android-skills:navigation-3 (optional Google companion plugin, see README).

  1. Navigation 3 setup:
// Routes are serializable Kotlin types — compile-time safe, no route strings
@Serializable data object TaskList : NavKey
@Serializable data class TaskDetail(val taskId: String) : NavKey

@Composable
fun AppNavigation(modifier: Modifier = Modifier) {
    val backStack = rememberNavBackStack(TaskList)

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryProvider = entryProvider {
            entry<TaskList> {
                TaskListScreen(
                    onNavigateToDetail = { taskId -> backStack.add(TaskDetail(taskId)) },
                )
            }
            entry<TaskDetail> { key ->
                TaskDetailScreen(
                    taskId = key.taskId,
                    onNavigateBack = { backStack.removeLastOrNull() },
                )
            }
        },
        modifier = modifier,
    )
}
  1. Navigation rules:
    • The back stack (rememberNavBackStack) lives in the navigation host; navigate by mutating it (add, removeLastOrNull) — it is a SnapshotStateList, so NavDisplay reacts automatically
    • Screens receive navigation callbacks (onNavigateToDetail), never the back stack itself
    • Routes are @Serializable types, not strings — arguments are constructor parameters
    • Multi-pane layouts use Nav3 Scenes via androidx.compose.material3.adaptive:adaptive-navigation3 (see Step 5) — two destinations can be visible at once, no dual-NavHost workarounds
    • Existing apps on Navigation 2: stay on type-safe routes (@Serializable destinations, Nav 2.8+) and migrate with the official Nav2→Nav3 guide (developer.android.com/guide/navigation/navigation-3/migration-guide) — see deprecation-and-migration
    • Deep links: verify HTTPS App Links with assetlinks.json + android:autoVerify="true"; validate every parameter of an inbound link before acting on it (see security-and-hardening)
Step 5: Adaptive Layouts
  1. Drive layout off window size classes — never orientation or device type. Targeting API 37, this stops being optional: screenOrientation/resizeableActivity restrictions are ignored on displays ≥600dp, with no opt-out. Portrait-only apps get letterboxed into windows they never designed for.
// WindowSizeClass with the V2 breakpoints (adds Large ≥1200dp, Extra-large)
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val useTwoPane = windowSizeClass.isWidthAtLeastBreakpoint(
    WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND  // 840dp
)

// Material 3 adaptive scaffolds do the switching for you:
// - NavigationSuiteScaffold: bottom bar <-> nav rail by window size
// - NavigableListDetailPaneScaffold: list+detail side by side on Expanded+
// (androidx.compose.material3.adaptive:adaptive-layout / adaptive-navigation;
//  pass supportLargeAndXLargeWidth = true to surface the Large/XL classes)
  1. Adaptive rules:
    • Compact (<600dp) and Medium: single pane; Expanded (≥840dp) and up: multi-pane via ListDetailPaneScaffold/SupportingPaneScaffold
    • Edge-to-edge is the default (enforced targeting Android 15+): call enableEdgeToEdge(), consume WindowInsets in scaffolds, never rely on statusBarColor
    • Predictive back: use PredictiveBackHandler/OnBackPressedDispatcher — never override onBackPressed() (default-on targeting 36+)
    • State survives size changes: ViewModel + rememberSaveable, not configChanges hacks
    • Deeper guides: android-skills:adaptive (pointer/keyboard input, Grid/FlexBox, Nav3 scenes) and android-skills:edge-to-edge (insets, IME, system bar legibility) (optional Google companion plugin, see README)
Step 6: Recomposition Optimization
  1. Avoid unnecessary recompositions:
// Use stable types for state (data classes, immutable collections)
// Avoid lambdas that create new instances on every recomposition
// BAD: creates new lambda each recomposition
items(tasks) { task ->
    TaskItem(onToggle = { viewModel.toggle(task.id) })
}

// GOOD: use method reference or remember
items(tasks) { task ->
    TaskItem(onToggle = viewModel::toggleTask)
}

// Use derivedStateOf for computed values
val showFab by remember {
    derivedStateOf { listState.firstVisibleItemIndex == 0 }
}

// Use key() for list items
LazyColumn {
    items(tasks, key = { it.id }) { task ->
        TaskItem(task = task)
    }
}
  1. Recomposition debugging:
    • Enable recomposition counts in Layout Inspector
    • Use @Stable annotation for classes that Compose should treat as stable
    • Use ImmutableList from kotlinx.collections.immutable for list parameters
Step 7: Previews — and Verify Them
  1. Write previews for every screen and component:
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
@Composable
private fun TaskItemPreview() {
    AppTheme {
        TaskItem(
            task = Task(id = "1", title = "Buy groceries", completed = false),
            onToggle = {},
            onDelete = {},
        )
    }
}

@Preview(showBackground = true, device = Devices.PIXEL_7)
@Composable
private fun TaskListScreenPreview() {
    AppTheme {
        TaskListContent(
            uiState = TaskListUiState.Success(
                tasks = listOf(
                    Task("1", "Buy groceries", false),
                    Task("2", "Walk the dog", true),
                )
            ),
            onToggle = {},
            onDelete = {},
        )
    }
}
  1. Previews are verifiable — look at what you built:

    • Agent-side rendering: android studio render-compose-preview --output-image-file=preview.png <file> <composable> renders a @Preview to PNG without a device, so you can inspect the UI you just wrote instead of assuming it looks right (requires a running Android Studio with Gemini; see ../../references/android-cli-reference.md)
    • Screenshot tests: lock previews in with Compose Preview Screenshot Testing or Roborazzi so regressions fail CI — patterns in ../../references/testing-patterns.md, workflow in android-device-testing
Step 8: XML Interop (Legacy)

For a full screen-by-screen XML→Compose migration workflow, see android-skills:migrate-xml-views-to-jetpack-compose (optional Google companion plugin, see README).

  1. Compose in XML:
<androidx.compose.ui.platform.ComposeView
    android:id="@+id/compose_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />
binding.composeView.setContent {
    AppTheme {
        TaskItem(task = task, onToggle = {}, onDelete = {})
    }
}
  1. XML in Compose:
@Composable
fun LegacyMapView(modifier: Modifier = Modifier) {
    AndroidView(
        factory = { context -> MapView(context).apply { onCreate(null) } },
        update = { mapView -> /* update map */ },
        modifier = modifier,
    )
}

Common Rationalizations

Shortcut Why It Fails
"I'll add the modifier parameter later" Every composable consumer will need to add it later. Do it now.
"State hoisting is overkill for this screen" Unhoist state and you can't preview, test, or reuse the composable.
"I'll skip the empty/error states" Users will see a blank screen. Handle all states.
"Recomposition optimization is premature" Only premature if you haven't measured. Profile first, then decide.
"Previews are extra work" Previews catch issues faster than running the app. They pay for themselves.

Red Flags

  • Composable without Modifier parameter
  • collectAsState instead of collectAsStateWithLifecycle
  • Back stack (or a NavController) passed directly to screen composables
  • String routes or new NavHost/rememberNavController code in a Nav3 app
  • Layout branching on orientation or "isTablet" instead of window size class
  • android:screenOrientation="portrait" on activities (ignored ≥600dp from API 37)
  • Hardcoded colors/sizes instead of Material theme tokens
  • Missing loading, empty, or error states
  • No @Preview functions
  • ViewModel instantiated inside composables (use hiltViewModel())
  • Mutable state in composable parameters

Verification

  • All composables accept Modifier parameter
  • State hoisted — composables are stateless and testable
  • All UI states handled (loading, success, empty, error)
  • Material 3 theme tokens used (no hardcoded colors/sizes)
  • Navigation callbacks passed to screens (not the back stack/NavController)
  • Routes are @Serializable types rendered via NavDisplay (Navigation 3)
  • Layout verified at Compact and Expanded window sizes (resizable emulator or @Preview(device = ...))
  • collectAsStateWithLifecycle used for Flow collection
  • Previews exist for screens and key components — and rendered/screenshot-tested, not just written
  • ./gradlew assembleDebug builds successfully
  • Layout Inspector shows reasonable recomposition counts
  • On-device hierarchy inspected via android layout --pretty when verifying Compose output against @Preview (see ../../references/android-cli-reference.md)

FAQ

What does Android UI Engineering do?

Use when building UI with Jetpack Compose. Covers component design, state hoisting, recomposition optimization, Material 3 theming, Navigation 3, adaptive layouts for large screens, previews, screenshot verification, and XML interop.

Who made Android UI Engineering?

GuillemRoca (a community member) publishes it in the GuillemRoca/agent-skills-android repository on GitHub.

Is Android UI Engineering free?

Yes. Android UI Engineering is open source, published by GuillemRoca under the MIT license.

What comes with Android UI Engineering?

Just its instructions. Everything the agent needs is in one SKILL.md file.

Which agents can use Android UI Engineering?

Any coding agent that reads SKILL.md files, including Claude Code, Codex, Cursor, GitHub Copilot and Gemini CLI. The agent loads the skill when a task matches its description.

What are agent skills?

An agent skill is a folder with a SKILL.md file: instructions that teach a coding agent how to do one kind of task, and optionally files it can read while it works. The agent keeps only the skill's name and description in mind and reads the rest when a task calls for it.

How are agent skills different from MCP servers?

An MCP server gives an agent new tools to call, code that runs somewhere and returns results. A skill gives it knowledge: how to approach a task with the tools it already has. Many agents use both.

How can my agent test my Android app?

Robomori lets coding agents create, start and control Android emulators and devices from one command, so they can install your app, take screenshots and check dark mode, font sizes and languages.

You're subscribed

The next lesson lands in on Wednesday.