【Compose】Compose自定义视图

【Compose】Compose自定义视图

本文介绍了Jetpack Compose里如何自定义视图

View体系回顾

在View体系中,自定义view的流程已经比较熟悉了。主要有以下几个情景:

  • 第一种,继承于现成的View,比如TextView,ImageView,一般都是自己初始化Paint类,在构造器里初始化,在onDraw里画到画布上。
  • 第二种,直接继承自View,需要考虑wrapcontent和padding属性的特殊配置,因为分析源码发现其ATMOST和EXACTLY属性没有区分,所以要实现wrapcontent就需要在onMeasure里自行判断。
  • 第三种是继承自现成的ViewGroup,像LinearLayout,只需要在布局文件里放置想要的子控件,再到构造方法里初始化,配置即可,一般使用于可大量重用的格式化组件。
  • 第四种是直接继承自ViewGroup,需要自行实现onMeasure和onLayout方法,来达到自己想要的组件效果。这种需要特殊注意子控件的处理。

【Android进阶】Android自定义View

Jetpack Compose自定义视图

Jetpack Compose中对于UI的写法更加简单,也是有两种实现方式,一个是基于现有的Composable函数来组合,二是自己使用Canvas来绘制。

基于现有的Composable函数来组合

Compose的UI是基于函数来实现的,所以我们可以直接使用现有的Composable函数来组合成我们想要的UI。

举例,使用LazyColumn和Text,制作一个上下滑动的时间选择器:

