Skip to content
cccskills
FreePublished on GitHub

android-compose-patterns

Use when building native Android with Jetpack Compose - stateless composables, state hoisting, ViewModel-owned state, edge-to-edge, predictive back, adaptive layout, atomic components, and Material theming over hardcoded values

The English summary is being prepared. Showing the original description.

See how to install

Files included (1)

  • SKILL.md7.4 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

Jetpack Compose Patterns

Overview

Native Android (Jetpack Compose) is chosen when a task needs deep Android integration or the app is already native (see native-vs-flutter-decision). Same discipline as Flutter/SwiftUI: small stateless composables, state hoisted out, reusable atomic components.

Core principle: Composables are stateless functions of state. State is hoisted to a ViewModel; composables emit events upward.

Rules

  • Stateless composables + state hoisting. A composable takes its state as parameters and exposes callbacks (onXxx) — it does not own app state. The screen-level composable connects a ViewModel to the stateless UI.
  • ViewModel owns state and logic, exposed as StateFlow/UiState; the composable collects it with collectAsStateWithLifecycle(). Networking/business rules live in the ViewModel + repository, never in the composable.
  • Model UI state as a sealed hierarchy: Loading | Data | Empty | Error — when over it renders every branch. No happy-path-only screens.
  • Atomic components: shared library of reusable composables (atoms → molecules → organisms). Reuse before building; grep before adding.
  • Material theme, not hardcoded values: MaterialTheme.colorScheme / typography / a spacing tokens object (MaterialTheme.spacing, see mobile-design-system-foundation) — never scattered Color(0xFF...) and magic dp. Support dark theme + dynamic color.
  • remember/derivedStateOf to avoid recomputation; hoist remembered state only when it's genuinely local (scroll, text field).
  • @Preview reusable composables in their key states, and across the matrix (see below).

Edge-to-edge (enforced from targetSdk 35)

  • Call enableEdgeToEdge() before setContent in the activity — the opt-out is gone at targetSdk 35+.
  • Consume insets at the Scaffold: use its innerPadding and Modifier.consumeWindowInsets(innerPadding) on the content so a nested scrollable doesn't double-apply it.
  • IME padding: apply Modifier.imePadding() once, at the container that should move with the keyboard — applying it a second time further down the tree produces extra empty space.
  • android:windowSoftInputMode="adjustResize" in the manifest for screens with text input.
  • NavigationSuiteScaffold does not automatically propagate its padding to the content slot — apply insets explicitly inside it, don't assume it's handled.

Predictive back (default at targetSdk 36)

  • onBackPressed() / OnBackPressedCallback without isEnabled toggling is not called the same way once predictive back is the default — use BackHandler (simple) or PredictiveBackHandler (custom transition) in Compose, or Navigation 3's built-in handling.
  • Verify back navigation in this run rather than assuming the old override still fires.

Adaptive layout

  • currentWindowAdaptiveInfo().windowSizeClass to branch layout by available width/height — never by Configuration.orientation or a hardcoded device check.
  • NavigationSuiteScaffold switches a bottom nav bar to a nav rail automatically at the window-size-class breakpoint — prefer it over hand-rolling the switch.
  • GridCells.Adaptive(minWidth) for grids that should reflow by available width.
  • Android 16 (opt-out via PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY) and Android 17/API 37 (no opt-out) ignore screenOrientation/resizeableActivity restrictions on screens ≥600dp — design every screen to work in both orientations and at tablet width; don't rely on an orientation lock to avoid building the wider layout.

