卷 II · 距离· CH 07· 真光学质心 深度 7/28

对齐:眼睛比参考线挑剔

代码里明明写着 Alignment.Center,看上去就是偏了一点。你以为是错觉——不是。这一章把那个「一点」精确算出来,顺便说清楚对齐真正的作用是什么。

多边形质心往右补 16.7%对齐边的选择

对齐做的不是「整齐」

先纠正一个常见的理解。对齐的价值不是「看起来整齐」——那是结果,不是目的。

对齐制造的是「隐形的线」

当三个元素的左边缘落在同一条竖线上,眼睛会自动补出这条线,并且把线上的东西读成同一层

所以对齐和上一章的间距做的是同一件事:声明关系。间距说「谁跟谁是一伙的」,对齐说「谁跟谁是同一级的」。

推论:不该同级的东西,就不该对齐。把次要说明和主标题左对齐,等于说它俩一样重要。想降级它,除了做轻,还可以让它缩进——缩进本身就是「我从属于上面那个」的信号。

选哪条边对齐

一个很实用的判断:沿着阅读方向的那条起始边,永远优先。

中文和英文都是从左往右读,所以左对齐是默认答案。原因很物理:左对齐时每行的起点在同一个位置,眼睛回扫时不用重新找起点。

对齐方式什么时候用代价
左对齐 默认。正文、列表、表单标签
居中 只在元素很少且很短时用:空态、对话框、启动页 每行起点都不同,多行文本读起来累
右对齐 只给数字:金额、数量、日期 文字右对齐几乎总是错的
两端对齐 移动端基本别用 会产生忽宽忽窄的词间空隙(「河流」)
数字必须右对齐,而且要等宽

一列金额如果左对齐,9.901234.00 的小数点不在一条线上,眼睛没法比较大小。右对齐让个位、十位、百位各自成列,扫一眼就能比。

还要加一条:用等宽数字。很多字体的默认数字是比例宽度的(1 比 8 窄),于是即使右对齐,中间几位也会错开。

Text(
    text = amount,
    textAlign = TextAlign.End,
    style = MaterialTheme.typography.bodyLarge.copy(
        fontFeatureSettings = "tnum"     // tabular numbers:数字等宽
    )
)

现在说那个「就是偏了一点」

一个圆形播放按钮,里面一个三角形。你写 Alignment.Center,跑起来看——三角形偏左。

你以为是自己眼花,于是加了个 padding(start = 2.dp),好像好点了。这个 2dp 就是第 1 章那 24 个间距里的一个:没有出处的补丁

它其实是有出处的,而且能算:

几何中心 vs 质心

Alignment.Center 对齐的是外接矩形的中心——把图形的最左、最右取平均。

但眼睛感知的「中心」是质心(面积的重心):面积集中在哪边,视觉中心就偏向哪边。

播放三角形的面积全堆在左边(左边是一整条竖边,右边收成一个尖),所以它的质心在外框宽度的 1/3 处,而外框中心在 1/2 处。

差值 = 1/2 − 1/3 = 1/6 ≈ 16.7%。一个 24px 的图标,就是往右挪 4px

多边形质心的公式一点都不玄,是标准的面积加权积分:

// 多边形质心:沿边做叉积加权
function centroid(pts) {
  var a = 0, cx = 0, cy = 0;
  for (var i = 0; i < pts.length; i++) {
    var p = pts[i], q = pts[(i + 1) % pts.length];
    var cross = p[0] * q[1] - q[0] * p[1];
    a  += cross;
    cx += (p[0] + q[0]) * cross;
    cy += (p[1] + q[1]) * cross;
  }
  a *= 0.5;
  return { cx: cx / (6 * a), cy: cy / (6 * a) };
}

// 播放三角 [[0,0],[100,50],[0,100]]
//   外框中心 x = 50.00
//   质心     x = 33.33
//   补偿     = 16.67  (外框宽的 16.7%)

在 Demo 里切换几个形状,你会看到一个规律:

  • 方块、圆、正十字:质心 = 外框中心,不需要补偿。所以你从没在它们身上察觉过问题。
  • 三角形、播放键:偏得最厉害。
  • 五边形:竖直方向也偏——尖朝上的形状,视觉重心比几何中心低。
