17、自定义组件:自定义Composable、Canvas绘图、自定义Layout、自定义Modifier

Jetpack Compose 最吸引我的地方,就是它把「自定义」这件事的门槛降得很低。你想想看,在传统 View 体系里,想画个自定义图形得继承 View、重写 onDraw、处理测量……一套流程下来,代码量不小。但在 Compose 里,自定义组件就像搭积木一样自然。

我个人习惯把自定义分为四个层次:自定义 Composable(组合已有组件)、Canvas 绘图(直接画)、自定义 Layout(控制测量与摆放)、自定义 Modifier(复用行为)。这一章,我们就逐个击破。

核心思路:Compose 的自定义,本质上是「声明式」的。你描述「要什么」,框架帮你搞定「怎么做」。但当你需要精细控制时,Canvas、Layout、Modifier 就是你的武器库。

自定义组件知识体系 自定义组件 自定义Composable 组合已有组件 Canvas绘图 drawScope直接绘制 自定义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.minWidthconstraints.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 内部使用 rememberanimate*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