对齐:眼睛比参考线挑剔
代码里明明写着 Alignment.Center,看上去就是偏了一点。你以为是错觉——不是。这一章把那个「一点」精确算出来,顺便说清楚对齐真正的作用是什么。
对齐做的不是「整齐」
先纠正一个常见的理解。对齐的价值不是「看起来整齐」——那是结果,不是目的。
当三个元素的左边缘落在同一条竖线上,眼睛会自动补出这条线,并且把线上的东西读成同一层。
所以对齐和上一章的间距做的是同一件事:声明关系。间距说「谁跟谁是一伙的」,对齐说「谁跟谁是同一级的」。
推论:不该同级的东西,就不该对齐。把次要说明和主标题左对齐,等于说它俩一样重要。想降级它,除了做轻,还可以让它缩进——缩进本身就是「我从属于上面那个」的信号。
选哪条边对齐
一个很实用的判断:沿着阅读方向的那条起始边,永远优先。
中文和英文都是从左往右读,所以左对齐是默认答案。原因很物理:左对齐时每行的起点在同一个位置,眼睛回扫时不用重新找起点。
| 对齐方式 | 什么时候用 | 代价 |
|---|---|---|
| 左对齐 | 默认。正文、列表、表单标签 | 无 |
| 居中 | 只在元素很少且很短时用:空态、对话框、启动页 | 每行起点都不同,多行文本读起来累 |
| 右对齐 | 只给数字:金额、数量、日期 | 文字右对齐几乎总是错的 |
| 两端对齐 | 移动端基本别用 | 会产生忽宽忽窄的词间空隙(「河流」) |
一列金额如果左对齐,9.90 和 1234.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 个间距里的一个:没有出处的补丁。
它其实是有出处的,而且能算:
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 的画布里就是画偏的。
所以这一节真正的用处有两个:
- 让你在遇到「明明居中却偏」时,知道这不是错觉,也知道该往哪边挪多少——而不是随便试个 2dp。
- 提醒你:自己画图标或者用第三方图标包时,这层补偿可能没有。发现偏了,先量质心,别直接加 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,是两种完全不同的东西——前者是知识,后者是债。
对齐的检查方法
比起间距,对齐更容易靠眼睛查,因为人对「一条线上有没有豁口」极其敏感。两个土办法:
- 截图,拉一条辅助线。任何截图工具都能画直线。把线贴在最左的元素上,往下扫一遍。
- 按 M 键——这本书的标注模式会把每个区块的外框画出来,左边缘对没对齐一眼可见。这一页现在就可以试。
还有一条更省事的:减少对齐线的数量。一屏上如果只有一条左对齐线(所有内容左边缘都在 16dp),那根本不会有对齐问题。每多一个缩进层级,就多一条要维护的线。
《Refactoring UI》里有一节叫「Grids are overrated」,说的是别把什么都做成百分比。这条在移动端同样成立,而且更狠。
你写 fillMaxWidth(0.48f) 想让两个卡片各占一半——在 360dp 上正好,在 840dp 的平板上,每个卡片就有 400dp 宽,里面那张 64dp 的头像会显得可笑地小。
正确的做法是分清哪些量该跟着屏幕走,哪些量有它自己的固有尺寸:头像、图标、按钮高度、正文字号——这些是固定的;只有容器的宽度和它们之间的间距该响应屏幕。第 11 章会把这条接到行长上。
- 「用哪种对齐」——默认左对齐;居中只给「元素很少且很短」的场合;数字右对齐并开
tnum。 - 「它看着偏了,挪几个像素」——先算质心。三角形往右 16.7%,圆形放大到 102%–105%,其余先怀疑是不是根本没偏。
- 「补偿写在哪」——写进组件内部,配一个有名字的常量和一句注释。绝不在调用处加裸 padding。