卷 V · 手· CH 19· Fitts 尺 深度 19/28

手指不是鼠标

鼠标指针是一个点,手指是一个直径将近一厘米的软垫,而且它还会挡住自己要点的东西。这一章把「点着费劲」换算成毫秒。

ID = log2(D/W + 1)48dp = 7.62mm视觉 ≠ 可点

Fitts 定律

1954 年的一条心理物理学定律,说的是「移动到一个目标要多久」:

难度 ID = log2(距离 / 目标宽度 + 1)      单位:bit
耗时 MT = a + b × ID

两个直觉都在里面:目标越远越慢,目标越小越慢。但有意思的是那个 log2——它说明这两者的影响都是对数的。

拉一下滑杆,看这几组:

距离目标ID耗时
48dp48dp1.00 bit204ms
200dp48dp2.37 bit483ms
200dp24dp3.22 bit657ms
600dp24dp4.70 bit959ms
把目标翻倍,省下的时间在递减

在 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 HIG44pt建议
WCAG 2.1 AAA · 2.5.544px标准
WCAG 2.2 AA · 2.5.824px标准(底线)

WCAG 2.2 把 AA 的底线定在 24 而不是 44,是因为 44 在密集界面(比如日历、表格)里确实做不到。但 24 是底线,不是目标。

最重要的一条:视觉尺寸 ≠ 触摸目标

这是这一章唯一一条你今天就能去改代码的规则:

图标可以是 24dp,但它的可点区域必须是 48dp

这两个数不是同一个东西。视觉尺寸由设计决定(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

第 8 章那个 Modifier 顺序在这里咬人

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  // ✗ 慎用
) { /* … */ }
本章关掉的自由度本章 3 条 · 累计 54 / 78
  • 「这个按钮做多大」——视觉尺寸随你,可点区域一律 ≥ 48dp。用 IconButton,不用 Modifier.clickable 自己造。
  • 「主要动作放哪」——底部拇指弧内。返回不能只靠左上角。破坏性操作故意放远。
  • 「相邻按钮之间留多少」——至少 8dp,防误触。反馈永远不出现在手指下方。