@Composable
fun TimePicker() {
    val hours = (0..23).toList()
    // 扩展列表,前后各添加 2 个空项
    val extendedHours = List(2) { -1 } + hours + List(2) { -1 }
    val lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = extendedHours.size / 2) // 初始默认滚动到中间
    val selectedHour by remember { derivedStateOf { calculateCenterItem(lazyListState, extendedHours) } }

    LaunchedEffect(lazyListState) {
        snapshotFlow { lazyListState.firstVisibleItemScrollOffset }
            .map { calculateCenterItem(lazyListState, extendedHours) }
            .distinctUntilChanged()
            .collect { }
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "Select Hour",
            fontSize = 24.sp,
            fontWeight = FontWeight.Bold,
            modifier = Modifier.padding(bottom = 16.dp)
        )

        Box(
            modifier = Modifier
                .background(Color.LightGray, RoundedCornerShape(8.dp))
                .padding(8.dp)
        ) {
            LazyColumn(
                state = lazyListState,
                modifier = Modifier
                    .height(200.dp)
            ) {
                items(extendedHours.size) { index ->
                    val hour = extendedHours[index]
                    if (hour != -1) { // 只显示有效的小时项
                        HourItem(
                            hour = hour,
                            isSelected = hour == selectedHour,
                            selectedHour = selectedHour,
                            onClick = { }
                        )
                    } else {
                        Spacer(
                            modifier = Modifier
                                .fillMaxWidth()
                                .height(40.dp) // 空项占位高度
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun HourItem(hour: Int, isSelected: Boolean, selectedHour: Int, onClick: () -> Unit) {
    // 计算当前项与选中项的差值
    val difference = kotlin.math.abs(hour - selectedHour)
    // 根据差值动态计算字体大小
    val fontSize = when (difference) {
        0 -> 30.sp // 选中项最大
        1 -> 24.sp // 靠近选中项
        2 -> 20.sp // 次靠近选中项
        else -> 16.sp // 其他项
    }

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .clickable { onClick() }
            .padding(vertical = 8.dp),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = if (hour < 10) "0$hour" else hour.toString(),
            fontSize = fontSize,
            color = if (isSelected) Color.Blue else Color.Black,
            fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
            textAlign = TextAlign.Center
        )
    }
}

// 计算当前位于屏幕中部的项
private fun calculateCenterItem(lazyListState: LazyListState, extendedHours: List<Int>): Int {
    val layoutInfo = lazyListState.layoutInfo
    val visibleItems = layoutInfo.visibleItemsInfo
    if (visibleItems.isEmpty()) return -1

    val centerY = layoutInfo.viewportStartOffset + layoutInfo.viewportSize.height / 2
    val centerItem = visibleItems.find {
        it.offset <= centerY && it.offset + it.size >= centerY
    } ?: visibleItems.first()

    return extendedHours[centerItem.index]
}

运行截图:

基于Canvas来绘制

Compose中使用Canvas来绘制UI,我们需要使用 Canvas(modifier = Modifier) 来调用Canvas,看看 Canvas 在Compose框架里面的实现:

@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
    Spacer(modifier.drawBehind(onDraw))

可以看到需要传入一个DrawScope的参数,并传入了 modifier.drawBehind(onDraw) 中,用于在Composable的背景上绘制自定义图形。在这个作用域内,我们可以使用 drawXXX 方法来绘制UI。

以下是 DrawScope 中一些常用的方法:

drawLine: 绘制一条直线。
drawRect: 绘制一个矩形。
drawCircle: 绘制一个圆形。
drawOval: 绘制一个椭圆形。
drawArc: 绘制一个弧形。
drawPath: 绘制一个自定义路径。
drawImage: 绘制一个图像。
drawText: 绘制文本。
drawIntoCanvas: 在画布上执行自定义绘制操作。
clipRect: 裁剪画布到一个矩形区域。
clipPath: 裁剪画布到一个自定义路径。
rotate: 旋转画布。
scale: 缩放画布。
translate: 平移画布。
save: 保存当前画布状态。
restore: 恢复之前保存的画布状态。

采用上面同样的例子,绘制一个时钟表盘:


@Composable
fun Clock() {
    var time= remember { mutableStateOf(Calendar.getInstance()) }

    LaunchedEffect(Unit) {
        while (true) {
            time.value = Calendar.getInstance()
            delay(1000) // 每秒更新一次
        }
    }

    Canvas(modifier = Modifier.fillMaxSize()) {
        val center = Offset(size.width / 2, size.height / 2)
        val radius = size.minDimension / 2 - 20.dp.toPx()

        // 绘制表盘
        drawCircle(color = Color.LightGray, radius = radius, style = Stroke(4.dp.toPx()))

        // 绘制刻度
        for (i in 0..59) {
            val angle = i * 6f
            val length = if (i % 5 == 0) 20.dp.toPx() else 10.dp.toPx()
            val start = Offset(
                center.x + (radius - length) * cos(Math.toRadians(angle.toDouble())).toFloat(),
                center.y + (radius - length) * sin(Math.toRadians(angle.toDouble())).toFloat()
            )
            val end = Offset(
                center.x + radius * cos(Math.toRadians(angle.toDouble())).toFloat(),
                center.y + radius * sin(Math.toRadians(angle.toDouble())).toFloat()
            )
            drawLine(color = Color.Black, start = start, end = end, strokeWidth = 2.dp.toPx())
        }

        // 绘制时针
        val hour = time.value.get(Calendar.HOUR)
        val minute = time.value.get(Calendar.MINUTE)
        val hourAngle = (hour * 30 + minute * 0.5).toFloat()
        rotate(hourAngle) {
            drawLine(
                color = Color.Black,
                start = center,
                end = Offset(center.x, center.y - radius * 0.5f),
                strokeWidth = 8.dp.toPx()
            )
        }

        // 绘制分针
        val minuteAngle = (minute * 6).toFloat()
        rotate(minuteAngle) {
            drawLine(
                color = Color.Black,
                start = center,
                end = Offset(center.x, center.y - radius * 0.7f),
                strokeWidth = 6.dp.toPx()
            )
        }

        // 绘制秒针
        val second = time.value.get(Calendar.SECOND)
        val secondAngle = (second * 6).toFloat()
        rotate(secondAngle) {
            drawLine(
                color = Color.Red,
                start = center,
                end = Offset(center.x, center.y - radius * 0.9f),
                strokeWidth = 4.dp.toPx()
            )
        }

        // 绘制中心圆
        drawCircle(color = Color.Black, radius = 10.dp.toPx())
    }
}

运行截图:

还有一个类似的方法是 Modifier.drawWithContent ,它也可以在Composable的内容上绘制自定义图形,区别可以看成这个方法是在Composable的内容上绘制,而不是在Composable的背景上绘制。

/**
 * Creates a [DrawModifier] that allows the developer to draw before or after the layout's
 * contents. It also allows the modifier to adjust the layout's canvas.
 */
fun Modifier.drawWithContent(
    onDraw: ContentDrawScope.() -> Unit
): Modifier = this then DrawWithContentElement(onDraw)

里面这个 ContentDrawScope 是继承自 DrawScope 的。

/**
 * Receiver scope for drawing content into a layout, where the content can
 * be drawn between other canvas operations. If [drawContent] is not called,
 * the contents of the layout will not be drawn.
 */
@JvmDefaultWithCompatibility
interface ContentDrawScope : DrawScope {
    /**
     * Causes child drawing operations to run during the `onPaint` lambda.
     */
    fun drawContent()
}

使用举例:

@Composable
fun DrawWithContentExample() {
    Box(
        modifier = Modifier
           .size(200.dp)
           .background(Color.LightGray)
           .drawWithContent {
               drawContent() // 绘制原始内容
               drawCircle(
                   color = Color.Blue,
                   radius = 50f,
                   center = Offset(size.width / 2, size.height / 2)  
               )    
           } 
    )  
}

以上就是对Compose自定义视图两个主要方法的介绍。