好消息:这件事通常不用你做

Material Icons、SF Symbols 这些成熟图标库,已经在图标内部做好了光学补偿。播放键在 24×24 的画布里就是画偏的。

所以这一节真正的用处有两个:

  1. 让你在遇到「明明居中却偏」时,知道这不是错觉,也知道该往哪边挪多少——而不是随便试个 2dp。
  2. 提醒你:自己画图标或者用第三方图标包时,这层补偿可能没有。发现偏了,先量质心,别直接加 padding。

另外两处光学调整

① 圆形要比方形画得大一点

同样是 24px,一个正方形和一个圆放在一起,圆看起来更小。因为圆的面积是 πr²,而同尺寸方形是 4r²——圆只有方的 78.5%

所以图标库里的圆形图标(比如「录制」的实心圆)通常会画到 102%–105%。这也是为什么 Material 3 的图标规范给的是「光学尺寸」而不是「几何尺寸」。

② 文字左边缘要比色块左边缘再往左一点

一个带背景色的卡片,里面第一行文字。如果两者的 padding 完全相同,文字看起来会偏右。因为字母的左边缘不是一条直线——字形本身自带一点侧边空白(sidebearing)。

这个量很小(大约字号的 2%–4%),移动端通常忽略。但在大标题上会明显——这就是为什么落地页的巨型标题常常带一个负的 letterSpacing 和一点点负边距。

Compose 里做光学补偿,最干净的方式是把它封在组件里,不要在调用处散落:

// ✗ 补丁散落在调用处,三个月后没人知道 2.dp 是什么
Icon(Icons.Default.PlayArrow, null, Modifier.padding(start = 2.dp))

// ✓ 补偿写进组件,带出处
private val PlayOpticalShift = 0.167f   // 三角形质心在外框 1/3 处

@Composable
fun PlayButton(size: Dp = 48.dp, onClick: () -> Unit) {
    val iconSize = size * 0.5f
    IconButton(onClick = onClick, modifier = Modifier.size(size)) {
        Icon(
            imageVector = Icons.Default.PlayArrow,
            contentDescription = "播放",
            modifier = Modifier
                .size(iconSize)
                // 质心补偿:往右 1/6 个图标宽
                .offset(x = iconSize * (0.5f - 1f / 3f))
        )
    }
}

关键不在这几行代码,在那个常量名和它上面的注释。一个有名字、有注释的 0.167,和一个裸的 2.dp,是两种完全不同的东西——前者是知识,后者是债。

对齐的检查方法

比起间距,对齐更容易靠眼睛查,因为人对「一条线上有没有豁口」极其敏感。两个土办法:

  1. 截图,拉一条辅助线。任何截图工具都能画直线。把线贴在最左的元素上,往下扫一遍。
  2. M——这本书的标注模式会把每个区块的外框画出来,左边缘对没对齐一眼可见。这一页现在就可以试。

还有一条更省事的:减少对齐线的数量。一屏上如果只有一条左对齐线(所有内容左边缘都在 16dp),那根本不会有对齐问题。每多一个缩进层级,就多一条要维护的线。

顺带纠正一个 Web 时代带过来的习惯

《Refactoring UI》里有一节叫「Grids are overrated」,说的是别把什么都做成百分比。这条在移动端同样成立,而且更狠。

你写 fillMaxWidth(0.48f) 想让两个卡片各占一半——在 360dp 上正好,在 840dp 的平板上,每个卡片就有 400dp 宽,里面那张 64dp 的头像会显得可笑地小。

正确的做法是分清哪些量该跟着屏幕走,哪些量有它自己的固有尺寸:头像、图标、按钮高度、正文字号——这些是固定的;只有容器的宽度它们之间的间距该响应屏幕。第 11 章会把这条接到行长上。

本章关掉的自由度本章 3 条 · 累计 21 / 78
  • 「用哪种对齐」——默认左对齐;居中只给「元素很少且很短」的场合;数字右对齐并开 tnum
  • 「它看着偏了,挪几个像素」——先算质心。三角形往右 16.7%,圆形放大到 102%–105%,其余先怀疑是不是根本没偏。
  • 「补偿写在哪」——写进组件内部,配一个有名字的常量和一句注释。绝不在调用处加裸 padding。