android-design-guidelines

Material Design 3 and Android platform guidelines for Jetpack Compose and XML layouts. Implement dynamic color from user wallpaper (Android 12+) with static fallback; use semantic color roles ( primary , secondary , tertiary , surface , error ) instead of hardcoded hex values Support all three window size classes (Compact < 600dp, Medium 600–839dp, Expanded 840dp+) with responsive layouts; use Navigation Bar for phones, Navigation Rail for tablets, and Permanent Drawer for large screens Apply Material 3 typography scale (15 roles from displayLarge to labelSmall ) using sp units; test at 200% font scale and support user bold text preferences Ensure accessibility: 48dp minimum touch targets, 4.5:1 color contrast, contentDescription on all interactive elements, full TalkBack traversal, and custom canvas views using ExploreByTouchHelper Opt into predictive back gesture (Android 13+) using BackHandler in Compose or OnBackInvokedCallback in View-based apps; enable edge-to-edge display with proper inset handling

INSTALLATION
npx skills add https://github.com/ehmo/platform-design-skills --skill android-design-guidelines
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

Android Platform Design Guidelines — Material Design 3

1. Material You &#x26; 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 &#x26;&#x26; 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 on color role for its background (e.g., onPrimary text on primary background).
  • R1.5: Use surface and its variants for backgrounds. Never use primary or secondary as large background areas.
  • R1.6: Use tertiary sparingly 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 surface color 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 secondaryContainer color. 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 with BackHandler (androidx.activity.compose)

BackHandler(enabled = true) {

    // Called when back is confirmed; navigate back in your nav controller

    navController.popBackStack()

}
// Compose: Predictive back progress animation using predictiveBackHandler modifier

// (androidx.activity:activity-compose 1.8+)

Modifier.predictiveBackHandler(enabled = true) { progress ->

    // progress is a Flow<BackEventCompat> with x, y, swipeEdge, progress (0.0–1.0)

    progress.collect { backEvent ->

        animationState = backEvent.progress

    }

}
<!-- AndroidManifest.xml: opt in to predictive back -->

<application android:enableOnBackInvokedCallback="true">

Rules:

  • R2.10: Opt in to predictive back in the manifest. In Compose apps, use BackHandler (from androidx.activity.compose) to intercept back events. In View-based apps, implement OnBackInvokedCallback (API 33+) or OnBackPressedCallback (AndroidX) instead of overriding onBackPressed().
  • 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.
  • R2.13: Do not suppress the system-provided back preview animation. If you implement custom enter/exit transitions, interpolate them using BackEventCompat.progress (0.0–1.0) and respect BackEventCompat.swipeEdge (EDGE_LEFT/EDGE_RIGHT) so the exiting screen scales down and shifts toward the initiating edge, matching the system animation.
  • R2.14: Prefer recognition over recall. Keep destinations labeled, selected state visible, and back-stack context preserved so users do not reconstruct where they are after every navigation step.
// Compose: drive a custom animation from predictive back progress

