| name | build-native-mobile-ui |
| description | Builds native mobile UI in SwiftUI (iOS) and Jetpack Compose (Android) — declarative layout (List/LazyVStack vs Scaffold/LazyColumn), unidirectional state with hoisting (@Observable vs ViewModel/StateFlow), typed navigation stacks with deep links, adaptive sizing (size classes/WindowSizeClass), light/dark theming via semantic tokens, lifecycle-correct side effects, recomposition control, and VoiceOver/TalkBack accessibility. |
| when_to_use | Implementing or reviewing a native iOS (SwiftUI) or Android (Jetpack Compose) screen/component — lists, forms, custom layouts, state hoisting, typed navigation, dark mode/Dynamic Type, adaptive phone/tablet/foldable, recomposition jank. Distinct from scaffold-cross-platform-app (React Native/Flutter, not native Swift/Kotlin), build-react-component (web/React), and audit-accessibility-wcag (web WCAG audit). |
When to Use
Reach for this skill when building or reviewing a native iOS/Android screen in a declarative UI framework (SwiftUI or Jetpack Compose, i.e. Swift/Kotlin — not React Native or Flutter):
- "Build a SwiftUI/Compose list-detail screen with pull-to-refresh"
- "Hoist this view state — the toggle should be controlled by the parent"
- "Add a NavigationStack / Compose Navigation route with a deep link"
- "Make this adapt to iPad / foldable / landscape (two-pane on wide)"
- "Support dark mode + Dynamic Type without truncation"
- "VoiceOver reads this button wrong / TalkBack skips the row"
- "This list janks / recomposes the whole screen on every keystroke"
NOT this skill:
- Cross-platform UI in React Native or Flutter (JSX/Dart, Expo Router, Riverpod/Bloc) → scaffold-cross-platform-app (this skill is native Swift/Kotlin only)
- Web/React components (JSX, hooks, DOM) → build-react-component
- CSS/Tailwind breakpoints and responsive web layout → style-responsive-tailwind
- Auditing a web page against WCAG success criteria → audit-accessibility-wcag
- Server cache, fetching, optimistic mutation, query invalidation → manage-client-server-state (this skill owns UI state, not network state)
- Architecting the token tiers/themes/multi-platform export pipeline → design-token-system (this skill consumes tokens in a screen, doesn't design the system)
- Converting a Figma/design spec into pixel-faithful code → implement-from-design (use this skill for the framework idioms once you have the spec)
- The server send path, payload schema, or token registration for push → implement-push-notifications (this skill owns the in-app deep-link router push taps land in)
- Code signing, build lanes, store upload, phased rollout → ship-mobile-app-store-release
- Profiling/fixing web load metrics (LCP/CLS) → optimize-core-web-vitals
Steps
-
Pick the container primitive by data shape — never default to a plain stack for collections. Lazy containers virtualize; eager ones build every child up front and jank past ~50 rows.
| Need | SwiftUI | Compose |
|---|
| Long/unbounded scrolling list | List (free separators, swipe, refresh) or LazyVStack in ScrollView | LazyColumn (with key = { it.id }) |
| Small fixed group (≤ ~20, all visible) | VStack/Form/Section | Column |
| Screen chrome (top bar, FAB, snackbar, insets) | NavigationStack + .toolbar | Scaffold(topBar, floatingActionButton, snackbarHost) |
| Grid | LazyVGrid(columns:) | LazyVerticalGrid(columns = GridCells.Adaptive(160.dp)) |
| Overlap / z-stack | ZStack | Box |
Always set stable item identity (List(items, id: \.id) / items(list, key = { it.id })) — without it, scroll position and animations break on reorder.
-
One source of truth, hoisted up; flow data down, events up. A child that owns the state it renders is unreusable and untestable. Make leaf views stateless (value + callback); keep state at the lowest common owner.
SwiftUI — child takes Binding, owns nothing:
struct ToggleRow: View {
let title: String
@Binding var isOn: Bool
var body: some View { (title, isOn: ) }
}
pushEnabled
(title: , isOn: )
Common Errors
VStack/Column for a long list. Builds every child eagerly → jank and memory blowup. Use LazyVStack/List / LazyColumn.
- No stable item key.
LazyColumn without key= (or List keyed by index) reorders/animates wrong and loses scroll on insert. Key by a stable id.
- State owned in the leaf you want to reuse. Child
@State/internal remember for what the parent should control → can't lift, can't test, drifts out of sync. Hoist: Binding / value+onValueChange.
remember { mutableStateOf(...) } for screen state. Lost on rotation/process death; doesn't survive nav. Put it in a ViewModel (or rememberSaveable for trivial UI bits).
- Collecting flow with
.collectAsState() instead of collectAsStateWithLifecycle() — keeps collecting in the background, wasting work and risking stale UI. Use the lifecycle-aware one.
- Side effect in
body/composable body. Network or mutableStateOf write during composition → infinite recomposition or duplicate loads. Move to .task/LaunchedEffect.
- Wrong/empty
LaunchedEffect key. LaunchedEffect(Unit) that reads id never reloads when id changes; over-keyed restarts constantly. Key on exactly the inputs the effect uses.
- Stringly-typed nav routes (
navigate("detail/$id") with manual parsing) — typos compile, args lose types, deep links break silently. Use type-safe routes / value: + navigationDestination(for:).
- Single shared back stack across tabs. Switching tabs nukes the other tab's history. Give each tab its own
NavHost/stack and save/restore it.
- Hardcoded padding for the notch/status bar / ignoring
innerPadding. Content slides under the bar or the keyboard. Honor safe area / apply Scaffold innerPadding + .
Verify
- Builds & previews render:
xcodebuild -scheme <S> -destination 'platform=iOS Simulator,name=iPhone 15' build / ./gradlew assembleDebug. SwiftUI #Preview and Compose @Preview show light and dark variants without crashing.
- List performance: scroll a 500+ item list on device — no dropped frames; inserting/removing keeps scroll position. (Compose: Layout Inspector → recomposition counts stay flat per row while scrolling; a row recomposing on unrelated state changes is a fail.)
- State hoisting holds: toggle the child's control, confirm the parent's single source of truth updates and no duplicate/stale copy exists; rotate the device (or trigger config change) — state survives (VM/
rememberSaveable), is not reset.
- Navigation & deep link: push → Back returns correctly; cold-launch the deep link (
xcrun simctl openurl booted app://item/42 / adb shell am start -a android.intent.action.VIEW -d "app://item/42") lands on the right screen with a sane back stack; switch tabs and return — the other tab's stack is preserved.
- Adaptivity: run iPhone portrait, iPhone landscape, and iPad / a foldable (or resizable emulator dragged across 600dp and 840dp) — layout switches single↔two-pane at the size-class boundary, nothing clips or overlaps.
- Dynamic Type / dark: set the largest accessibility text size and dark mode (iOS Settings → Accessibility → Larger Text; emulator font scale 1.3+ / Dark theme) — no truncation, no white-on-white, all controls reachable.
- Screen reader: enable VoiceOver (Accessibility Inspector → audit) / TalkBack — swipe through: every actionable element announces a name + role, decorative content is skipped, focus order is logical, and no target is below 44pt/48dp (Xcode Accessibility Inspector audit / Compose
testTagsAsResourceId + Accessibility Scanner report zero issues).
Done = the screen builds, previews render light+dark, a 500+ row list scrolls without dropped frames and without per-row recomposition on unrelated changes, state is hoisted and survives a config change, typed navigation + cold deep link land correctly with per-tab back stacks preserved, layout adapts across the size-class boundaries, and the accessibility inspector/scanner reports zero issues at the largest Dynamic Type / font scale.