android-design-guidelines
npx skills add https://github.com/ehmo/platform-design-skills --skill android-design-guidelines
Agent 安装分布
Skill 文档
Android Platform Design Guidelines â Material Design 3
1. Material You & Theming [CRITICAL]
1.1 Dynamic Color
Enable dynamic color derived from the user’s wallpaper. Dynamic color is the default on Android 12+ and should be the primary theming strategy.
// Compose: Dynamic color 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
)
}
<!-- XML: Dynamic color in themes.xml -->
<style name="Theme.App" parent="Theme.Material3.DayNight.NoActionBar">
<item name="dynamicColorThemeOverlay">@style/ThemeOverlay.Material3.DynamicColors.DayNight</item>
</style>
Rules:
- R1.1: Always provide a fallback static color scheme for devices below Android 12.
- R1.2: Never hardcode color hex values in components. Always reference color roles from the theme.
- R1.3: Test with at least 3 different wallpapers to verify dynamic color harmony.
1.2 Color Roles
Material 3 defines a structured set of color roles. Use them semantically, not aesthetically.
| Role | Usage | On-Role |
|---|---|---|
primary |
Key actions, active states, FAB | onPrimary |
primaryContainer |
Less prominent primary elements | onPrimaryContainer |
secondary |
Supporting UI, filter chips | onSecondary |
secondaryContainer |
Navigation bar active indicator | onSecondaryContainer |
tertiary |
Accent, contrast, complementary | onTertiary |
tertiaryContainer |
Input fields, less prominent accents | onTertiaryContainer |
surface |
Backgrounds, cards, sheets | onSurface |
surfaceVariant |
Decorative elements, dividers | onSurfaceVariant |
error |
Error states, destructive actions | onError |
errorContainer |
Error backgrounds | onErrorContainer |
outline |
Borders, dividers | â |
outlineVariant |
Subtle borders | â |
inverseSurface |
Snackbar background | inverseOnSurface |
// Correct: semantic color roles
Text(
text = "Error message",
color = MaterialTheme.colorScheme.error
)
Surface(color = MaterialTheme.colorScheme.errorContainer) {
Text(text = "Error detail", color = MaterialTheme.colorScheme.onErrorContainer)
}
// WRONG: hardcoded colors
Text(text = "Error", color = Color(0xFFB00020)) // Anti-pattern
Rules:
- R1.4: Every foreground element must use the matching
oncolor role for its background (e.g.,onPrimarytext onprimarybackground). - R1.5: Use
surfaceand its variants for backgrounds. Never useprimaryorsecondaryas large background areas. - R1.6: Use
tertiarysparingly for accent and complementary contrast only.
1.3 Light and Dark Themes
Support both light and dark themes. Respect the system setting by default.
// Compose: Detect system theme
val darkTheme = isSystemInDarkTheme()
Rules:
- R1.7: Always support both light and dark themes. Never ship light-only.
- R1.8: Dark theme surfaces use elevation-based tonal mapping, not pure black (#000000). Use
surfacecolor roles which handle this automatically. - R1.9: Provide a manual theme override in app settings (System / Light / Dark).
1.4 Custom Color Seeds
When branding requires custom colors, provide a seed color and generate tonal palettes using Material Theme Builder.
// Custom color scheme with brand seed
private val BrandLightColorScheme = lightColorScheme(
primary = Color(0xFF1B6D2F),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFA4F6A8),
onPrimaryContainer = Color(0xFF002107),
// ... generate full palette from seed
)
Rules:
- R1.10: Generate tonal palettes from seed colors using Material Theme Builder. Never manually pick individual tones.
- R1.11: When using custom colors, still support dynamic color as the default and use custom colors as fallback.
2. Navigation [CRITICAL]
2.1 Navigation Bar (Bottom)
The primary navigation pattern for phones with 3-5 top-level destinations.
// Compose: Navigation Bar
NavigationBar {
items.forEachIndexed { index, item ->
NavigationBarItem(
icon = {
Icon(
imageVector = if (selectedItem == index) item.filledIcon else item.outlinedIcon,
contentDescription = item.label
)
},
label = { Text(item.label) },
selected = selectedItem == index,
onClick = { selectedItem = index }
)
}
}
Rules:
- R2.1: Use Navigation Bar for 3-5 top-level destinations on compact screens. Never use for fewer than 3 or more than 5.
- R2.2: Always show labels on navigation bar items. Icon-only navigation bars are not permitted.
- R2.3: Use filled icons for the selected state and outlined icons for unselected states.
- R2.4: The active indicator uses
secondaryContainercolor. Do not override this.
2.2 Navigation Rail
For medium and expanded screens (tablets, foldables, desktop).
// Compose: Navigation Rail for larger screens
NavigationRail(
header = {
FloatingActionButton(
onClick = { /* primary action */ },
containerColor = MaterialTheme.colorScheme.tertiaryContainer
) {
Icon(Icons.Default.Add, contentDescription = "Create")
}
}
) {
items.forEachIndexed { index, item ->
NavigationRailItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = selectedItem == index,
onClick = { selectedItem = index }
)
}
}
Rules:
- R2.5: Use Navigation Rail on medium (600-839dp) and expanded (840dp+) window sizes. Pair it with Navigation Bar on compact.
- R2.6: Optionally include a FAB in the rail header for the primary action.
- R2.7: Labels are optional on the rail but recommended for clarity.
2.3 Navigation Drawer
For 5+ destinations or complex navigation hierarchies, typically on expanded screens.
// Compose: Permanent Navigation Drawer for large screens
PermanentNavigationDrawer(
drawerContent = {
PermanentDrawerSheet {
Text("App Name", modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium)
HorizontalDivider()
items.forEach { item ->
NavigationDrawerItem(
label = { Text(item.label) },
selected = item == selectedItem,
onClick = { selectedItem = item },
icon = { Icon(item.icon, contentDescription = null) }
)
}
}
}
) {
Scaffold { /* page content */ }
}
Rules:
- R2.8: Use modal drawer on compact screens, permanent drawer on expanded screens.
- R2.9: Group drawer items into sections with dividers and section headers.
2.4 Predictive Back Gesture
Android 13+ supports predictive back with an animation preview.
// Compose: Predictive back handling
val predictiveBackHandler = remember { PredictiveBackHandler(enabled = true) { progress ->
// Animate based on progress (0.0 to 1.0)
}}
<!-- AndroidManifest.xml: opt in to predictive back -->
<application android:enableOnBackInvokedCallback="true">
Rules:
- R2.10: Opt in to predictive back in the manifest. Handle
OnBackInvokedCallbackinstead of overridingonBackPressed(). - R2.11: The system back gesture navigates back in the navigation stack. The Up button (toolbar arrow) navigates up in the app hierarchy. These may differ.
- R2.12: Never intercept system back to show “are you sure?” dialogs unless there is unsaved user input.
2.5 Navigation Component Selection
| Screen Size | 3-5 Destinations | 5+ Destinations |
|---|---|---|
| Compact (< 600dp) | Navigation Bar | Modal Drawer + Navigation Bar |
| Medium (600-839dp) | Navigation Rail | Modal Drawer + Navigation Rail |
| Expanded (840dp+) | Navigation Rail | Permanent Drawer |
3. Layout & Responsive [HIGH]
3.1 Window Size Classes
Use window size classes for adaptive layouts, not raw pixel breakpoints.
// Compose: Window size classes
val windowSizeClass = calculateWindowSizeClass(this)
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> CompactLayout()
WindowWidthSizeClass.Medium -> MediumLayout()
WindowWidthSizeClass.Expanded -> ExpandedLayout()
}
| Class | Width | Typical Device | Columns |
|---|---|---|---|
| Compact | < 600dp | Phone portrait | 4 |
| Medium | 600-839dp | Tablet portrait, foldable | 8 |
| Expanded | 840dp+ | Tablet landscape, desktop | 12 |
Rules:
- R3.1: Always use
WindowSizeClassfrommaterial3-window-size-classfor responsive layout decisions. - R3.2: Never use fixed pixel breakpoints. Device categories are fluid.
- R3.3: Support all three width size classes. At minimum, compact and expanded.
3.2 Material Grid
Apply canonical Material grid margins and gutters.
| Size Class | Margins | Gutters | Columns |
|---|---|---|---|
| Compact | 16dp | 8dp | 4 |
| Medium | 24dp | 16dp | 8 |
| Expanded | 24dp | 24dp | 12 |
Rules:
- R3.4: Content should not span the full width on expanded screens. Use a max content width of ~840dp or list-detail layout.
- R3.5: Apply consistent horizontal margins matching the grid spec.
3.3 Edge-to-Edge Display
Android 15+ enforces edge-to-edge. All apps should draw behind system bars.
// Compose: Edge-to-edge setup
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
Scaffold(
modifier = Modifier.fillMaxSize(),
// Scaffold handles insets for top/bottom bars automatically
) { innerPadding ->
Content(modifier = Modifier.padding(innerPadding))
}
}
}
}
Rules:
- R3.6: Call
enableEdgeToEdge()beforesetContent. Draw behind both status bar and navigation bar. - R3.7: Use
WindowInsetsto pad content away from system bars.Scaffoldhandles this for top bar and bottom bar content automatically. - R3.8: Scrollable content should scroll behind transparent system bars with appropriate inset padding at the top and bottom of the list.
3.4 Foldable Device Support
// Compose: Detect fold posture
val foldingFeatures = WindowInfoTracker.getOrCreate(context)
.windowLayoutInfo(context)
.collectAsState(initial = WindowLayoutInfo(emptyList()))
Rules:
- R3.9: Detect hinge/fold position and avoid placing critical content across the fold.
- R3.10: Use
ListDetailPaneScaffoldorSupportingPaneScaffoldfrom Material3 adaptive library for foldable-aware layouts.
4. Typography [HIGH]
4.1 Material Type Scale
| Role | Default Size | Default Weight | Usage |
|---|---|---|---|
| displayLarge | 57sp | 400 | Hero text, onboarding |
| displayMedium | 45sp | 400 | Large feature text |
| displaySmall | 36sp | 400 | Prominent display |
| headlineLarge | 32sp | 400 | Screen titles |
| headlineMedium | 28sp | 400 | Section headers |
| headlineSmall | 24sp | 400 | Card titles |
| titleLarge | 22sp | 400 | Top app bar title |
| titleMedium | 16sp | 500 | Tabs, navigation |
| titleSmall | 14sp | 500 | Subtitles |
| bodyLarge | 16sp | 400 | Primary body text |
| bodyMedium | 14sp | 400 | Secondary body text |
| bodySmall | 12sp | 400 | Captions |
| labelLarge | 14sp | 500 | Buttons, prominent labels |
| labelMedium | 12sp | 500 | Chips, smaller labels |
| labelSmall | 11sp | 500 | Timestamps, annotations |
// Compose: Custom typography
val AppTypography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily(Font(R.font.brand_regular)),
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily(Font(R.font.brand_regular)),
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
// ... define all 15 roles
)
Rules:
- R4.1: Always use
spunits for text sizes to support user font scaling preferences. - R4.2: Never set text below 12sp for body content. Labels may go to 11sp minimum.
- R4.3: Reference typography roles from
MaterialTheme.typography, not hardcoded sizes. - R4.4: Support dynamic type scaling. Test at 200% font scale. Ensure no text is clipped or overlapping.
- R4.5: Line height should be approximately 1.2-1.5x the font size for readability.
5. Components [HIGH]
5.1 Floating Action Button (FAB)
The FAB represents the single most important action on a screen.
// Compose: FAB variants
// Standard FAB
FloatingActionButton(onClick = { /* action */ }) {
Icon(Icons.Default.Add, contentDescription = "Create new item")
}
// Extended FAB (with label - preferred for clarity)
ExtendedFloatingActionButton(
onClick = { /* action */ },
icon = { Icon(Icons.Default.Edit, contentDescription = null) },
text = { Text("Compose") }
)
// Large FAB
LargeFloatingActionButton(onClick = { /* action */ }) {
Icon(Icons.Default.Add, contentDescription = "Create", modifier = Modifier.size(36.dp))
}
Rules:
- R5.1: Use at most one FAB per screen. It represents the primary action.
- R5.2: Place the FAB at the bottom-end of the screen. On screens with a Navigation Bar, the FAB floats above it.
- R5.3: The FAB should use
primaryContainercolor by default. UsetertiaryContainerfor secondary screens. - R5.4: Prefer
ExtendedFloatingActionButtonwith a label for clarity. Collapse to icon-only on scroll if needed.
5.2 Top App Bar
// Compose: Top app bar variants
// Small (default)
TopAppBar(
title = { Text("Page Title") },
navigationIcon = {
IconButton(onClick = { /* navigate up */ }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { /* search */ }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
}
)
// Medium â expands title area
MediumTopAppBar(
title = { Text("Section Title") },
scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
)
// Large â for prominent titles
LargeTopAppBar(
title = { Text("Screen Title") },
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
)
Rules:
- R5.5: Use
TopAppBar(small) for most screens. UseMediumTopAppBarorLargeTopAppBarfor prominent section or screen titles. - R5.6: Connect scroll behavior to the app bar so it collapses/expands with content scrolling.
- R5.7: Limit action icons to 2-3. Overflow additional actions into a more menu.
5.3 Bottom Sheets
// Compose: Modal bottom sheet
ModalBottomSheet(
onDismissRequest = { showSheet = false },
sheetState = rememberModalBottomSheetState()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Sheet Title", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(16.dp))
// Sheet content
}
}
Rules:
- R5.8: Use modal bottom sheets for non-critical supplementary content. Use standard bottom sheets for persistent content.
- R5.9: Bottom sheets must have a visible drag handle for discoverability.
- R5.10: Sheet content must be scrollable if it can exceed the visible area.
5.4 Dialogs
// Compose: Alert dialog
AlertDialog(
onDismissRequest = { showDialog = false },
title = { Text("Discard draft?") },
text = { Text("Your unsaved changes will be lost.") },
confirmButton = {
TextButton(onClick = { /* confirm */ }) { Text("Discard") }
},
dismissButton = {
TextButton(onClick = { showDialog = false }) { Text("Cancel") }
}
)
Rules:
- R5.11: Dialogs interrupt the user. Use them only for critical decisions requiring immediate attention.
- R5.12: Confirm button uses a text button, not a filled button. The dismiss button is always on the left.
- R5.13: Dialog titles should be concise questions or statements. Body text provides context.
5.5 Snackbar
// Compose: Snackbar with action
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) {
// trigger snackbar
LaunchedEffect(key) {
val result = snackbarHostState.showSnackbar(
message = "Item archived",
actionLabel = "Undo",
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) { /* undo */ }
}
}
Rules:
- R5.14: Use snackbars for brief, non-critical feedback. They auto-dismiss and should not contain critical information.
- R5.15: Snackbars appear at the bottom of the screen, above the Navigation Bar and below the FAB.
- R5.16: Include an action (e.g., “Undo”) when the operation is reversible. Limit to one action.
5.6 Chips
// Filter Chip
FilterChip(
selected = isSelected,
onClick = { isSelected = !isSelected },
label = { Text("Filter") },
leadingIcon = if (isSelected) {
{ Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) }
} else null
)
// Assist Chip
AssistChip(
onClick = { /* action */ },
label = { Text("Add to calendar") },
leadingIcon = { Icon(Icons.Default.CalendarToday, contentDescription = null) }
)
Rules:
- R5.17: Use
FilterChipfor toggling filters,AssistChipfor smart suggestions,InputChipfor user-entered content (tags),SuggestionChipfor dynamically generated suggestions. - R5.18: Chips should be arranged in a horizontally scrollable row or a flow layout, not stacked vertically.
5.7 Component Selection Guide
| Need | Component |
|---|---|
| Primary screen action | FAB |
| Brief feedback | Snackbar |
| Critical decision | Dialog |
| Supplementary content | Bottom Sheet |
| Toggle filter | Filter Chip |
| User-entered tag | Input Chip |
| Smart suggestion | Assist Chip |
| Content group | Card |
| Vertical list of items | LazyColumn with ListItem |
| Segmented option (2-5) | SegmentedButton |
| Binary toggle | Switch |
| Selection from list | Radio buttons or exposed dropdown menu |
6. Accessibility [CRITICAL]
6.1 TalkBack and Content Descriptions
// Compose: Accessible components
Icon(
Icons.Default.Favorite,
contentDescription = "Add to favorites" // Descriptive, not "heart icon"
)
// Decorative elements
Icon(
Icons.Default.Star,
contentDescription = null // null for purely decorative
)
// Merge semantics for compound elements
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
Icon(Icons.Default.Event, contentDescription = null)
Text("March 15, 2026")
}
// Custom actions
Box(modifier = Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Archive") { /* archive */ true },
CustomAccessibilityAction("Delete") { /* delete */ true }
)
})
Rules:
- R6.1: Every interactive element must have a
contentDescription(ornullif purely decorative). - R6.2: Content descriptions must describe the action or meaning, not the visual appearance. Say “Add to favorites” not “Heart icon.”
- R6.3: Use
mergeDescendants = trueto group related elements into a single TalkBack focus unit (e.g., a list item with icon + text + subtitle). - R6.4: Provide
customActionsfor swipe-to-dismiss or long-press actions so TalkBack users can access them.
6.2 Touch Targets
// Compose: Ensure minimum touch target
IconButton(onClick = { /* action */ }) {
// IconButton already provides 48dp minimum touch target
Icon(Icons.Default.Close, contentDescription = "Close")
}
// Manual minimum touch target
Box(
modifier = Modifier
.sizeIn(minWidth = 48.dp, minHeight = 48.dp)
.clickable { /* action */ },
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Info, contentDescription = "Info", modifier = Modifier.size(24.dp))
}
Rules:
- R6.5: All interactive elements must have a minimum touch target of 48x48dp. Material 3 components handle this by default.
- R6.6: Do not reduce touch targets to save space. Use padding to increase the touchable area if the visual element is smaller.
6.3 Color Contrast and Visual
Rules:
- R6.7: Text contrast ratio must be at least 4.5:1 for normal text and 3:1 for large text (18sp+ or 14sp+ bold) against its background.
- R6.8: Never use color as the only means of conveying information. Pair with icons, text, or patterns.
- R6.9: Support “bold text” and “high contrast” accessibility settings.
6.4 Focus and Traversal
// Compose: Custom focus order
Column {
var focusRequester = remember { FocusRequester() }
TextField(
modifier = Modifier.focusRequester(focusRequester),
value = text,
onValueChange = { text = it }
)
LaunchedEffect(Unit) {
focusRequester.requestFocus() // Auto-focus on screen load
}
}
Rules:
- R6.10: Focus order must follow a logical reading sequence (top-to-bottom, start-to-end). Avoid custom
focusOrderunless the default is incorrect. - R6.11: After navigation or dialog dismissal, move focus to the most logical target element.
- R6.12: All screens must be fully operable using TalkBack, Switch Access, and external keyboard.
7. Gestures & Input [MEDIUM]
7.1 System Gestures
Rules:
- R7.1: Never place interactive elements within the system gesture inset zones (bottom 20dp, left/right 24dp edges) as they conflict with system navigation gestures.
- R7.2: Use
WindowInsets.systemGesturesto detect and avoid gesture conflict zones.
7.2 Common Gesture Patterns
// Compose: Pull to refresh
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { viewModel.refresh() }
) {
LazyColumn { /* content */ }
}
// Compose: Swipe to dismiss
SwipeToDismissBox(
state = rememberSwipeToDismissBoxState(),
backgroundContent = {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.error),
contentAlignment = Alignment.CenterEnd
) {
Icon(Icons.Default.Delete, contentDescription = "Delete",
tint = MaterialTheme.colorScheme.onError)
}
}
) {
ListItem(headlineContent = { Text("Swipeable item") })
}
Rules:
- R7.3: All swipe-to-dismiss actions must be undoable (show snackbar with undo) or require confirmation.
- R7.4: Provide alternative non-gesture ways to trigger all gesture-based actions (for accessibility).
- R7.5: Apply Material ripple effect on all tappable elements. Compose
clickablemodifier includes ripple by default.
7.3 Long Press
Rules:
- R7.6: Use long press for contextual menus and multi-select mode. Never use it as the only way to access a feature.
- R7.7: Provide haptic feedback on long press via
HapticFeedbackType.LongPress.
8. Notifications [MEDIUM]
8.1 Notification Channels
// Create notification channel (required for Android 8+)
val channel = NotificationChannel(
"messages",
"Messages",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "New message notifications"
enableLights(true)
lightColor = Color.BLUE
}
notificationManager.createNotificationChannel(channel)
| Importance | Behavior | Use For |
|---|---|---|
| IMPORTANCE_HIGH | Sound + heads-up | Messages, calls |
| IMPORTANCE_DEFAULT | Sound | Social updates, emails |
| IMPORTANCE_LOW | No sound | Recommendations |
| IMPORTANCE_MIN | Silent, no status bar | Weather, ongoing |
Rules:
- R8.1: Create separate notification channels for each distinct notification type. Users can configure each independently.
- R8.2: Choose importance levels conservatively. Overusing
IMPORTANCE_HIGHleads users to disable notifications entirely. - R8.3: All notifications must have a tap action (PendingIntent) that navigates to relevant content.
- R8.4: Include a
contentDescriptionin notification icons for accessibility.
8.2 Notification Design
Rules:
- R8.5: Use
MessagingStylefor conversations. Include sender name and avatar. - R8.6: Add direct reply actions to messaging notifications.
- R8.7: Provide a “Mark as read” action on message notifications.
- R8.8: Use expandable notifications (
BigTextStyle,BigPictureStyle,InboxStyle) for rich content. - R8.9: Foreground service notifications must accurately describe the ongoing operation and provide a stop action where appropriate.
9. Permissions & Privacy [HIGH]
9.1 Runtime Permissions
// Compose: Permission request
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
if (permissionState.status.isGranted) {
CameraPreview()
} else {
Column {
Text("Camera access is needed to scan QR codes.")
Button(onClick = { permissionState.launchPermissionRequest() }) {
Text("Grant Camera Access")
}
}
}
Rules:
- R9.1: Request permissions in context, at the moment they are needed, not at app launch.
- R9.2: Always explain why the permission is needed before requesting it (rationale screen).
- R9.3: Gracefully handle permission denial. Provide degraded functionality rather than blocking the user.
- R9.4: Never request permissions you do not actively use. Google Play will reject apps with unnecessary permissions.
9.2 Privacy-Preserving APIs
// Photo picker: no permission needed
val pickMedia = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri ->
uri?.let { /* handle selected media */ }
}
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
Rules:
- R9.5: Use the Photo Picker (Android 13+) instead of requesting
READ_MEDIA_IMAGES. No permission needed. - R9.6: Use
ACCESS_COARSE_LOCATION(approximate) unless precise location is essential for functionality. - R9.7: Prefer one-time permissions for camera and microphone in non-recording contexts.
- R9.8: Display a privacy indicator when camera or microphone is actively in use.
10. System Integration [MEDIUM]
10.1 Widgets
// Compose Glance API widget
class TaskWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
GlanceTheme {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.padding(16.dp)
) {
Text(
text = "Tasks",
style = TextStyle(fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.onSurface)
)
// Widget content
}
}
}
}
}
Rules:
- R10.1: Use Glance API for new widgets. Support dynamic color via
GlanceTheme. - R10.2: Widgets must have a default configuration and work immediately after placement.
- R10.3: Provide multiple widget sizes (small, medium, large) where practical.
- R10.4: Use rounded corners matching the system widget shape (
system_app_widget_background_radius).
10.2 App Shortcuts
<!-- shortcuts.xml -->
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="compose"
android:enabled="true"
android:shortcutShortLabel="@string/compose_short"
android:shortcutLongLabel="@string/compose_long"
android:icon="@drawable/ic_shortcut_compose">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.example.app"
android:targetClass="com.example.app.ComposeActivity" />
</shortcut>
</shortcuts>
Rules:
- R10.5: Provide 2-4 static shortcuts for common actions. Support dynamic shortcuts for recent content.
- R10.6: Shortcut icons should be simple, recognizable silhouettes on a circular background.
- R10.7: Test shortcuts with long-press on the app icon and in the Settings > Apps shortcut list.
10.3 Deep Links and Share
Rules:
- R10.8: Support Android App Links (verified deep links) for all public content URLs.
- R10.9: Implement the share sheet with
ShareCompatorIntent.createChooser. Provide rich previews with title, description, and thumbnail. - R10.10: Handle incoming share intents with appropriate content type filtering.
Design Evaluation Checklist
Use this checklist to evaluate Android UI implementations:
Theme & Color
- Dynamic color enabled with static fallback
- All colors reference Material theme roles (no hardcoded hex)
- Light and dark themes both supported
- On-colors match their background color roles
- Custom colors generated from seed via Material Theme Builder
Navigation
- Correct navigation component for screen size and destination count
- Navigation bar labels always visible
- Predictive back gesture opted in and handled
- Up vs Back behavior correct
Layout
- All three window size classes supported
- Edge-to-edge with proper inset handling
- Content does not span full width on large screens
- Foldable hinge area respected
Typography
- All text uses sp units
- All text references MaterialTheme.typography roles
- Tested at 200% font scale with no clipping
- Minimum 12sp body, 11sp labels
Components
- At most one FAB per screen
- Top app bar connected to scroll behavior
- Snackbars used for non-critical feedback only
- Dialogs reserved for critical interruptions
Accessibility
- All interactive elements have contentDescription
- All touch targets >= 48dp
- Color contrast >= 4.5:1 for text
- No information conveyed by color alone
- Full TalkBack traversal tested
- Switch Access and keyboard navigation work
Gestures
- No interactive elements in system gesture zones
- All gesture actions have non-gesture alternatives
- Swipe-to-dismiss is undoable
Notifications
- Separate channels for each notification type
- Appropriate importance levels
- Tap action navigates to relevant content
Permissions
- Permissions requested in context, not at launch
- Rationale shown before permission request
- Graceful degradation on denial
- Photo Picker used instead of media permission
System Integration
- Widgets use Glance API with dynamic color
- App shortcuts provided for common actions
- Deep links handled for public content
Anti-Patterns
| Anti-Pattern | Why It Is Wrong | Correct Approach |
|---|---|---|
| Hardcoded color hex values | Breaks dynamic color and dark theme | Use MaterialTheme.colorScheme roles |
Using dp for text size |
Ignores user font scaling | Use sp units |
| Custom bottom navigation bar | Inconsistent with platform | Use Material NavigationBar |
| Navigation bar without labels | Violates Material guidelines | Always show labels |
| Dialog for non-critical info | Interrupts user unnecessarily | Use Snackbar or Bottom Sheet |
| FAB for secondary actions | Dilutes primary action prominence | One FAB for the primary action only |
onBackPressed() override |
Deprecated; breaks predictive back | Use OnBackInvokedCallback |
| Touch targets < 48dp | Accessibility violation | Ensure minimum 48x48dp |
| Permission request at launch | Users deny without context | Request in context with rationale |
| Pure black (#000000) dark theme | Eye strain; not Material 3 | Use Material surface color roles |
| Icon-only navigation bar | Users cannot identify destinations | Always include text labels |
| Full-width content on tablets | Wastes space; poor readability | Max width or list-detail layout |
READ_EXTERNAL_STORAGE for photos |
Unnecessary since Android 13 | Use Photo Picker API |
| Blocking UI on permission denial | Punishes the user | Graceful degradation |
| Manual color palette selection | Inconsistent tonal relationships | Use Material Theme Builder |