android-design-guidelines by ehmo/platform-design-skills
npx skills add https://github.com/ehmo/platform-design-skills --skill android-design-guidelines启用源自用户壁纸的动态颜色。动态颜色是 Android 12+ 的默认设置,应作为主要的主题化策略。
// Compose: 动态颜色主题
@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: themes.xml 中的动态颜色 -->
<style name="Theme.App" parent="Theme.Material3.DayNight.NoActionBar">
<item name="dynamicColorThemeOverlay">@style/ThemeOverlay.Material3.DynamicColors.DayNight</item>
</style>
规则:
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
Material 3 定义了一套结构化的颜色角色。请按语义使用它们,而非仅凭美观。
| 角色 | 用途 | 对应前景色 |
|---|---|---|
primary | 关键操作、活动状态、FAB | onPrimary |
primaryContainer | 较不突出的主要元素 | onPrimaryContainer |
secondary | 辅助 UI、筛选芯片 | onSecondary |
secondaryContainer | 导航栏活动指示器 | onSecondaryContainer |
tertiary | 强调色、对比色、互补色 | onTertiary |
tertiaryContainer | 输入字段、较不突出的强调色 | onTertiaryContainer |
surface | 背景、卡片、底部动作条 | onSurface |
surfaceVariant | 装饰性元素、分隔线 | onSurfaceVariant |
error | 错误状态、破坏性操作 | onError |
errorContainer | 错误背景 | onErrorContainer |
outline | 边框、分隔线 | — |
outlineVariant | 细微边框 | — |
inverseSurface | Snackbar 背景 | inverseOnSurface |
// 正确:语义化颜色角色
Text(
text = "错误信息",
color = MaterialTheme.colorScheme.error
)
Surface(color = MaterialTheme.colorScheme.errorContainer) {
Text(text = "错误详情", color = MaterialTheme.colorScheme.onErrorContainer)
}
// 错误:硬编码颜色
Text(text = "错误", color = Color(0xFFB00020)) // 反模式
规则:
on 颜色角色(例如,在 primary 背景上使用 onPrimary 文本)。surface 及其变体作为背景。切勿将 primary 或 secondary 用作大面积背景。tertiary。同时支持浅色和深色主题。默认情况下应遵循系统设置。
// Compose: 检测系统主题
val darkTheme = isSystemInDarkTheme()
规则:
surface 颜色角色,它会自动处理这一点。当品牌需要自定义颜色时,提供一个种子颜色,并使用 Material Theme Builder 生成色调调色板。
// 带有品牌种子的自定义配色方案
private val BrandLightColorScheme = lightColorScheme(
primary = Color(0xFF1B6D2F),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFA4F6A8),
onPrimaryContainer = Color(0xFF002107),
// ... 从种子生成完整调色板
)
规则:
适用于具有 3-5 个顶级目的地的手机的主要导航模式。
// Compose: 导航栏
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 }
)
}
}
规则:
secondaryContainer 颜色。不要覆盖此设置。适用于中等和扩展屏幕(平板电脑、可折叠设备、桌面)。
// Compose: 适用于较大屏幕的导航轨道
NavigationRail(
header = {
FloatingActionButton(
onClick = { /* 主要操作 */ },
containerColor = MaterialTheme.colorScheme.tertiaryContainer
) {
Icon(Icons.Default.Add, contentDescription = "创建")
}
}
) {
items.forEachIndexed { index, item ->
NavigationRailItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = selectedItem == index,
onClick = { selectedItem = index }
)
}
}
规则:
适用于 5 个以上目的地或复杂的导航层次结构,通常在扩展屏幕上使用。
// Compose: 适用于大屏幕的永久性导航抽屉
PermanentNavigationDrawer(
drawerContent = {
PermanentDrawerSheet {
Text("应用名称", 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 { /* 页面内容 */ }
}
规则:
Android 13+ 支持带有动画预览的预测性返回。
// Compose: 使用 BackHandler 的预测性返回 (androidx.activity.compose)
BackHandler(enabled = true) {
// 当返回被确认时调用;在你的导航控制器中导航返回
navController.popBackStack()
}
// Compose: 使用 predictiveBackHandler 修饰符的预测性返回进度动画
// (androidx.activity:activity-compose 1.8+)
Modifier.predictiveBackHandler(enabled = true) { progress ->
// progress 是一个包含 x, y, swipeEdge, progress (0.0–1.0) 的 Flow<BackEventCompat>
progress.collect { backEvent ->
animationState = backEvent.progress
}
}
<!-- AndroidManifest.xml: 选择启用预测性返回 -->
<application android:enableOnBackInvokedCallback="true">
规则:
BackHandler(来自 androidx.activity.compose)来拦截返回事件。在 基于 View 的 应用中,实现 OnBackInvokedCallback (API 33+) 或 OnBackPressedCallback (AndroidX),而不是覆盖 onBackPressed()。BackEventCompat.progress (0.0–1.0) 对它们进行插值,并尊重 BackEventCompat.swipeEdge (EDGE_LEFT/EDGE_RIGHT),以便退出的屏幕缩小并向起始边缘移动,与系统动画匹配。// Compose: 根据预测性返回进度驱动自定义动画
Modifier.predictiveBackHandler(enabled = true) { progress ->
progress.collect { backEvent ->
// backEvent.progress: 0.0 (手势开始) → 1.0 (已提交)
// backEvent.swipeEdge: BackEventCompat.EDGE_LEFT 或 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()
}
}
| 屏幕尺寸 | 3-5 个目的地 | 5+ 个目的地 |
|---|---|---|
| 紧凑 (< 600dp) | 导航栏 | 模态抽屉 + 导航栏 |
| 中等 (600-839dp) | 导航轨道 | 模态抽屉 + 导航轨道 |
| 扩展 (840dp+) | 导航轨道 | 永久性抽屉 |
使用窗口尺寸类别进行自适应布局,而非原始像素断点。
// Compose: 窗口尺寸类别
val windowSizeClass = calculateWindowSizeClass(this)
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> CompactLayout()
WindowWidthSizeClass.Medium -> MediumLayout()
WindowWidthSizeClass.Expanded -> ExpandedLayout()
}
| 类别 | 宽度 | 典型设备 | 列数 |
|---|---|---|---|
| 紧凑 | < 600dp | 手机竖屏 | 4 |
| 中等 | 600-839dp | 平板竖屏、可折叠设备 | 8 |
| 扩展 | 840dp+ | 平板横屏、桌面 | 12 |
规则:
material3-window-size-class 的 WindowSizeClass 进行响应式布局决策。应用规范的 Material 网格边距和间距。
| 尺寸类别 | 边距 | 间距 | 列数 |
|---|---|---|---|
| 紧凑 | 16dp | 8dp | 4 |
| 中等 | 24dp | 16dp | 8 |
| 扩展 | 24dp | 24dp | 12 |
规则:
Android 15+ 强制要求边到边显示。所有应用都应在系统栏后面绘制。
// Compose: 边到边设置
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
Scaffold(
modifier = Modifier.fillMaxSize(),
// Scaffold 自动处理顶部/底部栏的内边距
) { innerPadding ->
Content(modifier = Modifier.padding(innerPadding))
}
}
}
}
规则:
setContent 之前调用 enableEdgeToEdge()。在状态栏和导航栏后面绘制。WindowInsets 将内容与系统栏隔开。Scaffold 会自动为顶部栏和底部栏内容处理此问题。// Compose: 检测折叠姿态
val foldingFeatures = WindowInfoTracker.getOrCreate(context)
.windowLayoutInfo(context)
.collectAsState(initial = WindowLayoutInfo(emptyList()))
规则:
ListDetailPaneScaffold 或 SupportingPaneScaffold 来实现支持可折叠设备的布局。| 角色 | 默认大小 | 默认字重 | 用途 |
|---|---|---|---|
| displayLarge | 57sp | 400 | 英雄文本、引导页 |
| displayMedium | 45sp | 400 | 大型功能文本 |
| displaySmall | 36sp | 400 | 突出的显示文本 |
| headlineLarge | 32sp | 400 | 屏幕标题 |
| headlineMedium | 28sp | 400 | 节标题 |
| headlineSmall | 24sp | 400 | 卡片标题 |
| titleLarge | 22sp | 400 | 顶部应用栏标题 |
| titleMedium | 16sp | 500 | 标签页、导航 |
| titleSmall | 14sp | 500 | 副标题 |
| bodyLarge | 16sp | 400 | 主要正文文本 |
| bodyMedium | 14sp | 400 | 次要正文文本 |
| bodySmall | 12sp | 400 | 说明文字 |
| labelLarge | 14sp | 500 | 按钮、突出的标签 |
| labelMedium | 12sp | 500 | 芯片、较小的标签 |
| labelSmall | 11sp | 500 | 时间戳、注释 |
// Compose: 自定义排版
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
)
// ... 定义所有 15 个角色
)
规则:
sp 单位表示文本大小,以支持用户字体缩放偏好。MaterialTheme.typography 引用排版角色,而非硬编码大小。FAB 代表屏幕上最重要的单一操作。
// Compose: FAB 变体
// 标准 FAB
FloatingActionButton(onClick = { /* 操作 */ }) {
Icon(Icons.Default.Add, contentDescription = "创建新项目")
}
// 扩展 FAB (带标签 - 为了清晰度,推荐使用)
ExtendedFloatingActionButton(
onClick = { /* 操作 */ },
icon = { Icon(Icons.Default.Edit, contentDescription = null) },
text = { Text("撰写") }
)
// 大型 FAB
LargeFloatingActionButton(onClick = { /* 操作 */ }) {
Icon(Icons.Default.Add, contentDescription = "创建", modifier = Modifier.size(36.dp))
}
规则:
primaryContainer 颜色。对于次要屏幕,使用 tertiaryContainer。ExtendedFloatingActionButton。如果需要,可以在滚动时折叠为仅图标。// Compose: 顶部应用栏变体
// 小型 (默认)
TopAppBar(
title = { Text("页面标题") },
navigationIcon = {
IconButton(onClick = { /* 向上导航 */ }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
},
actions = {
IconButton(onClick = { /* 搜索 */ }) {
Icon(Icons.Default.Search, contentDescription = "搜索")
}
}
)
// 中型 — 扩展标题区域
MediumTopAppBar(
title = { Text("节标题") },
scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
)
// 大型 — 用于突出的标题
LargeTopAppBar(
title = { Text("屏幕标题") },
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
)
规则:
TopAppBar(小型)。对于突出的节或屏幕标题,使用 MediumTopAppBar 或 LargeTopAppBar。// Compose: 模态底部动作条
ModalBottomSheet(
onDismissRequest = { showSheet = false },
sheetState = rememberModalBottomSheetState()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("动作条标题", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(16.dp))
// 动作条内容
}
}
规则:
// Compose: 警告对话框
AlertDialog(
onDismissRequest = { showDialog = false },
title = { Text("丢弃草稿?") },
text = { Text("您未保存的更改将会丢失。") },
confirmButton = {
TextButton(onClick = { /* 确认 */ }) { Text("丢弃") }
},
dismissButton = {
TextButton(onClick = { showDialog = false }) { Text("取消") }
}
)
规则:
// Compose: 带操作的 Snackbar
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) {
// 触发 snackbar
LaunchedEffect(key) {
val result = snackbarHostState.showSnackbar(
message = "项目已归档",
actionLabel = "撤销",
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) { /* 撤销 */ }
}
}
规则:
// 筛选芯片
FilterChip(
selected = isSelected,
onClick = { isSelected = !isSelected },
label = { Text("筛选") },
leadingIcon = if (isSelected) {
{ Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) }
} else null
)
// 辅助芯片
AssistChip(
onClick = { /* 操作 */ },
label = { Text("添加到日历") },
leadingIcon = { Icon(Icons.Default.CalendarToday, contentDescription = null) }
)
规则:
FilterChip 切换筛选器,AssistChip 用于智能建议,InputChip 用于用户输入的内容(标签),SuggestionChip 用于动态生成的建议。| 需求 | 组件 |
|---|---|
| 主要屏幕操作 | FAB |
| 简短反馈 | Snackbar |
| 关键决策 | 对话框 |
| 补充内容 | 底部动作条 |
| 切换筛选器 | 筛选芯片 |
| 用户输入的标签 | 输入芯片 |
| 智能建议 | 辅助芯片 |
| 内容组 | 卡片 |
| 项目的垂直列表 | 带 ListItem 的 LazyColumn |
| 分段选项 (2-5) | SegmentedButton |
| 二进制切换 | 开关 |
| 从列表中选择 | 单选按钮或暴露的下拉菜单 |
// Compose: 无障碍组件
Icon(
Icons.Default.Favorite,
contentDescription = "添加到收藏夹" // 描述性,而非“心形图标”
)
// 装饰性元素
Icon(
Icons.Default.Star,
contentDescription = null // 对于纯装饰性元素,使用 null
)
// 合并复合元素的语义
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
Icon(Icons.Default.Event, contentDescription = null)
Text("2026年3月15日")
}
// 自定义操作
Box(modifier = Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("归档") { /* 归档 */ true },
CustomAccessibilityAction("删除") { /* 删除 */ true }
)
})
规则:
contentDescription(如果是纯装饰性的,则为 null)。mergeDescendants = true 将相关元素分组到单个 TalkBack 焦点单元中(例如,一个包含图标 + 文本 + 副标题的列表项)。customActions,以便 TalkBack 用户可以访问它们。// Compose: 确保最小触摸目标
IconButton(onClick = { /* 操作 */ }) {
// IconButton 已提供 48dp 的最小触摸目标
Icon(Icons.Default.Close, contentDescription = "关闭")
}
// 手动最小触摸目标
Box(
modifier = Modifier
.sizeIn(minWidth = 48.dp, minHeight = 48.dp)
.clickable { /* 操作 */ },
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Info, contentDescription = "信息", modifier = Modifier.size(24.dp))
}
规则:
规则:
Configuration.fontWeightAdjustment (API 31+) 来检测用户的粗体文本偏好并相应缩放自定义字重。使用 AccessibilityManager.isHighTextContrastEnabled() 来检测高对比度模式并替换为更高对比度的颜色值。Material 3 组件会自动处理这两者;自定义文本渲染和颜色使用必须显式选择加入。// 检测粗体文本偏好 (API 31+)
val fontWeightAdjustment = resources.configuration.fontWeightAdjustment
val isBoldText = fontWeightAdjustment >= 700 // 相当于 FontWeight.Bold.weight
// 检测高对比度模式
val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val isHighContrast = am.isHighTextContrastEnabled
// Compose: 使用自动遵循 fontWeightAdjustment 的 MaterialTheme.typography
Text(
text = "标签",
style = MaterialTheme.typography.bodyLarge // 适应 fontWeightAdjustment
)
// 对于自定义颜色:提供高对比度替代方案
val labelColor = if (isHighContrast) {
MaterialTheme.colorScheme.onSurface // 强对比度
} else {
MaterialTheme.colorScheme.onSurfaceVariant // 正常对比度
}
// Compose: 自定义焦点顺序
Column {
var focusRequester = remember { FocusRequester() }
TextField(
modifier = Modifier.focusRequester(focusRequester),
value = text,
onValueChange = { text = it }
)
LaunchedEffect(Unit) {
focusRequester.requestFocus() // 屏幕加载时自动聚焦
}
}
规则:
focusOrder。在画布上绘制内容的自定义 View 子类(图表、自定义选择器、绘图表面)默认对 TalkBack 是不可见的,因为它们没有子视图。使用来自 androidx.customview.widget 的 ExploreByTouchHelper 来定义虚拟无障碍树。
ExploreByTouchHelper 向 TalkBack 暴露虚拟无障碍树。覆盖 getVirtualViewAt() 以将触摸坐标映射到虚拟视图 ID,并覆盖 onPopulateNodeForVirtualView() 以提供每个虚拟节点的文本、边界和操作。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 {
// 返回 (x, y) 处切片的虚拟视图 ID,或 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)
}
规则:
WindowInsets.systemGestures 来检测和避免手势冲突区域。// Compose: 下拉刷新
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { viewModel.refresh() }
) {
LazyColumn { /* 内容 */ }
}
// Compose: 滑动关闭
SwipeToDismissBox(
state = rememberSwipeToDismissBoxState(),
backgroundContent = {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.error),
contentAlignment = Alignment.CenterEnd
) {
Icon(Icons.Default.Delete, contentDescription = "删除",
tint = MaterialTheme.colorScheme.onError)
}
}
) {
ListItem(headlineContent = { Text("可滑动项目") })
}
规则:
clickable 修饰符默认包含波纹效果。规则:
HapticFeedbackType.LongPress 在长按时提供触觉反馈。// 创建通知渠道 (Android 8+ 必需)
val channel = NotificationChannel(
"messages",
"消息",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "新消息通知"
enableLights(true)
lightColor = Color.BLUE
}
notificationManager.createNotificationChannel(channel)
| 重要性 | 行为 | 用于 |
|---|---|---|
| IMPORTANCE_HIGH | 声音 + 弹出通知 | 消息、通话 |
| IMPORTANCE_DEFAULT | 声音 | 社交更新、电子邮件 |
| IMPORTANCE_LOW | 无声音 | 推荐 |
| IMPORTANCE_MIN | 静音、无状态栏图标 | 天气、持续任务 |
规则:
IMPORTANCE_HIGH 会导致用户完全禁用通知。contentDescription 以支持无障碍功能。规则:
MessagingStyle。包含发送者姓名和头像。BigTextStyle、BigPictureStyle、InboxStyle)。// Compose: 权限请求
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
if (permissionState.status.isGranted) {
CameraPreview()
} else {
Column {
Text("需要相机权限来扫描二维码。")
Button(onClick = { permissionState.launchPermissionRequest() }) {
Text("授予相机权限")
}
}
}
规则:
// 照片选择器:无需权限
val pickMedia = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri ->
uri?.let { /* 处理选中的媒体 */ }
}
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
规则:
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:
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 |
// 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:
on color role for its background (e.g., onPrimary text on primary background).surface and its variants for backgrounds. Never use primary or secondary as large background areas.tertiary sparingly for accent and complementary contrast only.Support both light and dark themes. Respect the system setting by default.
// Compose: Detect system theme
val darkTheme = isSystemInDarkTheme()
Rules:
surface color roles which handle this automatically.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:
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:
secondaryContainer color. Do not override this.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:
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:
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 (/) so the exiting screen scales down and shifts toward the initiating edge, matching the system animation.
| 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 |
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:
WindowSizeClass from material3-window-size-class for responsive layout decisions.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:
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:
enableEdgeToEdge() before setContent. Draw behind both status bar and navigation bar.WindowInsets to pad content away from system bars. Scaffold handles this for top bar and bottom bar content automatically.// Compose: Detect fold posture
val foldingFeatures = WindowInfoTracker.getOrCreate(context)
.windowLayoutInfo(context)
.collectAsState(initial = WindowLayoutInfo(emptyList()))
Rules:
ListDetailPaneScaffold or SupportingPaneScaffold from Material3 adaptive library for foldable-aware layouts.| 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 |
// 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:
sp units for text sizes to support user font scaling preferences.MaterialTheme.typography, not hardcoded sizes.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:
primaryContainer color by default. Use tertiaryContainer for secondary screens.ExtendedFloatingActionButton with a label for clarity. Collapse to icon-only on scroll if needed.// 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:
TopAppBar (small) for most screens. Use MediumTopAppBar or LargeTopAppBar for prominent section or screen titles.// 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:
// 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:
// 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:
// 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:
FilterChip for toggling filters, AssistChip for smart suggestions, InputChip for user-entered content (tags), SuggestionChip for dynamically generated suggestions.| 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 |
// 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:
contentDescription (or null if purely decorative).mergeDescendants = true to group related elements into a single TalkBack focus unit (e.g., a list item with icon + text + subtitle).customActions for swipe-to-dismiss or long-press actions so TalkBack users can access them.// 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:
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 }
// 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:
focusOrder unless the default is incorrect.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)
Rules:
WindowInsets.systemGestures to detect and avoid gesture conflict zones.// 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:
clickable modifier includes ripple by default.Rules:
HapticFeedbackType.LongPress.// 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:
IMPORTANCE_HIGH leads users to disable notifications entirely.contentDescription in notification icons for accessibility.Rules:
MessagingStyle for conversations. Include sender name and avatar.BigTextStyle, BigPictureStyle, InboxStyle) for rich content.// 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:
// Photo picker: no permission needed
val pickMedia = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri ->
uri?.let { /* handle selected media */ }
}
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
Rules:
READ_MEDIA_IMAGES. No permission needed.ACCESS_COARSE_LOCATION (approximate) unless precise location is essential for functionality.// 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:
GlanceTheme.system_app_widget_background_radius).<!-- 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:
Rules:
ShareCompat or Intent.createChooser. Provide rich previews with title, description, and thumbnail.Use this checklist to evaluate Android UI implementations:
| 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 |
Weekly Installs
384
Repository
GitHub Stars
295
First Seen
Feb 1, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode327
codex317
gemini-cli314
github-copilot291
cursor287
claude-code276
前端设计技能指南:避免AI垃圾美学,打造独特生产级界面
36,100 周安装
| 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 |
EDGE_LEFTEDGE_RIGHTR2.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() } }
| 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 |
}
| 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 |