外观
主题与设计基调
这一页不教你怎么造主题 —— 配色、字阶、形状、动效已经在组件库里定完了,插件侧没有第二套。这一页教另一件事:怎么正确地消费它们。
做砸界面的从来不是「不知道有哪些 token」,而是两件事:
- 把视觉规格当成可以直接写的数字 ——
Color(0xFF...)、14.sp、RoundedCornerShape(12.dp)。这类写法在深色模式和宿主换肤时当场失效。 - 把「层级」理解成「多加几层颜色」—— 结果是整页越写越糊,用户不知道先看哪。
一句话定调:颜色、字阶、圆角只从 MaterialTheme 与 AppShapes 取;层级靠留白、圆角、容器色、字阶这四件套做,不靠堆色。
本页提到的一切 token 事实(有哪些角色、有哪些档位、圆角各是多少)一律以组件库为准:
| 要知道什么 | 去哪 |
|---|---|
| 有哪些颜色角色、深浅色怎么来的 | /api/ui/theme |
| 15 级字阶各是多少、用在哪 | /api/ui/type |
| M3 形状档位与命名形状 | /api/ui/shapes |
一、主题谁负责:PluginTheme,不是你
插件界面的外面已经有一层主题环境了。它由插件入口装好:
kotlin
// 这是入口文件(步骤 ⑥)里的代码,已经写好了 —— 你不要在 App() 里再包一层
class MainActivity : BasePluginActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
activity.setContent {
PluginTheme { // ← 主题入口只有这一个
App(onFinish = { activity.finish() })
}
}
}
}PluginTheme(在 obsession-shared,见宿主桥)会读宿主的深浅色设置(0 跟随系统 / 1 强制浅 / 2 强制深),折算成一个布尔值再交给组件库的主题函数,顺带把配色、字阶、形状、动效规格、过渡滚动、轻提示容器一起挂好。用户切浅色 / 深色,插件自动跟着变。
你的职责只有一句话:在已经挂好的环境里,取正确的 token。
下面这些一行都不要写
| 别写 | 为什么错 | 你该做的 |
|---|---|---|
HuaLuoTheme { … } | 它没有 darkTheme 的默认值,不传就退化成「跟系统」,直接忽略宿主的深浅色设置 —— 这正是「浅色 / 深色失效」的根因 | 什么都不包。宿主已经包过了 |
MaterialTheme { … } 自建一层 | 它不会装上配色 / 字阶 / 形状;包一层之后下游取到的全是 M3 默认值 | 同上 |
自建 ColorScheme / lightColorScheme() / defaultColorScheme() | 换配色是宿主的事。插件塞进第二套,用户换肤在这个页面就失效 | 需要什么语义,取 MaterialTheme.colorScheme.* |
isSystemInDarkTheme()、SettingsManager.getDarkModeOption() | 「深浅色怎么折算」就是 PluginTheme 正在做的活;自己再算一遍 = 绕过宿主设置 | 不需要判断深浅色 —— token 自己会跟着变 |
自定义字体、覆盖 Typography、MaterialTheme(fontFamily = …) | 字阶是骨架、字体是系统默认。内嵌第三方字体会让 APK 多出十几 MB | 用 MaterialTheme.typography.* 的档位 |
| 局部再套一层主题 | 嵌套会把轻提示容器与过渡滚动工厂再挂一份,内层还整体覆盖外层配色 | 只取 token,不建主题 |
三层分工看清楚,就不会越界:
| 角色 | 负责什么 | 产出 |
|---|---|---|
组件库 hualuo-compose-ui | 定义 token 与每个组件的标准规格 | Typography / Shapes / AppShapes / 两套内置 ColorScheme / 组件默认值 |
宿主 + PluginTheme | 决定这一刻用哪一套(浅色 / 深色 / 将来换肤) | 一个挂好的主题环境 |
| 你(插件) | 在里面取对的 token,把业务结构搭成组件 | 干净的界面 |
插件不要去组件库找「主题入口」
组件库文档里的主题入口是给宿主用的。插件侧唯一的主题入口是 PluginTheme。你要从组件库那几页拿的不是「怎么建主题」,而是「有哪些角色和档位可以取」。
二、颜色:只从 MaterialTheme.colorScheme 取
2.1 为什么一行 Color(0xFF…) 都不能写
| 写法 | 后果 |
|---|---|
Color(0xFFF2F3FA) 当卡片底 | 这是浅色主题的卡片色。深色模式下它还是一块亮白,页面直接花掉 |
Color(0xFF1A1A1A) 当正文色 | 深色模式下黑字压深底,读不出来 |
| 任意硬编码色 | 宿主换肤 = 换一个 ColorScheme;硬编码色不认识它,不跟着走 |
还有一条更隐蔽的:语义色是成对设计的(容器色 + 压在上面的 on 角色),它们的对比度是算过的。你手挑两种颜色配在一起,没人保证它们能读清 —— 尤其在深色下。
2.2 角色取用对照表
M3 的槽位分两类:容器角色(铺底的那一支)和 on 角色(压在该容器上的文字 / 图标那一支)。成对取,颜色和对比度就都是对的。
| 角色 | 用在哪 | 别用在哪 |
|---|---|---|
primary | 唯一的强调色:实心按钮底、选中胶囊底、聚焦描边与光标 | 大面积铺底(整块卡片背景);长段正文 |
onPrimary | 压在 primary 上的文字 / 图标(FilledButton 内部就是这么取的) | 任何非 primary 底色之上 |
primaryContainer | 「有色但不抢戏」的小块:ListItem 左侧图标容器底、强调型小标签 | 和 primary 实心块同屏大面积并用 |
onPrimaryContainer | 压在 primaryContainer 上的图标 / 文字(ListItem 图标默认色) | 直接铺在白底上当正文色 |
secondaryContainer | 次级容器底:低强调的块、TonalButton 的默认底 | 当主色用;承载大段正文 |
onSecondaryContainer | 压在 secondaryContainer 上的文字 / 图标 | — |
surface | 表面色(页面骨架的容器色默认就是它,通常不用你手取) | 强调色;当卡片底去区分层级 |
surfaceContainer | 一屏里最多的卡片色:内容卡、分组卡(SectionRow 的 GROUP 卡就是它) | 卡片套卡片时内外同色(两张卡会糊成一块) |
surfaceContainerLow | 比 surfaceContainer 更靠近页面底色的低一层容器:嵌套卡片内层、次级区块 | 需要「浮起来」的主卡片 |
surfaceContainerHighest | 最强的一档容器色:空态图标容器底(EmptyHint 默认)、角标、chip 底 | 大面积铺底 |
onSurface | 主文字、标题、主要图标 | 次要说明文字 |
onSurfaceVariant | 次要文字与弱图标:副标题、说明、时间戳、未选中的图标 | 标题与主要动作 |
outline | 需要看清边界的描边:未选中胶囊描边、输入框未聚焦描边 | 当分割线用;大面积描边 |
outlineVariant | 最弱的分割与描边:HorizontalDivider、标签描边 | 需要明确边界的可操作控件(那里用 outline) |
error | 错误态与危险动作:错误提示文字、错误描边、SectionItem(danger = true) 的标题与图标 | 当装饰色用 |
onError | 压在 error 上的文字 / 图标 | — |
errorContainer | 错误态的容器底:错误档提示条、错误图标容器 | 当普通强调色用 |
onErrorContainer | 压在 errorContainer 上的文字 / 图标 | — |
background | 页面整体底色(骨架的 containerColor 默认就是它) | 卡片底(那是 surfaceContainer 系) |
选角色的顺序只有两步:先问「我是容器还是压在容器上的内容」,再问「我在这一屏是主、是次、还是分隔」。答完这两问,上面表里只有一个格子。
2.3 三条补充规则
- M3 没有的语义色不要往主题里加。 成功绿、警告琥珀这类状态色由用到它的那个组件自己持有常量。往主题里塞第二套「扩展配色」,每加一个语义就多维护一组明暗映射,还会让「换个配色」从改一个文件变成改两个。
Color.kt里的*Light/*Darktoken 不要直接引用。 它们是给主题构造用的原料,换肤时不跟着走。业务代码能碰的只有MaterialTheme.colorScheme.*。- 组件给了默认色的地方不要自己传。
ListItem的图标底 / 图标色、SectionItem(danger = true)的error色、EmptyHint的图标容器色、TextField的聚焦primary/ 未聚焦outline,都是刻意的取值。只有在组件明确开放了colors/tint参数、且你能说出「为什么要偏离默认」时才覆盖。
三、字阶:档位就是全部约定
页面里不写 fontSize / fontWeight / letterSpacing。内容类型决定档位,档位决定字号、行高、字距、字重。
| 内容 | 档位 |
|---|---|
折叠大标题页的标题(CollapsingTitleScaffold 内) | headlineLarge |
普通标题栏(TopTitleBar 默认取这条) | headlineMedium |
首页区块标题(SectionRow(style = SECTION)) | titleLarge |
| 区块小标题 | headlineSmall |
卡片标题 / 列表项标题 / 菜单选中项(ListItem.title、SectionItem.title) | titleMedium |
| 弹窗标题、空态主文案、提示条标题 | titleSmall |
| 正文、菜单项 | bodyLarge |
| 次要正文、错误说明、输入框内的文字 | bodyMedium |
副标题、说明文案(ListItem.subtitle、空态说明) | bodySmall |
| 按钮文字 | labelLarge |
标签、小号辅助文案(PillLabel、SectionRow(GROUP) 的标题) | labelMedium |
| 角标 | labelSmall |
三条容易踩的:
- 会变宽度的数字必须等宽。 余额、观看次数、版本号、下载进度、倒计时一变,整行就左右抖 —— 因为默认是比例数字,
1比8窄。用NumberStyles.display/title/label,或临时给任意样式开等宽:style = MaterialTheme.typography.titleLarge.tabular()。 - 不要覆盖字阶自带的字距。 大字号是负字距、小字号是正字距,按字号校准过;行高也比 M3 的英文基线略大,因为中文比拉丁文高,多行中文靠这个才不挤。
- 别在组件上再手拧一次字阶。 组件内部已经取了正确的档位(
ListItem标题titleMedium、副标题bodySmall;空态主文案titleSmall、说明bodySmall)。想改字阶意味着你要改的是内容类型,不是「让这行字小一点」。
headline* 的两处真实字重
headlineLarge / headlineMedium 文档注释说改成 Bold 了,但代码里的实际值仍是 Black(900),以代码为准。所以直接引用这两个 token 的地方拿到的是 900 —— 这也是为什么折叠标题栏与登录页大标题选择自己手写 Bold。你自己排大字号标题时,优先用 displaySmall / titleLarge 这类常规档位。
四、形状:优先语义档位,其次命名形状
两个入口:M3 的语义档位(跟主题走)和 AppShapes 的命名形状(按用途命名)。不要自己写 RoundedCornerShape(…)。
| 入口 | 值 | 用在哪 |
|---|---|---|
MaterialTheme.shapes.extraSmall | 8dp | 极小元素 |
MaterialTheme.shapes.small | 12dp | 小元素 |
MaterialTheme.shapes.medium | 16dp | 默认首选:一般容器 |
MaterialTheme.shapes.large | 24dp | 大容器 |
MaterialTheme.shapes.extraLarge | 32dp | 特大容器 |
AppShapes.card | 四角 24dp | 标准卡片:内容卡、设置分组、资产卡 |
AppShapes.cardSmall | 四角 16dp | 小卡片:二级卡片、弹窗内块、下拉菜单 |
AppShapes.row | 四角 16dp | 列表行(整行当一张卡时用) |
AppShapes.heroTop | 上 28dp / 下直角 | 折叠标题栏的内容卡片 |
AppShapes.sheetTop | 上 28dp / 下直角 | 底部弹层、底部导航上沿 |
AppShapes.iconContainer | 12dp | 列表项左侧图标容器 |
AppShapes.iconContainerLarge | 16dp | 大图标容器:应用图标、头像占位、空态图标 |
AppShapes.pill | 50%(胶囊) | 按钮、标签、chip |
AppShapes.badge | 6dp | 小角标:Update 标记、数量徽标 |
AppShapes.textField | 12dp | 输入框(单行与多行都是这一档) |
四条判断规则:
- 能取语义档位就取
MaterialTheme.shapes.*,AppShapes留给「档位表达不了」的形态 —— 不对称圆角、胶囊、内层收紧的图标容器。 - 内层要比外层小一档。 卡片 24dp,卡片内的图标容器只用 12dp。大圆角套大圆角会糊成一团。
- 同值不同语义的别互替。
heroTop和sheetTop值一样但语义不同(折叠头部 vs 底部弹层上沿),row和cardSmall同理 —— 将来只改其中一个时不会误伤另一个。 - 组件已经定死的形状不要覆盖。 四个按钮形态固定胶囊、
TextField/TextArea固定 12dp(浮动标签在边线上开缺口,胶囊弧放不下)、PillTab胶囊、ListItem的图标容器 48dp。这些都不开放参数,也别用modifier去改。
五、视觉层级:靠四件套,不靠堆颜色
想让一个东西「更重」,正确的四个手段,按优先顺序:
| 手段 | 怎么用 | 例 |
|---|---|---|
| 留白 | 一级容器给足内边距,二级收紧,三级最紧 | 20dp → 16dp → 12dp |
| 圆角 | 内层比外层小一档 | 24dp → 16dp → 12dp |
| 容器色 | 用 surfaceContainer 的相邻档位做深浅,一屏最多两档 | surfaceContainer → surfaceContainerLow → surfaceContainerHighest |
| 字阶 | 同一块内容里最多跨两档 | titleMedium → titleSmall → labelMedium |
不要用:加边框、加阴影、加第三种强调色、把字重再加重一档。边框和阴影在这里都是「外来噪音」,而颜色一多,主次就没了。
同一张卡片,做出一级 / 二级 / 三级
kotlin
// 一级:页面主卡片 —— 留白最松、圆角最大、容器色最靠外、字阶最高
@Composable
fun PrimaryCard(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(AppShapes.card) // 24dp
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
content = content,
)
}
// 二级:卡片内部的区块 —— 圆角降一档、容器色降一档、内边距收紧
@Composable
fun SecondaryBlock(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(AppShapes.cardSmall) // 16dp
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
content = content,
)
}
// 三级:最小信息块 —— 圆角再降一档、容器色取最强那档、内边距最紧
@Composable
fun TertiaryChip(
label: String,
modifier: Modifier = Modifier,
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier
.clip(AppShapes.iconContainer) // 12dp
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}配套的用法(层级是组合出来的,不是单靠某一件):
kotlin
PrimaryCard {
Text("本月统计", style = MaterialTheme.typography.titleMedium) // 一级字阶
SecondaryBlock {
Text("已读 12 本", style = MaterialTheme.typography.titleSmall) // 二级字阶
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TertiaryChip("达标")
TertiaryChip("连续 7 天")
}
}
}三条边界:
- 容器色最多两档同屏。 三级块挤在同一张卡里时,第三档只在「小到一眼看出是标签」时用;否则退回留白区分。
- 不要为了「有层次」而嵌套。 一个二级块里再放二级块,说明这个结构该拆成两屏或者换成分组标题。
- 卡片套卡片时内外不能同色。 外层
surfaceContainer,内层就取surfaceContainerLow或surfaceContainerHighest,别复制同一个角色。
六、原子设计分级:先想清楚这个 composable 属于哪一层
分级不是为了好看,是为了回答两个问题:它能不能被第二处复用,以及它该收什么参数。
| 层级 | 判定标准 | 插件里的例子 |
|---|---|---|
| atom 原子 | 单一职责;不含业务字段(或只含一个值 + 一个回调);内部只用 token 与 foundation 原语 | StatNumber(NumberStyles.display + primary)、CoverIcon(AppShapes.iconContainer + surfaceContainerHighest)、TagChip(PillLabel) |
| molecule 分子 | 2~4 个原子拼成「能干成一件事」的单元;收 data class 与回调,不收仓库 / 状态容器 | BookRow = ListItem + FavoriteIconButton;SearchRow = TextField + 清空按钮 |
| organism 有机体 | 一个屏幕区域:分子 + 原子 + 它自己的状态分支;仍然只收数据与回调 | BookListSection = 分组标题 + LazyColumn(BookRow) + 到底脚注;HeaderSection = 折叠标题栏 + PillTabsRow |
| template 模板 | 只定骨架与插槽,不含数据、不碰业务状态 | LibraryScreenTemplate = TopTitleScaffold + 三个插槽 |
| screen 页面 | 唯一可以持有业务状态、调工具库、判三态的一层 | App(onFinish) |
依赖规则只有一条:每层只依赖它下面那层。 有机体里不要重新发明原子 —— 判断标准不是「能不能出现 Text」(Text(style = MaterialTheme.typography.…) 本身就是 token 消费,不违规),而是有没有把同一套视觉规格写第二遍。同一个「图标容器 + 圆角 + 配色 + 尺寸」在三个文件里各写一遍,就是该往上抽的信号。
可复用组件的契约
抽出来的玩意要能被下一个人直接用,必须满足这六条:
kotlin
/**
* 无状态版是主签名:状态由调用方持有,组件只发通知。
* 需要省事的地方再开一个同名的 stateful 重载,内部 remember。
*/
@Composable
fun ReaderRow(
title: String,
progress: Float,
onFavoriteToggle: () -> Unit, // 事件往上走
modifier: Modifier = Modifier, // ① 必备,且透传到根节点
trailing: @Composable (() -> Unit)? = null, // ② 可变内容走插槽
) {
ListItem(
title = title,
subtitle = "${(progress * 100).toInt()}%",
icon = Phosphor.Book,
trailing = trailing
?: { FavoriteIconButton(isFavorite = false, onToggle = onFavoriteToggle) },
modifier = modifier,
)
}| 契约 | 具体要求 |
|---|---|
modifier 参数 | modifier: Modifier = Modifier,且透传到根节点、不要吞掉。调用方控制外部边距 / 尺寸的唯一入口 |
| slot 优先于开关 | 内容会变的地方给 @Composable 插槽(trailing / content),不要给一堆 showXxx: Boolean 枚举。组件库自己就是这么做的(ListItem.trailing、SectionRow.content) |
| 默认值能独立跑通 | 不传任何可选参数也应该渲染合理。默认值本身就是「标准用法」—— 随手传 size = ButtonSize.SMALL 属于例外,要有理由 |
| 只用 token | 函数体里出现 Color(0xFF / 14.sp / FontWeight.Bold / RoundedCornerShape( 就是没写完 |
| stateful / stateless 成对 | 主签名无状态(收 value + onValueChange,可预览、可复用、不会出现第二份不同步的数据);另开一个重载在内部 remember 供省事调用 |
| 按「它是什么」命名 | ReaderRow 而不是 HomeReaderRow;GradeCard 而不是 RedBorderCard。按所在页面或外观命名的东西,复用不出去 |
层级也不是越细越好:原子只在被第二处用到时才抽。一个页面专用的「封面 + 书名 + 进度」直接写成分子就行,为了凑分级把每个 Text 都包一层,只会让调用链变长。
七、设计稿落地五步法
拿到一张设计稿(或截图、或自己画的线框),按这个顺序落,不要从「先摆一个 Column」开始:
| 步 | 做什么 | 产出 |
|---|---|---|
| ① 拆解层级 | 从上往下切横向分区,每个分区起一个按用途的名字 | 一张分区清单 |
| ② 映射 token | 每个分区里的色 / 字阶 / 圆角,找到对应的角色与档位 | token 对照,不允许留「看起来差不多的色」 |
| ③ 定间距 | 内边距、分区间距、行间距,全部落到 8dp 网格(4dp 只给紧邻的图文配对) | 一串数字 |
| ④ 定状态 | 空 / 加载 / 错误 / 正常,四态各自的组件与文案 | 一个 when |
| ⑤ 定动效 | 只在「状态切换」和「进出场」上加动效,用主题里的动效规格 | 动效清单 |
间距这一条说清楚:本项目没有间距 token,间距就是数字 dp,按 8dp 网格取值(常用 16dp)。不要为了「整齐」在插件里新建一套 CompositionLocal 间距系统 —— 多一层间接、跨插件不通用,而且它会和组件内部已经定好的边距打架。组件内部边距固定、外部边距全归 modifier,这条规则组件库自己也守着。间距细节见布局与 Modifier。
动效只用两样东西:animate*AsState / AnimatedVisibility 这类官方 API,和 MaterialTheme.motionScheme 里的规格(defaultSpatialSpec / defaultEffectsSpec)。不要写死 tween(300) —— 写死就换不回来了。完整判断见动效。
完整示范:从一张列表页截图到 composable 树
设计稿的文字描述(真实场景都没 Figma,只有一句需求或一张截图):
一个漫画书架列表页。顶部一行大标题「我的书架」,右边一个搜索图标。下面一排胶囊分类:全部 / 连载中 / 已完结。再下面是卡片列表,每行左边一个封面缩略图,右边书名 + 作者,最右侧一个心形收藏按钮。列表滚到底显示「没有更多了」。下拉可以刷新。
① 拆层级(从上往下切,按用途命名,不按布局命名):
| 分区 | 命名 | 动静 |
|---|---|---|
| 标题栏 + 搜索入口 | Header | 静态 |
| 分类切换 | CategoryTabs | 与列表联动 |
| 内容列表 | BookList | 长列表、分页、可刷新 |
| 分页脚注 | ListFooter | 随分页状态变 |
② 映射 token:
| 稿子里看到 | 映射到 |
|---|---|
| 页面底 | background(骨架自己管,不用手取) |
| 卡片底 | surfaceContainer |
| 书名 | titleMedium + onSurface |
| 作者 | bodySmall + onSurfaceVariant |
| 收藏心形(未收藏 / 已收藏) | onSurfaceVariant / primary(FavoriteIconButton 默认值) |
| 卡片圆角 | AppShapes.card |
| 封面缩略图容器 | AppShapes.iconContainer + surfaceContainerHighest |
③ 定间距:页面左右 16dp;卡片内边距 16dp;卡片之间 8dp;分类行与列表之间 12dp。
④ 定状态:LoadingState(首屏) / ErrorState(带重试) / EmptyHint(真的没数据) / 正常列表。
⑤ 定动效:分类切换时列表内容用淡入淡出;EmptyHint ↔ 列表之间不额外加动画。
推导出的树(每层标注用哪个组件库组件):
text
App(onFinish) ← screen:持有分页状态、三态分支
└── TopTitleScaffold(title = "我的书架", onBack = onFinish,
│ actions = { ActionImageButton(Phosphor.MagnifyingGlass, …) })
│ ← template / 骨架:标题栏 + 三边 inset
├── PillTabsRow(labels, selectedIndex, onSelect)
│ ← organism:分类导航,选中态外置
└── PullToRefresh(...) ← 组件库:包住「可刷新的内容」
└── ContentList(...) ← organism:四态分支
├── LoadingState()
├── ErrorState(message = …, onRetry = …)
├── EmptyHint(text = …, description = …)
└── LazyColumn(state = listState) ← foundation 原语
├── items(books, key = { it.id }) { BookRow(book, onToggleFavorite) }
│ ← molecule
└── item { ListFooter(hasMore, loadFailed, onRetry) }
← molecule / 脚注继续往下拆一层,分子就是原子的组合:
text
BookRow(book, onToggleFavorite) ← molecule
└── ListItem(
title = book.title,
subtitle = book.author,
iconUrl = book.cover,
trailing = { FavoriteIconButton(isFavorite = book.isFavorite,
onToggle = onToggleFavorite) },
onClick = onOpenDetail,
)几个能对回前面几条规则的落点:
- 没有一处颜色、字号、圆角是手写的。 卡片底走
surfaceContainer,封面容器走AppShapes.iconContainer,书名走titleMedium。深色模式下这些自动变。 - 标题单行、副标题最多两行、超长都省略 —— 由
ListItem自己管,不用写maxLines。同理行高、图标容器 48dp、图标与文字间距都由组件标定。 - 收藏按钮是受控的:
FavoriteIconButton只发通知、不自己翻转状态,所以book.isFavorite必须真的被改掉,否则点了没反应。 - 分页脚注是一个
item { },不是给LazyColumn外面套一层Column—— 它得跟着列表滚。自动加载更多的完整写法见列表、滚动与自动加载更多。 - 搜索入口用标题栏图标按钮(
ActionImageButton),因为搜索不是这个页面的主要动作。如果搜索是主动作,就改成页面里画TextField—— 二选一,不要两种都放。
八、常见错误
| 错误 | 长什么样 | 后果 | 正确做法 |
|---|---|---|---|
| 硬编码颜色 | Color(0xFFF2F3FA) 当卡片底、Color.White 当文字 | 深色模式下白底白字;宿主换肤不跟 | MaterialTheme.colorScheme.*,按角色表取 |
| 硬编码字号 / 字重 | fontSize = 14.sp、fontWeight = FontWeight.Bold | 全站字阶失去唯一来源,标题一页一个样 | style = MaterialTheme.typography.… |
| 硬编码圆角 | RoundedCornerShape(12.dp) | 改一处要改十处,迟早和 AppShapes 漂移 | MaterialTheme.shapes.* / AppShapes.* |
| 自己拼标题栏 | 手写 Row + 返回箭头 + 自己补 statusBarsPadding() | 三边 inset 漏一处就顶到状态栏;返回键触控区不达标 | TopTitleScaffold(别自己补 inset) |
| 间距魔法数字 | 3dp / 7dp / 18dp / 26dp 随手写 | 全站参差,整体「差一点」但查不出哪差 | 8dp 网格;4dp 只给紧邻图文配对 |
| 一页塞太多主色 | 选中胶囊 + FilledButton + 主色图标容器同屏 | 几个实心主色块互相抢焦点,用户不知道主操作是哪个 | 一屏只留一个主色实心块,其余降档(TonalButton / OutlinedButton / TertiaryButton) |
| 深色下对比度不够 | 次要文字用 onSurface、1dp 细线用 outline 硬描 | 深色下读不清或看不见分隔 | 按角色表取;对比度要求见无障碍 |
| 用了不存在的 token | MaterialTheme.colorScheme.surfaceVariantHigh、MaterialTheme.typography.bodyXLarge | 编译不过;或者你以为取自主题,实际拿到别的值 | 只从 /api/ui/theme / /api/ui/type 列出的档位里取 |
| 卡片套卡片同色 | 内外都 surfaceContainer | 两张卡糊成一块,看不出嵌套 | 内层换相邻档位(surfaceContainerLow / surfaceContainerHighest)+圆角降一档 |
落笔前过一遍这五条,能滤掉绝大部分问题:
- [ ] 界面外层是
PluginTheme,我没写HuaLuoTheme/MaterialTheme/ 自建ColorScheme - [ ] 没有任何
Color(0xFF…)/fontSize/fontWeight/RoundedCornerShape(…) - [ ] 每个颜色都能说出它取自哪个角色,且和压在它上面的
on角色成对 - [ ] 层级靠留白 + 圆角 + 容器色 + 字阶做出来,没靠边框、阴影或第三种强调色
- [ ] 抽出来的 composable 有
modifier参数、可变内容走插槽、内部只用 token
组件参数怎么查、哪个组件管哪种形态,看 UI 组件库;间距与 Modifier 顺序看布局与 Modifier;状态怎么组织看状态与副作用。