【Compose】Compose自定义视图
in 博客目录

本文介绍了Jetpack Compose里如何自定义视图
View体系回顾
在View体系中,自定义view的流程已经比较熟悉了。主要有以下几个情景:
- 第一种,继承于现成的View,比如TextView,ImageView,一般都是自己初始化Paint类,在构造器里初始化,在onDraw里画到画布上。
- 第二种,直接继承自View,需要考虑wrapcontent和padding属性的特殊配置,因为分析源码发现其ATMOST和EXACTLY属性没有区分,所以要实现wrapcontent就需要在onMeasure里自行判断。
- 第三种是继承自现成的ViewGroup,像LinearLayout,只需要在布局文件里放置想要的子控件,再到构造方法里初始化,配置即可,一般使用于可大量重用的格式化组件。
- 第四种是直接继承自ViewGroup,需要自行实现onMeasure和onLayout方法,来达到自己想要的组件效果。这种需要特殊注意子控件的处理。
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自定义视图两个主要方法的介绍。