自定义布局:从测量到绘制的完整掌控
说实话,Jetpack Compose 的布局系统是我最喜欢的一部分。它不像传统 View 系统那样有那么多隐式规则,而是把测量和放置的权力完全交到了你手里。今天我们就来聊聊自定义布局的四个核心维度:Layout composable、测量与放置、自定义 Modifier、IntrinsicSize,以及自定义绘制。
核心观点:Compose 的布局哲学是「测量一次,放置一次」。这个约束看似严格,实则让你写出可预测、高性能的布局代码。
1. Layout composable:布局的底层基石
如果你用过 Row 或 Column,其实你已经用过 Layout 了——它们底层都是调用了 Layout 函数。Layout 是一个 Composable 函数,它接收一个 content 和一个 measurePolicy。说白了,你告诉它「我有这些孩子,你帮我量一量、摆一摆」。
@Composable
fun MyCustomLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// measurables: 所有子项的测量句柄
// constraints: 父布局给我们的约束(最大/最小宽高)
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
// 计算总宽高
val width = placeables.maxOf { it.width }
val height = placeables.sumOf { it.height }
layout(width, height) {
var y = 0
placeables.forEach { placeable ->
placeable.placeRelative(x = 0, y = y)
y += placeable.height
}
}
}
}
我在项目中遇到过这样一个场景:需要做一个「瀑布流」布局,每个子项宽度固定但高度不同。用 Layout 实现起来非常直接——先测量所有子项,然后按列放置。嗯,这里要注意:measure 只能调用一次,否则会抛异常。这是 Compose 的硬性约束。
小技巧:如果你需要多次测量子项(比如先测量一次确定尺寸,再根据结果调整),可以用 remeasurement 或者把测量逻辑放到 LaunchedEffect 里。但我个人建议尽量避免多次测量,性能会打折扣。
2. 测量与放置:Constraints 和 Placeable
测量阶段的核心是 Constraints。它包含四个值:minWidth、maxWidth、minHeight、maxHeight。父布局通过 Constraints 告诉你「你最多能占多大,最少不能小于多少」。你想想看,这其实是一种协商机制——父布局给范围,子布局在这个范围内决定自己的实际尺寸。
val constraints = Constraints(
minWidth = 0,
maxWidth = 200.dp.toPx().toInt(),
minHeight = 0,
maxHeight = 400.dp.toPx().toInt()
)
val placeable = measurable.measure(constraints)
// placeable.width 和 placeable.height 就是子项最终决定的尺寸
放置阶段就更简单了。拿到 Placeable 后,调用 placeRelative 或 place 方法,传入 x、y 坐标即可。区别在于 placeRelative 会考虑布局方向(LTR/RTL),而 place 不会。我一般都用 placeRelative,除非你明确知道不需要支持 RTL。
我曾经踩过的坑:在放置时直接用了 place 并硬编码了 x 坐标。结果用户切换到阿拉伯语后,布局完全错乱了。后来全部改成 placeRelative,问题解决。所以,如果你做国际化应用,请一定用 placeRelative。
3. 自定义 Modifier:链式调用的魔法
Modifier 是 Compose 中最优雅的设计之一。每个 Modifier 其实就是一个包装函数,它接收上一个 Modifier 的测量结果,然后叠加自己的逻辑。自定义 Modifier 有两种常见方式:layout 和 drawWithContent。
方式一:用 layout 修改尺寸或位置
fun Modifier.myPadding(start: Dp, top: Dp, end: Dp, bottom: Dp): Modifier = this.then(
layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val width = placeable.width + start.toPx() + end.toPx()
val height = placeable.height + top.toPx() + bottom.toPx()
layout(width.toInt(), height.toInt()) {
placeable.placeRelative(
x = start.toPx().toInt(),
y = top.toPx().toInt()
)
}
}
)
方式二:用 drawWithContent 修改绘制
fun Modifier.myBorder(color: Color, width: Dp): Modifier = this.then(
drawWithContent {
drawContent() // 先绘制原始内容
drawRect(
color = color,
size = size,
style = Stroke(width = width.toPx())
)
}
)
我个人习惯把「修改布局」的逻辑用 layout 实现,把「修改外观」的逻辑用 drawWithContent 实现。这样职责清晰,也方便后续维护。
4. IntrinsicSize:让父布局知道你的「内在尺寸」
IntrinsicSize 是个容易被忽略但极其重要的概念。它解决的是「父布局在测量子项之前,需要知道子项的最小/最大尺寸」的问题。比如你有一个 Column,里面有两个子项,你想让它们平分高度——但 Column 自己都不知道自己有多高,怎么平分?
这时候就需要 IntrinsicSize。Compose 提供了三个方法:minIntrinsicWidth、maxIntrinsicWidth、minIntrinsicHeight、maxIntrinsicHeight。你可以在自定义 Layout 时重写这些方法,告诉父布局你的「内在尺寸」。
@Composable
fun IntrinsicColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 先测量所有子项的最小高度
val placeables = measurables.map { it.measure(constraints) }
// ... 放置逻辑
}
}
// 在 Modifier 中也可以使用 intrinsicSize
Modifier.height(IntrinsicSize.Min) // 告诉父布局:我的最小高度就是我的内在高度
避坑指南:IntrinsicSize 的计算是有成本的。如果你在大量子项上使用 IntrinsicSize,Compose 会先做一次「预测量」来计算内在尺寸,再做一次「正式测量」。所以,只在必要时使用,不要滥用。
5. 自定义绘制:Canvas 与 DrawScope
最后聊聊绘制。自定义绘制通常用 Canvas 或 Modifier.drawBehind / Modifier.drawWithContent。Canvas 是一个 Composable,它直接给你一个 DrawScope,你可以在里面画各种形状、文字、路径。
@Composable
fun CircleProgress(progress: Float) {
Canvas(modifier = Modifier.size(100.dp)) {
// 背景圆环
drawCircle(
color = Color.LightGray,
radius = size.minDimension / 2,
style = Stroke(width = 8.dp.toPx())
)
// 进度弧
drawArc(
color = Color.Blue,
startAngle = -90f,
sweepAngle = progress * 360f,
useCenter = false,
style = Stroke(width = 8.dp.toPx(), cap = StrokeCap.Round)
)
}
}
如果你需要在已有内容上叠加绘制,就用 Modifier.drawWithContent。比如给一个 Image 添加一个半透明遮罩:
Modifier.drawWithContent {
drawContent() // 先画图片
drawRect(
color = Color.Black.copy(alpha = 0.3f),
size = size
)
}
我记得有一次做自定义进度条,需要绘制渐变色的弧线。用 drawArc 配合 Brush.sweepGradient 轻松搞定。Compose 的绘制 API 比传统 Canvas 简洁太多了,你试试就知道。
总结一下
自定义布局的四个维度其实是一条链路:
- Layout composable 是入口,它定义了布局的结构
- 测量与放置 是核心逻辑,决定了子项的位置和大小
- 自定义 Modifier 是复用手段,让你把布局逻辑封装成可链式调用的单元
- IntrinsicSize 是沟通桥梁,让父布局和子布局能更好地协商尺寸
- 自定义绘制 是视觉表达,让你的组件拥有独特的视觉效果
掌握了这些,你就能写出任何你想要的布局了。从简单的等分列,到复杂的仪表盘、自定义滑动条,都不在话下。记住:Compose 给了你全部的控制权,但你要尊重它的「测量一次、放置一次」的规则。遵守规则,你就能获得高性能和可预测性。