17、自定义组件:自定义Composable、Canvas绘图、自定义Layout、自定义Modifier
Jetpack Compose 最吸引我的地方,就是它把「自定义」这件事的门槛降得很低。你想想看,在传统 View 体系里,想画个自定义图形得继承 View、重写 onDraw、处理测量……一套流程下来,代码量不小。但在 Compose 里,自定义组件就像搭积木一样自然。
我个人习惯把自定义分为四个层次:自定义 Composable(组合已有组件)、Canvas 绘图(直接画)、自定义 Layout(控制测量与摆放)、自定义 Modifier(复用行为)。这一章,我们就逐个击破。
核心思路:Compose 的自定义,本质上是「声明式」的。你描述「要什么」,框架帮你搞定「怎么做」。但当你需要精细控制时,Canvas、Layout、Modifier 就是你的武器库。
17.1 自定义 Composable:组合的艺术
其实大部分自定义组件,都不需要你从零开始画。Compose 最强大的地方在于「组合」——把已有的组件像乐高一样拼起来,封装成一个新的组件。
@Composable
fun UserAvatar(
name: String,
avatarUrl: String?,
size: Dp = 48.dp,
modifier: Modifier = Modifier
) {
val imageSize = size
Row(
modifier = modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (avatarUrl != null) {
AsyncImage(
model = avatarUrl,
contentDescription = "用户头像",
modifier = Modifier
.size(imageSize)
.clip(CircleShape)
)
} else {
// 没有头像时显示首字母
Box(
modifier = Modifier
.size(imageSize)
.clip(CircleShape)
.background(Color.Gray),
contentAlignment = Alignment.Center
) {
Text(
text = name.take(1),
color = Color.White,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(text = name, style = MaterialTheme.typography.bodyLarge)
}
}
这段代码我经常在项目里用。你看,它没有画任何东西,只是把 Image、Box、Text 组合在一起,就形成了一个可复用的用户信息组件。这就是自定义 Composable 的精髓——封装复杂度,暴露简洁接口。
我的习惯:自定义 Composable 时,永远把 modifier 参数放在第一个(或靠前的位置),并且透传给最外层的容器。这样调用方可以自由控制边距、点击事件等,灵活性高很多。
17.2 Canvas 绘图:想画什么就画什么
当组合无法满足需求时,就该 Canvas 上场了。比如你要画一个自定义的进度环、波形图、或者某个特殊形状的图标。
Canvas 的用法很直接:你得到一个 DrawScope,然后调用它的各种 draw 方法。
@Composable
fun CircularProgress(
progress: Float, // 0f ~ 1f
modifier: Modifier = Modifier,
strokeWidth: Dp = 8.dp,
color: Color = Color.Blue
) {
Canvas(modifier = modifier.size(100.dp)) {
val strokePx = strokeWidth.toPx()
val radius = (size.minDimension - strokePx) / 2
val topLeft = Offset(
(size.width - radius * 2) / 2,
(size.height - radius * 2) / 2
)
val arcSize = Size(radius * 2, radius * 2)
// 背景圆环
drawArc(
color = Color.LightGray,
startAngle = -90f,
sweepAngle = 360f,
useCenter = false,
topLeft = topLeft,
size = arcSize,
style = Stroke(width = strokePx)
)
// 进度弧
drawArc(
color = color,
startAngle = -90f,
sweepAngle = progress * 360f,
useCenter = false,
topLeft = topLeft,
size = arcSize,
style = Stroke(width = strokePx, cap = StrokeCap.Round)
)
}
}
这里有个细节要注意:startAngle 默认从右侧(3点钟方向)开始,所以我习惯设成 -90°,让进度从顶部开始。嗯,这个小坑我踩过,当时画出来的环总是歪的,排查了半天才发现是角度起点的问题。
我曾经踩过的坑:Canvas 里的坐标单位是 px,不是 dp。如果你直接写 size(100),它会认为 100px,在不同密度屏幕上大小会不一致。记得用 modifier.size(100.dp) 来保证物理尺寸统一。
17.3 自定义 Layout:掌控测量与摆放
Compose 的布局流程分为两步:测量和摆放。默认的 Column、Row 已经能满足大部分场景,但当你需要实现「瀑布流」、「环绕布局」、「重叠布局」等特殊效果时,就得自己写 Layout 了。
自定义 Layout 的核心是 Layout 这个 Composable 函数,它接收一个 measurePolicy 参数。
@Composable
fun FlowLayout(
modifier: Modifier = Modifier,
horizontalSpacing: Dp = 8.dp,
verticalSpacing: Dp = 8.dp,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 1. 测量所有子组件
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
// 2. 计算布局尺寸和位置
var currentX = 0
var currentY = 0
var rowHeight = 0
val positions = mutableListOf<Pair<Int, Int>>()
val horizontalSpacingPx = horizontalSpacing.roundToPx()
val verticalSpacingPx = verticalSpacing.roundToPx()
placeables.forEach { placeable ->
if (currentX + placeable.width > constraints.maxWidth) {
// 换行
currentX = 0
currentY += rowHeight + verticalSpacingPx
rowHeight = 0
}
positions.add(Pair(currentX, currentY))
currentX += placeable.width + horizontalSpacingPx
rowHeight = maxOf(rowHeight, placeable.height)
}
val totalWidth = constraints.maxWidth
val totalHeight = currentY + rowHeight
// 3. 报告最终尺寸
layout(totalWidth, totalHeight) {
// 4. 摆放子组件
placeables.forEachIndexed { index, placeable ->
val (x, y) = positions[index]
placeable.placeRelative(x, y)
}
}
}
}
这段代码实现了一个简单的流式布局(类似 Flexbox 的 wrap)。你看,流程很清晰:先测量每个子组件能占多大空间,然后计算它们应该放在哪里,最后调用 layout() 报告尺寸并摆放。
我个人觉得,自定义 Layout 最难的地方不是 API,而是「测量逻辑」——你要想清楚子组件之间的约束关系。比如上面的流式布局,如果某个子组件特别宽,换行逻辑就会变得复杂。我建议先从简单的「等分布局」或「居中布局」练手。
关键点:自定义 Layout 中,constraints 是父组件给你的「可用空间范围」。你必须在 constraints.minWidth 到 constraints.maxWidth 之间选择最终尺寸。超出范围会导致布局异常。
17.4 自定义 Modifier:复用行为
Modifier 是 Compose 中最优雅的设计之一。它把「行为」和「组件」解耦了。比如你想给任意组件添加「点击时缩放」的效果,写一个自定义 Modifier 就能到处复用。
fun Modifier.bounceClick(
scaleDown: Float = 0.9f,
animationDuration: Int = 100
): Modifier = composed(
inspectorInfo = {
name = "bounceClick"
properties["scaleDown"] = scaleDown
properties["animationDuration"] = animationDuration
}
) {
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) scaleDown else 1f,
animationSpec = tween(durationMillis = animationDuration)
)
this
.graphicsLayer(scaleX = scale, scaleY = scale)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
}
)
}
}
使用起来很简单:
Image(
painter = painterResource(id = R.drawable.icon),
contentDescription = null,
modifier = Modifier
.size(48.dp)
.bounceClick() // 自定义 Modifier
)
这里用到了 composed 函数,它允许你在 Modifier 内部使用 remember 和 animate*AsState 等 Compose 特性。说白了,composed 让 Modifier 拥有了「状态」和「生命周期」。
我的建议:自定义 Modifier 时,尽量保持「单一职责」。一个 Modifier 只做一件事,然后通过链式调用组合。比如「点击缩放」和「长按震动」应该拆成两个 Modifier,而不是揉在一起。
17.5 避坑指南与经验总结
做自定义组件这几年,我总结了几条经验,分享给你:
- 能用组合就别用 Canvas——组合出来的组件天然支持语义化、无障碍、主题切换。Canvas 画出来的东西,这些都得自己实现。
- 自定义 Layout 一定要处理「无约束」情况——有时候父组件会给
constraints.maxWidth = Infinity,你的布局逻辑要能兜住,否则会崩溃。 - Modifier 的顺序很重要——
.size().clip().background()和.clip().size().background()效果可能完全不同。我习惯把size放在最前面,然后是clip,最后是绘制相关的。 - 善用
IntrinsicSize——当你的自定义 Layout 需要根据子组件内容自适应大小时,记得实现IntrinsicMeasurable接口,否则父组件无法正确测量你。
嗯,其实自定义组件没有想象中那么神秘。你只要记住:Compose 的一切都是函数,自定义就是「写一个返回 Unit 的函数,里面调用其他函数」。Canvas 是「画布上的函数调用」,Layout 是「测量和摆放的函数调用」,Modifier 是「链式调用的函数组合」。想通了这一点,你就掌握了 Compose 自定义的精髓。
最后说一句:不要为了自定义而自定义。先看看官方组件能不能满足需求,不行再自己动手。我在项目里见过有人花两天写了一个自定义下拉刷新,结果官方后来出了 pullRefresh 修饰符……所以,先查文档,再动手。
公众号:蓝海资料掘金营,微信 deep3321