一个按钮有多少种样子
这是全书唯一一章要你做加法的。前面十九章都在关自由度,这一章要打开一张你一直没看见的表——而表上那些空格,不会因为你没填就不出现。
两组正交的状态
一个可点的东西,它的样子由两组互不相干的东西决定:
正交的意思是:它们可以同时发生。一个「加载中」的按钮也可以是「focused」的(用户用键盘 Tab 到了它)。
笛卡儿积是 6 × 5 = 30 格。但其中 16 格是真的不存在的:
disabled状态下没有 hovered / pressed——它压根不响应;- 「加载中 / 空 / 出错」这三种内容下,交互态没有意义(没东西可按)。
剩下 14 格是真要画的。而大多数人写了几格?
正常、按下、禁用。覆盖率 21.4%。
剩下 11 格全靠运气——准确地说,全靠框架的默认值。
而框架的默认值不是「什么都不做」,是「做一个通用的、不知道你业务的选择」:加载态是个转圈,空态是一片白,错误态是一句 Something went wrong。
用户见到这几个界面的频率,比你想的高得多。而它们恰好是用户最焦虑的时刻。
区分「不用做」和「忘了做」
这张表最大的价值不是让你把 14 格都画一遍,而是:
Demo 里那些灰色的「—」是经过判断确认不需要的,不是漏掉的。
这个区分很重要,因为它把「我没做」拆成了两种:
- 不用做——已经想过了,这个组合不存在。可以放心。
- 忘了做——还没想过。这是 bug 的产地。
不画这张表的时候,这两种在你脑子里是同一种——都是「我没写」。
状态层:Material 3 怎么表示交互态
交互态的视觉表达在 M3 里是统一的:在底色上叠一层「on 色」,透明度是定死的。
| 状态 | 状态层不透明度 | 叠在 #6750A4 上 |
|---|---|---|
hovered | 8% | #735EAB |
focused | 10% | #7662AD |
pressed | 10% | #7662AD |
dragged | 16% | #7F6CB3 |
因为它们不靠颜色区分,靠时间和形态区分:
pressed是瞬时的,而且伴随一个从触点扩散的涟漪;focused是持续的,而且伴随一圈焦点框。
这也提醒了一件事:焦点态不是可选的。用键盘、遥控器(Android TV)、或者辅助设备操作的用户,全靠它知道自己在哪儿。而它恰恰是所有状态里最容易被忘掉的一个——因为你开发时用的是触屏。
四个内容状态,各自要回答什么
| 状态 | 用户此刻在想 | 你必须给的 |
|---|---|---|
| 加载中 | 要多久?卡住了吗? | 骨架屏优于转圈(它顺带说明了「等下会出来什么」) |
| 空 | 是我的问题还是它坏了? | 一句人话 + 一个能点的动作。空态是引导的机会,不是道歉 |
| 出错 | 我该怎么办? | 发生了什么 + 怎么办。「重试」按钮是最低要求 |
| 离线 | 我刚才做的还在吗? | 明确说「已保存到本地,联网后同步」,或者明确说没保存 |
《Refactoring UI》专门有一节讲这个:空态是用户第一次见到这个功能的时刻——他此刻最需要引导,而你给了他一片白。
一个好空态有三样东西:说清楚这儿是干嘛的、一个明确的行动召唤、以及(如果合适)一个示例。
可查的判据:空态里有没有一个能点的东西?没有的话,用户到了这一屏就是死路。
写进 Compose
把状态做成密封类,编译器会帮你数格子——这是这一章能拿到的最大一笔工程红利:
sealed interface OrderUiState {
data object Loading : OrderUiState
data object Empty : OrderUiState
data class Error(val message: String, val retry: () -> Unit) : OrderUiState
data class Offline(val cached: List<Order>) : OrderUiState
data class Content(val orders: List<Order>) : OrderUiState
}
@Composable
fun OrderScreen(state: OrderUiState) {
// when 是穷尽的:漏一个分支编译不过。
// 这就是「忘了做」变成编译错误的那一刻。
when (state) {
OrderUiState.Loading -> OrderSkeleton()
OrderUiState.Empty -> EmptyState(
title = "还没有订单",
body = "下单之后可以在这里查看物流",
action = { Button(onGoShopping) { Text("去逛逛") } } // ← 必须有
)
is OrderUiState.Error -> ErrorState(state.message, state.retry)
is OrderUiState.Offline -> OfflineBanner() + OrderList(state.cached)
is OrderUiState.Content -> OrderList(state.orders)
}
}
// 交互态:用 InteractionSource 读,不要自己维护 boolean
val interactions = remember { MutableInteractionSource() }
val pressed by interactions.collectIsPressedAsState()
val focused by interactions.collectIsFocusedAsState()
sealed interface + 穷尽的 when,把「我忘了写空态」从一个运行时才发现的问题变成了一个编译错误。
这是第 1 章说的「主场优势」在这一卷的体现:设计师没法让 Figma 强制他画出所有状态,你可以让编译器强制你。
每个状态都写一个 Preview
最后一条很实际的建议:每个状态配一个 @Preview。
@Preview(name = "1 加载中") @Composable
private fun PreviewLoading() = AppTheme { OrderScreen(OrderUiState.Loading) }
@Preview(name = "2 空") @Composable
private fun PreviewEmpty() = AppTheme { OrderScreen(OrderUiState.Empty) }
@Preview(name = "3 出错") @Composable
private fun PreviewError() = AppTheme { OrderScreen(OrderUiState.Error("网络超时") {}) }
@Preview(name = "4 离线") @Composable
private fun PreviewOffline() = AppTheme { OrderScreen(OrderUiState.Offline(sampleOrders)) }
@Preview(name = "5 正常") @Composable
private fun PreviewContent() = AppTheme { OrderScreen(OrderUiState.Content(sampleOrders)) }
好处不止是能看:写 Preview 的过程会强迫你把每个状态想一遍。你会在写第二个 Preview 的时候发现「诶,空态该说什么」——而这个发现比在生产环境发现便宜几个数量级。
卷 V 收尾
- 「要写哪些状态」——14 格,不是 3 格。用
sealed interface+ 穷尽when,让编译器数。 - 「按下 / 焦点长什么样」——M3 状态层:8% / 10% / 10% / 16%。别自己调透明度,别忘掉焦点态。
- 「空态放什么」——一句人话 + 一个能点的动作。没有可点动作就是死路。
- 「怎么确保没漏」——每个状态一个
@Preview。写 Preview 的过程本身就是检查。