卷 V · 手· CH 20· 状态矩阵 深度 20/28

一个按钮有多少种样子

这是全书唯一一章要你做加法的。前面十九章都在关自由度,这一章要打开一张你一直没看见的表——而表上那些空格,不会因为你没填就不出现。

14 个真格子覆盖率 21.4%状态层 8/10/10/16%

两组正交的状态

一个可点的东西,它的样子由两组互不相干的东西决定:

交互状态enabled / hovered / focused / pressed / dragged / disabled
内容状态正常 / 加载中 / 空 / 出错 / 离线

正交的意思是:它们可以同时发生。一个「加载中」的按钮也可以是「focused」的(用户用键盘 Tab 到了它)。

笛卡儿积是 6 × 5 = 30 格。但其中 16 格是真的不存在的:

  • disabled 状态下没有 hovered / pressed——它压根不响应;
  • 「加载中 / 空 / 出错」这三种内容下,交互态没有意义(没东西可按)。

剩下 14 格是真要画的。而大多数人写了几格?

3 格

正常、按下、禁用。覆盖率 21.4%

剩下 11 格全靠运气——准确地说,全靠框架的默认值

而框架的默认值不是「什么都不做」,是「做一个通用的、不知道你业务的选择」:加载态是个转圈,空态是一片白,错误态是一句 Something went wrong

用户见到这几个界面的频率,比你想的高得多。而它们恰好是用户最焦虑的时刻。

区分「不用做」和「忘了做」

这张表最大的价值不是让你把 14 格都画一遍,而是:

把「不存在的格子」显式标出来

Demo 里那些灰色的「—」是经过判断确认不需要的,不是漏掉的。

这个区分很重要,因为它把「我没做」拆成了两种:

  • 不用做——已经想过了,这个组合不存在。可以放心。
  • 忘了做——还没想过。这是 bug 的产地。

不画这张表的时候,这两种在你脑子里是同一种——都是「我没写」。

状态层:Material 3 怎么表示交互态

交互态的视觉表达在 M3 里是统一的:在底色上叠一层「on 色」,透明度是定死的。

状态状态层不透明度叠在 #6750A4 上
hovered8%#735EAB
focused10%#7662AD
pressed10%#7662AD
dragged16%#7F6CB3
为什么 pressed 和 focused 是同一个数

因为它们不靠颜色区分,靠时间和形态区分:

  • 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 收尾

加不加动效看它有没有解释因果
怎么动进来减速、离开加速;手势用 spring
多大能点可点区 ≥ 48dp,和视觉尺寸分开
有几种样子14 格,用 sealed class 让编译器帮你数
本章关掉的自由度本章 4 条 · 累计 58 / 78
  • 「要写哪些状态」——14 格,不是 3 格。用 sealed interface + 穷尽 when,让编译器数。
  • 「按下 / 焦点长什么样」——M3 状态层:8% / 10% / 10% / 16%。别自己调透明度,别忘掉焦点态。
  • 「空态放什么」——一句人话 + 一个能点的动作。没有可点动作就是死路。
  • 「怎么确保没漏」——每个状态一个 @Preview。写 Preview 的过程本身就是检查。