Previews and tests across the matrix

  • @PreviewScreenSizes, @PreviewFontScale, @PreviewLightDark, or a custom multi-preview with Devices.PHONE/Devices.TABLET — see mobile-visual-self-review for the full size/text-scale/dark matrix.
  • DeviceConfigurationOverride(ForcedSize(...) then FontScale(...) then DarkMode(...)) for instrumented matrix tests.
  • rule.enableAccessibilityChecks() on an API 34+ device/emulator before UI-mutating test actions (inconclusive on Robolectric — say so if that's all you ran); see mobile-accessibility.
  • "Run screenshot tests but do not update reference images" to "fix" a failure — a changed golden is a visual regression until a human confirms it's intended.

Semantics

  • contentDescription on every Image/Icon that carries meaning; null explicitly for a purely decorative one.
  • Modifier.semantics(mergeDescendants = true) on a composite row so a screen reader announces it once.
  • Touch targets ≥48dp — Modifier.size/minimumInteractiveComponentSize().

Worked Example

// ViewModel owns state + logic (unit-testable, no Compose)
class TaskListViewModel(private val repo: TaskRepository) : ViewModel() {
    private val _state = MutableStateFlow<UiState<List<Task>>>(UiState.Loading)
    val state: StateFlow<UiState<List<Task>>> = _state
    fun load() = viewModelScope.launch {
        _state.value = UiState.Loading
        _state.value = try {
            val items = repo.list()
            if (items.isEmpty()) UiState.Empty else UiState.Data(items)
        } catch (e: CancellationException) {
            throw e                              // never swallow cancellation
        } catch (e: Exception) {
            UiState.Error(e)
        }
    }
}

// stateless composable renders state, emits events
@Composable
fun TaskListScreen(state: UiState<List<Task>>, onRetry: () -> Unit) = when (state) {
    is UiState.Loading -> AppSpinner()
    is UiState.Error   -> AppErrorView(message = state.cause.message, onRetry = onRetry)
    is UiState.Data    -> TaskList(tasks = state.value)
    is UiState.Empty   -> EmptyView()
}

The fix from the previous version: runCatching { } wrapped around a suspend call inside viewModelScope.launch catches CancellationException along with real errors, which breaks structured concurrency (a cancelled screen keeps "loading" instead of stopping) — use try/catch and rethrow CancellationException explicitly. The Empty branch is now actually emitted when the list comes back empty, not just declared and never reached.

Testing

  • Unit-test the ViewModel with a fake/mockk repository and a test dispatcher — no Compose.
  • Compose UI tests (createComposeRule) assert visible behavior and clicks. Test-first.

Common Mistakes

  • A composable holding app state or calling the repository.
  • Business logic in the composable instead of the ViewModel.
  • Hardcoded colors/dp instead of MaterialTheme + tokens.
  • Collecting flows without collectAsStateWithLifecycle (leaks/waste).
  • Only the data branch rendered — Empty declared but never emitted.
  • runCatching around a suspend call, swallowing CancellationException.
  • Assuming edge-to-edge/predictive back still behave like targetSdk <35/<36 after a target bump.

Red Flags

  • A @Composable that imports a repository or Retrofit service.
  • Color(0xFF...)/magic dp outside the theme.
  • No error/empty branch in the when.
  • enableEdgeToEdge() missing from a new or recently targetSdk-bumped activity.
  • A screen that only renders correctly in one orientation.

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

More skills from this repository

Use when writing acceptance criteria for a task - express each as an observable Given/When/Then that QA can execute, including negative cases

The English summary is being prepared. Showing the original description.

makifbaysal/tasktrooper117Updated Oct 20, 2026

Use when the diff adds or changes an endpoint, resolver, RPC, job or query that takes an object id, a role check, a request binding or a tenant filter - BOLA/IDOR, function-level authorization, mass assignment and tenant scoping

The English summary is being prepared. Showing the original description.

makifbaysal/tasktrooper117Updated Oct 20, 2026

Use on every UI change - semantic HTML, labels for controls, keyboard-navigable dialogs/menus, visible focus, and never color as the only signal

The English summary is being prepared. Showing the original description.

makifbaysal/tasktrooper117Updated Oct 20, 2026

Use when a task changes any screen, form, dialog, menu or control - Lighthouse/axe scan of the changed screens, a keyboard walk, and the thresholds that fail a task

The English summary is being prepared. Showing the original description.

makifbaysal/tasktrooper117Updated Oct 20, 2026

How to work a task returned with review, QA or UAT findings. Use when a task is in need_revision or PR review comments are in your context.

The English summary is being prepared. Showing the original description.

makifbaysal/tasktrooper117Updated Oct 20, 2026

Use when the repository keeps its alerting in code, or a person asks for alerts outside TaskTrooper - PrometheusRule YAML, a Cloud Monitoring alert policy in Terraform, a Datadog monitor, each routed to TaskTrooper's alert ingest URL so it opens an incident here

The English summary is being prepared. Showing the original description.

makifbaysal/tasktrooper117Updated Oct 20, 2026

See all skills by makifbaysal

Report a problem with this skill