手指不是鼠标
鼠标指针是一个点,手指是一个直径将近一厘米的软垫,而且它还会挡住自己要点的东西。这一章把「点着费劲」换算成毫秒。
Fitts 定律
1954 年的一条心理物理学定律,说的是「移动到一个目标要多久」:
难度 ID = log2(距离 / 目标宽度 + 1) 单位:bit 耗时 MT = a + b × ID
两个直觉都在里面:目标越远越慢,目标越小越慢。但有意思的是那个 log2——它说明这两者的影响都是对数的。
拉一下滑杆,看这几组:
| 距离 | 目标 | ID | 耗时 |
|---|---|---|---|
| 48dp | 48dp | 1.00 bit | 204ms |
| 200dp | 48dp | 2.37 bit | 483ms |
| 200dp | 24dp | 3.22 bit | 657ms |
| 600dp | 24dp | 4.70 bit | 959ms |
在 200dp 外:24 → 48 省 174ms,48 → 96 省 152ms,96 → 192 只省 121ms。
所以做到 48 是划算的(一次性省下四分之一的时间),做到 96 就开始浪费屏幕了——你付出了四倍的面积,只换来越来越少的收益。
这就是「48dp 是个标准而不是越大越好」的定量理由。
鼠标指针撞到屏幕边就停住了。所以贴着边缘的目标,在 Fitts 的意义上宽度是无限大的——你可以粗暴地把鼠标往那个方向一甩。
macOS 的菜单栏贴顶、Windows 的开始按钮在左下角、关闭按钮在右上角,全都是在吃这个便宜。
触屏没有这条便宜——手指可以点到屏幕外面去,边缘不会「接住」你。取而代之的是另一套几何:拇指弧。
48dp 是怎么来的
不是拍脑袋。成年人指尖的接触面直径大约 8–10mm,而在 160dpi(也就是 1dp = 1px 的那个基准密度)下:
48 dp = 48 / 160 英寸 = 0.3 英寸 = 7.62 mm
刚好覆盖大部分人的指尖,同时不至于太浪费屏幕。同一个数在别的地方是这样的:
| 标准 | 要求 | 性质 |
|---|---|---|
| Material(Android) | 48dp | 建议 |
| Apple HIG | 44pt | 建议 |
| WCAG 2.1 AAA · 2.5.5 | 44px | 标准 |
| WCAG 2.2 AA · 2.5.8 | 24px | 标准(底线) |
WCAG 2.2 把 AA 的底线定在 24 而不是 44,是因为 44 在密集界面(比如日历、表格)里确实做不到。但 24 是底线,不是目标。
最重要的一条:视觉尺寸 ≠ 触摸目标
这是这一章唯一一条你今天就能去改代码的规则:
这两个数不是同一个东西。视觉尺寸由设计决定(24dp 的图标看起来正好),触摸目标由手指决定(48dp)。
做法是让可点区域向外扩展,而不是把图标做大:图标还是 24dp,外面套一个 48dp 的透明可点区。
Compose 的 IconButton 默认就是这么做的——所以你不该用 Modifier.clickable 自己造图标按钮,那样会丢掉这层。
Icon(
Icons.Default.Close,
contentDescription = "关闭",
modifier = Modifier
.size(24.dp)
.clickable { onClose() }
)
可点区域只有 24 × 24。用户点三次才关得掉。
IconButton(
onClick = onClose,
modifier = Modifier.size(48.dp)
) {
Icon(
Icons.Default.Close,
contentDescription = "关闭",
modifier = Modifier.size(24.dp)
)
}
看起来一模一样,可点区域 48 × 48。
clickable 写在哪一层,直接决定了触摸目标含不含那圈留白。顺序读法还是第 8 章那条:写在前面的更靠外。
// ✗ 可点区域 = 内容大小,那 12dp 留白点不到
Modifier.padding(12.dp).clickable { onClick() }
// ✓ 可点区域 = 内容 + 留白
Modifier
.clip(shape) // 先裁形状,涟漪才不会溢出圆角
.clickable { onClick() } // 可点区 = 这一层拿到的尺寸
.padding(12.dp) // 留白在可点区内部
clickable 写在 padding 前面,它拿到的就是「还没被 padding 缩小」的那个尺寸——于是留白也能点。
拇指弧
手机是单手握着用的。拇指的活动范围是以拇指根为圆心的一段圆弧,而不是一个矩形。
Demo 右边那张图画的就是这个:绿色是轻松够到的,黄色是够得着但要伸,白色的地方要换手。
这条几何在过去十年里变得越来越重要——手机从 4 寸长到了 6.7 寸,而拇指没变长。
推论:
- 主要动作放底部。这就是底部导航栏取代顶部 Tab 的原因,也是各家 App 把搜索框往下挪的原因。
- 返回不能只靠左上角。左上角对右手用户是最难够的一个点。必须支持系统返回手势和返回键。
- 破坏性操作可以故意放远。「删除账号」放在需要伸手的地方,是合理的设计——难够到就是一种保护。
还有两件小事
① 手指会挡住自己点的东西。所以反馈不该出现在手指下面——这就是长按弹出的菜单总是出现在上方、滑块的数值气泡总是浮在上面的原因。
② 相邻可点元素之间要留间隙。两个 48dp 的按钮紧挨着,中间那条边界上的误触率很高。Material 建议至少 8dp 的间隔——这正好是第 6 章那个阶梯上的一档。
// Compose 测试里可以直接断言触摸目标 —— 这条能进 CI
@Test
fun allIconButtonsMeetMinimumTouchTarget() {
composeTestRule.setContent { OrderScreen(...) }
listOf("close", "share", "more").forEach { tag ->
composeTestRule.onNodeWithTag(tag)
.assertHeightIsAtLeast(48.dp)
.assertWidthIsAtLeast(48.dp)
}
}
// 另外:别关掉 Material 的最小尺寸保障,除非你非常清楚在干什么
CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides Dp.Unspecified // ✗ 慎用
) { /* … */ }
- 「这个按钮做多大」——视觉尺寸随你,可点区域一律 ≥ 48dp。用
IconButton,不用Modifier.clickable自己造。 - 「主要动作放哪」——底部拇指弧内。返回不能只靠左上角。破坏性操作故意放远。
- 「相邻按钮之间留多少」——至少 8dp,防误触。反馈永远不出现在手指下方。