Modifier.predictiveBackHandler(enabled = true) { progress ->

    progress.collect { backEvent ->

        // backEvent.progress: 0.0 (gesture start) → 1.0 (committed)

        // backEvent.swipeEdge: BackEventCompat.EDGE_LEFT or EDGE_RIGHT

        exitScale = 1f - (backEvent.progress * 0.1f)

        exitOffsetX = if (backEvent.swipeEdge == BackEventCompat.EDGE_LEFT) -backEvent.progress * 32.dp.toPx() else backEvent.progress * 32.dp.toPx()

    }

}

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 &#x26; 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 WindowSizeClass from material3-window-size-class for 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() before setContent. Draw behind both status bar and navigation bar.
  • R3.7: Use WindowInsets to pad content away from system bars. Scaffold handles 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 ListDetailPaneScaffold or SupportingPaneScaffold from 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 sp units 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 primaryContainer color by default. Use tertiaryContainer for secondary screens.
  • R5.4: Prefer ExtendedFloatingActionButton with 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. Use MediumTopAppBar or LargeTopAppBar for 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 FilterChip for toggling filters, AssistChip for smart suggestions, InputChip for user-entered content (tags), SuggestionChip for dynamically generated suggestions.
  • R5.18: Chips should be arranged in a horizontally scrollable row or a flow layout, not stacked vertically.
  • R5.19: Expose waiting states immediately. If an action cannot finish right away, acknowledge it with inline state change, progress, or another visible response rather than leaving the UI static.

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 (or null if 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 = true to group related elements into a single TalkBack focus unit (e.g., a list item with icon + text + subtitle).
  • R6.4: Provide customActions for 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. Use Configuration.fontWeightAdjustment (API 31+) to detect the user's bold text preference and scale custom font weights accordingly. Use AccessibilityManager.isHighTextContrastEnabled() to detect high contrast mode and substitute higher-contrast color values. Material 3 components handle both automatically; custom text rendering and color usage must opt in explicitly.
// Detect bold text preference (API 31+)

val fontWeightAdjustment = resources.configuration.fontWeightAdjustment

val isBoldText = fontWeightAdjustment >= 700 // equivalent to FontWeight.Bold.weight

// Detect high contrast mode

val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager

val isHighContrast = am.isHighTextContrastEnabled

// Compose: use MaterialTheme.typography which respects fontWeightAdjustment automatically

Text(

    text = "Label",

    style = MaterialTheme.typography.bodyLarge // Adapts to fontWeightAdjustment

)

// For custom colors: provide high-contrast alternative

val labelColor = if (isHighContrast) {

    MaterialTheme.colorScheme.onSurface  // Strong contrast

} else {

    MaterialTheme.colorScheme.onSurfaceVariant  // Normal contrast

}

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 focusOrder unless 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.

6.5 Custom Canvas Views

Custom View subclasses that draw content on a Canvas (charts, custom pickers, drawing surfaces) are invisible to TalkBack by default because they have no child views. Use ExploreByTouchHelper from androidx.customview.widget to define a virtual accessibility tree.

  • R6.13: Custom canvas-drawn views must use ExploreByTouchHelper to expose a virtual accessibility tree to TalkBack. Override getVirtualViewAt() to map touch coordinates to virtual view IDs, and onPopulateNodeForVirtualView() to supply text, bounds, and actions for each virtual node.
import androidx.customview.widget.ExploreByTouchHelper

class PieChartView(context: Context) : View(context) {

    private val helper = object : ExploreByTouchHelper(this) {

        override fun getVirtualViewAt(x: Float, y: Float): Int {

            // Return virtual view ID for the slice at (x, y), or INVALID_ID

            return sliceIndexAt(x, y)

        }

        override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int>) {

            slices.indices.forEach { virtualViewIds.add(it) }

        }

        override fun onPopulateNodeForVirtualView(

            virtualViewId: Int,

            node: AccessibilityNodeInfoCompat

        ) {

            val slice = slices[virtualViewId]

            node.text = "${slice.label}: ${slice.percentage}%"

            node.setBoundsInParent(slice.bounds)

            node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK)

        }

        override fun onPerformActionForVirtualView(

            virtualViewId: Int, action: Int, arguments: Bundle?

        ): Boolean {

            if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) {

                onSliceSelected(virtualViewId)

                return true

            }

            return false

        }

    }

    init {

        ViewCompat.setAccessibilityDelegate(this, helper)

    }

    override fun dispatchHoverEvent(event: MotionEvent) =

        helper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event)

}

7. Gestures &#x26; 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.systemGestures to 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 clickable modifier 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_HIGH leads users to disable notifications entirely.
  • R8.3: All notifications must have a tap action (PendingIntent) that navigates to relevant content.
  • R8.4: Include a contentDescription in notification icons for accessibility.

8.2 Notification Design

Rules:

  • R8.5: Use MessagingStyle for 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 &#x26; 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 ShareCompat or Intent.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 &#x26; 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 BackHandler (Compose) or OnBackInvokedCallback (View-based) for predictive back support

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

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card