Install
$ agentstack add skill-guillemroca-agent-skills-android-android-ui-engineering ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
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
- 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,
)
}
- Component rules:
- Single responsibility — one composable does one thing
- Accept
Modifierparameter — always last with defaultModifier - Hoist state — push state up, push events down
- Stateless by default — only use
rememberwhen necessary - Composition over configuration — slots and lambdas over boolean flags
Step 2: State Management in Compose
- Collect state lifecycle-aware:
// Always use collectAsStateWithLifecycle (not collectAsState)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- 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
- 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,
)
}
- 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.
- 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 {
TaskListScreen(
onNavigateToDetail = { taskId -> backStack.add(TaskDetail(taskId)) },
)
}
entry { key ->
TaskDetailScreen(
taskId = key.taskId,
onNavigateBack = { backStack.removeLastOrNull() },
)
}
},
modifier = modifier,
)
}
- Navigation rules:
- The back stack (
rememberNavBackStack) lives in the navigation host; navigate by mutating it (add,removeLastOrNull) — it is aSnapshotStateList, soNavDisplayreacts automatically - Screens receive navigation callbacks (
onNavigateToDetail), never the back stack itself - Routes are
@Serializabletypes, 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 (
@Serializabledestinations, Nav 2.8+) and migrate with the official Nav2→Nav3 guide (developer.android.com/guide/navigation/navigation-3/migration-guide) — seedeprecation-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 (seesecurity-and-hardening)
Step 5: Adaptive Layouts
- Drive layout off window size classes — never orientation or device type. Targeting API 37, this stops being optional:
screenOrientation/resizeableActivityrestrictions 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)
- Adaptive rules:
- Compact (
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) } }
12. **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
13. **Write previews for every screen and component:**
```kotlin
@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 = {},
)
}
}
- Previews are verifiable — look at what you built:
- Agent-side rendering:
android studio render-compose-preview --output-image-file=preview.pngrenders a@Previewto 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; seereferences/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 inandroid-device-testing
Step 8: XML Interop (Legacy)
- Compose in XML:
binding.composeView.setContent {
AppTheme {
TaskItem(task = task, onToggle = {}, onDelete = {})
}
}
- 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
Modifierparameter collectAsStateinstead ofcollectAsStateWithLifecycle- Back stack (or a NavController) passed directly to screen composables
- String routes or new
NavHost/rememberNavControllercode 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
@Previewfunctions - ViewModel instantiated inside composables (use
hiltViewModel()) - Mutable state in composable parameters
Verification
- [ ] All composables accept
Modifierparameter - [ ] 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
@Serializabletypes rendered viaNavDisplay(Navigation 3) - [ ] Layout verified at Compact and Expanded window sizes (resizable emulator or
@Preview(device = ...)) - [ ]
collectAsStateWithLifecycleused for Flow collection - [ ] Previews exist for screens and key components — and rendered/screenshot-tested, not just written
- [ ]
./gradlew assembleDebugbuilds successfully - [ ] Layout Inspector shows reasonable recomposition counts
- [ ] On-device hierarchy inspected via
android layout --prettywhen verifying Compose output against@Preview(seereferences/android-cli-reference.md)
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: GuillemRoca
- Source: GuillemRoca/agent-skills-android
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.