AgentStack
SKILL verified MIT Self-run

Android Ui Engineering

skill-guillemroca-agent-skills-android-android-ui-engineering · by GuillemRoca

>-

No reviews yet
0 installs
4 views
0.0% view→install

Install

$ agentstack add skill-guillemroca-agent-skills-android-android-ui-engineering

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No 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.

Are you the author of Android Ui Engineering? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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

  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.

  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 {
                TaskListScreen(
                    onNavigateToDetail = { taskId -> backStack.add(TaskDetail(taskId)) },
                )
            }
            entry { 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 (

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 = {},
        )
    }
}
  1. Previews are verifiable — look at what you built:
  • Agent-side rendering: android studio render-compose-preview --output-image-file=preview.png 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)

  1. Compose in XML:
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)

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.