Android & Mobile UI/UX Engineering Guide
A practical, production-tested operational framework for designing and building native Android applications with Jetpack Compose, Material 3, offline-first resilience, and human-first mobile ergonomics.
1. Core Mobile Ergonomics & Thumb Zone
Mobile devices are held in one hand over 70% of the time. Design interfaces around thumb reachability, tactile feedback, and minimal physical friction.
1.1 The Ergonomic Reachability Hierarchy
+---------------------------+ <- Top of Screen (Display Only)
| Status & Headers | Hardest to reach. Reserve for passive
| Title, Quota Meter | status, search bars, and live meters.
+---------------------------+
| | <- Middle Reach Zone
| Scrollable Content | Ideal for list items, details, and
| Inbox Cards, Snippets | browsable content cards.
| |
+---------------------------+
| Primary Action Targets | <- Prime Thumb Zone (Bottom 40%)
| Send Buttons, Tabs | Easiest to tap. Keep navigation, CTAs,
| Bottom Sheets, Filters | and interactive controls here.
+---------------------------+
1.2 Non-Negotiable Touch Target Standards
- Minimum Interactive Target: Every clickable element must meet or exceed $48 \times 48\text{dp}$ (
Modifier.sizeIn(minWidth = 48.dp, minHeight = 48.dp)), even if the visual icon is $24\text{dp}$. - Edge Insets & Safe Areas: Always wrap root Compose layouts in
Scaffoldor applyModifier.windowInsetsPadding(WindowInsets.safeDrawing)to account for camera cutouts, gestures, and navigation bars. - Form Controls at the Bottom: Place text inputs, submission buttons, and confirmation dialogs in
ModalBottomSheetor bottom-pinned cards so the software keyboard (imePadding()) doesn't obscure the active field.
2. Speed & Perceived Performance
Mobile users perceive delay immediately. Never block navigation or show blank loading states when previously fetched data is available.
2.1 The Zero-Skeleton Rule (Stale-While-Revalidate)
- The Problem: Showing a full-page skeleton loader or circular spinner every time the user taps the Inbox tab feels slow, jarring, and unpolished.
- The Solution: Implement a Stale-While-Revalidate (SWR) cache pattern:
- Store fetched items in local encrypted storage (
EncryptedSharedPreferencesor SQLite/Room). - When entering the screen, render cached items immediately (0ms delay).
- Revalidate in the background if the cache is older than 5–10 minutes.
- When refreshed data arrives, smoothly append/update items without clearing the list or showing blank placeholders.
- Only show subtle indicators (e.g., a pull-to-refresh spinner or a tiny top progress bar) during background refreshes.
- Store fetched items in local encrypted storage (
2.2 Offline-First Outbox Pattern
Never fail a user action because the mobile device has a spotty cellular connection or is temporarily in airplane mode.
flowchart TD
UserAction["User Shares File / Types Note"] --> LocalDraft["1. Save to Local Outbox Drafts"]
LocalDraft --> RenderOutbox["2. Render in Outbox Queue Card Immediately"]
RenderOutbox --> CheckNet{"Connected to Network?"}
CheckNet -- Yes --> DirectUpload["3. Upload Directly"]
CheckNet -- No --> EnqueueWork["3. Enqueue WorkManager Job (NetworkType.CONNECTED)"]
EnqueueWork --> WaitConn["4. Wait for Wi-Fi / Cellular"]
WaitConn --> AutoDispatch["5. Background Upload with Exponential Backoff"]
DirectUpload --> Success["6. Update UI State & Notify Toast"]
AutoDispatch --> Success
Outbox UI States:
- Pending: "Waiting for network…" with a paused cloud icon.
- Uploading: "Sending 42%…" with a linear progress indicator.
- Failed / Retrying: Clear status indicator with explicit Retry and Discard buttons.
3. Motion, Transitions & Tactile Haptics
Modern Android design (Android 14/15+) requires dynamic, physics-based motion and tactile sensory feedback to feel native and responsive.
3.1 Edge-to-Edge & System Bar Handling
Always draw behind system bars globally in MainActivity.kt:
enableEdgeToEdge()
setContent {
VaultTheme {
Scaffold(
contentWindowInsets = WindowInsets.safeDrawing
) { innerPadding ->
AppNavHost(Modifier.padding(innerPadding))
}
}
}
3.2 Predictive Back Gestures
Enable predictive back in AndroidManifest.xml (android:enableOnBackInvokedCallback="true") and use PredictiveBackHandler for bottom sheets and detail drawers:
PredictiveBackHandler(enabled = isDetailOpen) { progressFlow ->
try {
progressFlow.collect { backEvent ->
// Scale or shift detail sheet proportional to backEvent.progress (0f to 1f)
detailScale = 1f - (backEvent.progress * 0.1f)
}
// User committed back gesture
closeDetail()
} catch (e: CancellationException) {
// User aborted back gesture, spring back to 1f
detailScale = 1f
}
}
3.3 Shared Element Transitions
When transitioning between an Inbox list row and an item detail view, wrap screens in SharedTransitionLayout:
SharedTransitionLayout {
NavHost(...) {
composable("inbox") {
InboxScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this
)
}
composable("detail/{id}") {
DetailScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this
)
}
}
}
Use .sharedElement() or .sharedBounds() on thumbnail cards, avatar icons, and titles to create seamless transitions.
3.4 Tactile Haptics
Never leave user actions silent when a physical milestone occurs:
- Threshold Swipes (Swipe-to-Dismiss): Trigger
HapticFeedbackConstantsCompat.GESTURE_STARTwhen the swipe crosses the action threshold. - Copy to Clipboard / Send Completed: Trigger
HapticFeedbackConstantsCompat.CONFIRM. - Validation Failure / Network Error: Trigger
HapticFeedbackConstantsCompat.REJECT.
val view = LocalView.current
fun triggerHaptic(feedbackConstant: Int) {
view.performHapticFeedback(feedbackConstant)
}
3.5 Spring Physics over Linear Easing
Avoid rigid linear animations. Always use spring physics for gestures, sheets, and dismissals:
val offsetAnim by animateFloatAsState(
targetValue = targetOffset,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
4. Smart One-Tap Actions & System Integration
Mobile interfaces should minimize cognitive load by recognizing the payload type and offering the most relevant native system action.
4.1 Context-Aware Action Matrix
| Payload Type | Primary Action | Secondary Action | System Integration |
|---|---|---|---|
| URL / Web Link | Open in Browser (Intent.ACTION_VIEW) | Copy Link | Chrome Custom Tabs or system default browser |
| Text / Snippet | Copy Text | Share (Intent.ACTION_SEND) | Android ClipboardManager with instant Toast / Snackbar |
| File / Media | Download to Folder | Open File | Storage Access Framework (SAF) + FileProvider |
4.2 Direct File Downloads with Custom Default Directory
Prompting the user with the system file picker every time they download a file creates unnecessary friction.
- Allow the user to select a Default Download Folder once in Settings/Hub using
ActivityResultContracts.OpenDocumentTree(). - Persist the permission permanently:
context.contentResolver.takePersistableUriPermission( treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) - When tapping Download:
- If a default directory is set: Stream bytes directly using
DocumentFile.createFile(), show live byte progress, and fire a native completion Toast ("Downloaded document.pdf to Vault"). - If no default is set: Fall back to
ActivityResultContracts.CreateDocument().
- If a default directory is set: Stream bytes directly using
4.3 Safe File Opening with FileProvider
Never use insecure raw file:// URIs. Always delegate file viewing to native Android applications using FileProvider:
<!-- res/xml/file_paths.xml -->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="cached_files" path="." />
</paths>
val contentUri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
tempFile
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(contentUri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Open with..."))
5. Frictionless Mobile Authentication
Typing server URLs and long passwords on mobile touchscreens is prone to typos.
5.1 Login Priority Hierarchy
- Priority 1: QR Code Camera Scan (1-Second Pairing)
- Display a temporary, signed pairing QR code on the desktop web app.
- Mobile user opens app, taps Scan QR Code, points camera at the screen.
- App parses origin, token, and device name in a single pass—authenticated instantly.
- Priority 2: Username & Password with Collapsible Server URL
- 99% of logins use the default configured host.
- Present only Username and Password fields by default.
- Hide the server URL behind a subtle collapsible toggle:
"Server configuration (advanced)".
5.2 14-Day Sliding Session Persistence
- Store access (
va1_) and refresh (vr1_) tokens in AndroidEncryptedSharedPreferences. - Automatically slide/rotate the refresh token on active usage.
- As long as the user opens the app within 14 days, they remain logged in without re-authenticating.
6. Adaptive Layouts (Foldables & Tablets)
Build screens that scale smoothly across window size classes using WindowSizeClass:
- Compact (
<600dp): Single column, bottom navigation bar, modal bottom sheets. - Medium (
600dp - 840dp): Navigation rail, two-column grid. - Expanded (
>840dp): Persistent master-detail two-pane layout side-by-side (List on left, inspector on right).
7. Material 3 Color Contrast & State Accessibility (WCAG 2.2 AA / AAA)
Mobile devices are uniquely prone to contrast failure due to variable ambient lighting (direct sunlight, outdoor glare, OLED battery-saver dimming). Strive for WCAG 2.2 AAA text contrast ($\ge 7:1$) and strict compliance with WCAG 1.4.11 (non-text boundary contrast $\ge 3:1$) and WCAG 1.4.1 (use of color).
7.1 The Material 3 Dark-Mode FilterChip Trap
- The Defect: By default, Material 3's
FilterChipin dark mode usessecondaryContainer(#37474F) for its selected background against the darksurface(#14181E). This yields an unacceptable contrast ratio of 1.83:1. In low brightness or glare, users cannot tell which chip is active. - The Fix:
- Enforce high-contrast boundary definition with
selectedBorderColor = MaterialTheme.colorScheme.primary(which produces a 9.88:1 contrast ratio in dark mode). - Set
selectedBorderWidth = 1.5.dp. - Comply with WCAG 1.4.1 (states not dependent on color alone) by providing a leading checkmark icon:
FilterChip( selected = isSelected, onClick = { onSelect() }, label = { Text(label) }, leadingIcon = if (isSelected) { { Icon(Icons.Filled.Done, contentDescription = null, Modifier.size(16.dp)) } } else null, border = FilterChipDefaults.filterChipBorder( enabled = true, selected = isSelected, selectedBorderColor = MaterialTheme.colorScheme.primary, selectedBorderWidth = 1.5.dp ) )
- Enforce high-contrast boundary definition with
7.2 Sunlight & Glare Legibility Standards
- Primary Text vs Surface: Standardize dark mode on high-contrast text (
onSurface#E1E2E8or#F8FAFCon#14181E), achieving $\ge 13:1$ contrast (exceeding WCAG AAA). - Form Control Outlines:
OutlinedTextFieldand radio/checkbox borders must useoutlinetokens that maintain at least $3:1$ contrast against container cards (Color(0xFF8D9199)or explicitrgba(255, 255, 255, 0.20)). - Compact Preset Rows: When laying out compact chip rows (e.g. retention presets
1h,4h,12h,1d), preserve target sizes and applymaxLines = 1withselectedBorderColorso chips never wrap or look ambiguous.
8. Reference Open-Source Repositories
Inspect these industry-leading open-source Android projects for design and implementation inspiration:
android/nowinandroid: Google's flagship Material 3 Compose sample demonstrating dynamic theming, adaptive layouts, and clean architecture.skydoves/Pokedex-Compose: Production-grade shared element transitions (.sharedBounds/.sharedElement) and smooth detail sheet gestures.plainhub/plain-app: Outstanding implementation of local phone management, Material You theming, and responsive client-server syncing.Gurupreet/ComposeCookBook: Exhaustive catalog of production Compose animations, bottom bars, and custom canvas micro-interactions.signalapp/Signal-Android: Gold standard for local encrypted storage, background outbox workers, and network resilience.