Jetpack Compose 支持单独在 Canvas
上绘图或使用 Canvas
构建复杂的 UI。 本文将介绍 Canvas
组件的基本用法。
1. Canvas 简介
Jetpack Compose 中的 Canvas
是一个可组合函数,它是对原生 API android.graphics.Canvas
的包装,提供声明式 Canvas
API,简化了UI绘制,只需调用 Canvas
可组合项即可将画布集成到可组合布局中。 如果要了解更多关于 Compose 中的 Canvas 的信息,建议查看:https://developer.android.com/jetpack/compose/graphics/draw/overview
如下是 Canvas 可组合函数的源码:
/**
* Component that allow you to specify an area on the screen and perform canvas drawing on this
* area. You MUST specify size with modifier, whether with exact sizes via [Modifier.size]
* modifier, or relative to parent, via [Modifier.fillMaxSize], [ColumnScope.weight], etc. If parent
* wraps this child, only exact sizes must be specified.
*/
@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
Spacer(modifier.drawBehind(onDraw))
@ExperimentalFoundationApi
@Composable
fun Canvas(modifier: Modifier, contentDescription: String, onDraw: DrawScope.() -> Unit) =
Spacer(modifier.drawBehind(onDraw).semantics { this.contentDescription = contentDescription })
Canvas 组件允许您在屏幕上指定一个区域并在此区域上执行画布绘制。
必须使用修饰符指定大小,无论是通过 modifier.size 修饰符指定精确大小,还是通过modifier.fillMaxSize、ColumnScope.weight等指定相对于父级的大小。如果父级包装此子级,则必须仅指定精确大小。
Canvas 提供了三个参数:Modifier,contentDescription 和 DrawScope.() → Unit。Modifier 是 Canvas 组件的修饰符,这里不多解释,contentDescription 是提供给 Accessibility 服务的描述字符串,DrawScope() → Unit 是 receiver 为 DrawScope 类型名为 onDraw 的 lambda 表达式,我们在这个 lambda 中使用 DrawScope 提供的字段和函数进行绘制。
2. Hello, Canvas!
2.1 使用 drawRect
绘制两个矩形:
@Composable
fun CanvasDemo() {
Canvas(modifier = Modifier.fillMaxWidth().height(320.dp)) {
drawRect(
color = Color.Blue,
size = Size(64.dp.toPx(), 64.dp.toPx()),
)
drawRect(
color = Color.Red,
topLeft = Offset.Zero.copy(x = 64.dp.toPx()),
size = Size(64.dp.toPx(), 64.dp.toPx()),
)
}
}
2.2 Canvas 是可组合项
Canvas 是 Composable 函数,使用 Canvas 自定义的组件可以供其他可组合组件使用,是可重用的。
例如将 Canvas 组件放到 Box 布局中,并由 Box 控制它在屏幕中的位置:
@Composable
fun CanvasDemo() {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.background(Color.Gray),
) {
Canvas(modifier = Modifier.size(64.dp)) {
drawRect(
color = Color.Blue,
size = size,
)
}
}
}
注意:下文对坐标系、转换、常用绘制操作的介绍基本都来自 2023年07月14日 官方文档:Compose 中的图形,如果网络允许,建议直接阅读最新的官方文档原文。
2.3 Canvas 绘制的坐标系
从上面 2.1 示例中绘制的两个矩形可以看到,默认情况下就行绘制的位置在左上角,如需要像红色矩形那样修改它的绘制位置,可通过 topLeft 参数来指定绘制位置相对原点的偏移量。
topLeft = Offset.Zero.copy(x = 64.dp.toPx()),
// Offset.Zero 是绘制坐标的原点(即左上角)
// 通过copy方法修改它的x坐标右移64.dp,它就绘制在了蓝色矩形的右侧
注意:所有的绘制操作都是通过调整像素大小来执行的。若要确保项目在不同的设备密度和屏幕尺寸上都能采用一致的尺寸,请务必使用
.toPx()
对dp
进行转换,或者采用小数尺寸。
DrawScope
上的许多绘制方法,位置和尺寸由默认参数值提供。默认参数通常会将要绘制的项目放置在画布的 [0, 0]
点上,并提供填充整个绘制区域的默认 size
,
坐标系的原点 ([0,0]
) 位于绘制区域最左上角的像素处。x
会随着向右移动而增加,y
则会随着向下移动而增加。
2.4 基本转换
DrawScope
提供一些用于更改绘制命令执行位置或方式的转换。
注意:这些转换仅适用于可组合项生命周期的绘制阶段,更改尺寸或位置并不会更改布局的尺寸和位置。如果元素超出自己的布局尺寸和位置,可能会在其他元素上绘制。
- 缩放
DrawScope.scale()
可用于按系数来增加绘制操作的大小。scale()
之类的操作适用于DrawScope lambda 中的所有绘图操作。
- 平移
DrawScope.translate()
可用于向上、向下、向左或向右移动绘制操作
- 旋转
DrawScope.rotate()
可用于围绕某个轴心点旋转绘制操作
- 边衬区
DrawScope.inset()
可用于调整当前 DrawScope
的默认参数,相应地更改绘制边界和平移绘制操作
// 添加内边距
Canvas(modifier = Modifier.fillMaxSize()) {
val canvasQuadrantSize = size / 2F
inset(horizontal = 50f, vertical = 30f) {
drawRect(color = Color.Green, size = canvasQuadrantSize)
}
}
- 多个转换
如需对绘制操作应用多个转换,请使用 DrawScope.withTransform()
函数,该函数会创建和应用单个转换来合并您需要的所有更改。与对各个转换进行嵌套调用相比,使用 withTransform()
效率更高,因为这种情况下所有转换都在单个操作中一起执行,Compose 不必计算并保存每个嵌套转换。
例如,以下代码会向矩形同时应用平移和旋转:
Canvas(modifier = Modifier.fillMaxSize()) {
withTransform({
translate(left = size.width / 5F)
rotate(degrees = 45F)
}) {
drawRect(
color = Color.Gray,
topLeft = Offset(x = size.width / 3F, y = size.height / 3F),
size = size / 3F
)
}
}
3. 常用绘制操作
3.1 绘制基本形状
DrawScope
上有许多形状绘制函数。如:drawCircle()
, drawRect()
, drawRoundedRect()
, drawLine()
, drawOval()
, drawArc()
, drawPoints()
@Composable
fun DrawBasicShapes() {
Column(Modifier.fillMaxWidth().height(400.dp)) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
onDraw = {
// 绘制圆形
drawCircle(
color = Color.Blue,
radius = 50.dp.toPx(),
center = Offset(50.dp.toPx(), 50.dp.toPx()),
)
// 绘制矩形
drawRect(
color = Color.Blue,
topLeft = Offset.Zero.copy(x = 116.dp.toPx()),
size = Size(100.dp.toPx(), 100.dp.toPx()),
)
// 绘制带圆角的矩形
drawRoundRect(
color = Color.Blue,
topLeft = Offset.Zero.copy(x = 232.dp.toPx()),
size = Size(100.dp.toPx(), 100.dp.toPx()),
cornerRadius = CornerRadius(32.dp.toPx(), 50.dp.toPx()),
)
// 绘制线条
drawLine(
color = Color.Red,
start = Offset(x = 0F, y = 100.dp.toPx()),
end = Offset(x = 300.dp.toPx(), 200.dp.toPx()),
strokeWidth = 5.dp.toPx(),
cap = StrokeCap.Round,
)
// 绘制椭圆
drawOval(
color = Color.Green,
topLeft = Offset(x = 0F, y = 200.dp.toPx()),
size = Size(width = 150.dp.toPx(), height = 100.dp.toPx()),
)
// 绘制弧形,并连接圆心
drawArc(
color = Color.Cyan,
startAngle = 90F,
sweepAngle = 120F,
useCenter = true,
topLeft = Offset(x = 160.dp.toPx(), y = 200.dp.toPx()),
size = DpSize(100.dp, 100.dp).toSize(),
)
// 绘制弧形,不连接圆心
drawArc(
color = Color.Red,
startAngle = 90F,
sweepAngle = 120F,
useCenter = false,
topLeft = Offset(x = 260.dp.toPx(), y = 200.dp.toPx()),
size = DpSize(100.dp, 100.dp).toSize(),
)
// 绘制点
val point = Offset(0.dp.toPx(), 316.dp.toPx())
val increment = Offset(16.dp.toPx(), 0.dp.toPx())
drawPoints(
points = listOf(
point,
point.plus(increment),
point.plus(increment.times(2F)),
point.plus(increment.times(3F)),
point.plus(increment.times(4F)),
point.plus(increment.times(5F)),
point.plus(increment.times(6F)),
point.plus(increment.times(7F)),
),
pointMode = PointMode.Points,
color = Color.Red,
strokeWidth = 8.dp.toPx(),
cap = StrokeCap.Round,
)
},
)
}
}
3.2 绘制图片
如需使用 DrawScope
绘制 ImageBitmap
,请使用 ImageBitmap.imageResource()
加载图片,然后调用 drawImage
@Composable
fun DrawImage() {
val image = ImageBitmap.imageResource(id = R.drawable.ic_jetpack_compose)
Canvas(modifier = Modifier.fillMaxWidth().height(200.dp)) {
drawImage(image)
}
}
如需详细了解如何对图片应用滤镜,参阅图片自定义文档。
3.3 绘制路径
路径是一系列数学指令,一旦执行便会生成绘图。在 DrawScope
可以使用 DrawScope.drawPath()
方法绘制路径。
例如,假设您想绘制一个三角形。您可以使用 lineTo()
和 moveTo()
等函数根据绘制区域的尺寸生成路径,然后使用新创建的路径调用 drawPath()
来获得一个三角形。
@Composable
fun DrawPath() {
Canvas(modifier = Modifier.fillMaxWidth().height(360.dp)) {
val path = Path()
path.moveTo(0f, 0f)
path.lineTo(size.width / 2f, size.height / 2f)
path.lineTo(size.width, 0f)
path.close()
drawPath(path, Color.Magenta, style = Stroke(width = 10f))
}
}
3.4 绘制文本
如需在 Compose 中绘制文本,您一般可以使用 Text
可组合项。
不过,如果使用 DrawScope
,或想通过自定义设置手动绘制文本,则可以使用 DrawScope.drawText()
方法。
@Composable
fun DrawText() {
val textMeasurer = rememberTextMeasurer()
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.padding(16.dp)
) {
drawText(
textMeasurer = textMeasurer,
text = "Hello https://itmob.cn",
style = TextStyle.Default.copy(fontSize = 32.sp),
)
}
}
Jetpack Compose 1.3.0 之前没有
DrawScope.drawText
API 不能直接在 Jetpack Compose 画布/Canvas 上绘制文本,必须通过 Android 原生Canvas
的 APInativeCanvas.drawText
来绘制文本。
详细信息参见另一篇文章:在 Jetpack Compose 中使用 DrawScope.drawText API 绘制文本
3.5 测量文本
绘制文本的方式与其他绘制命令略有不同。通常情况下,会在绘制命令中指定绘制形状/图片所需的尺寸(宽度和高度)。在绘制文本时,可以通过几个参数来控制所呈现文本的大小,例如字体大小、字体、连字和字母间距。
在 Compose 中,您可以使用 TextMeasurer
来获取根据上述因素测量的文本大小。如果您想在文本后面绘制背景,可以通过测量的信息获知文本所占区域的大小:
@Composable
fun MeasurerText() {
val textMeasurer = rememberTextMeasurer()
Spacer(
modifier = Modifier
.drawWithCache {
val measuredText =
textMeasurer.measure(
AnnotatedString(longTextSample),
constraints = Constraints.fixedWidth((size.width * 2f / 3f).toInt()),
style = TextStyle(fontSize = 18.sp),
)
onDrawBehind {
drawRect(color = Color.Cyan, size = measuredText.size.toSize())
drawText(measuredText)
}
}
.fillMaxSize(),
)
}
注意:上面示例使用了
Modifier.drawWithCache
,因为绘制文本是一项成本较高的操作。在绘制区域的大小发生变化之前,使用drawWithCache
将有助于缓存创建的对象。如需了解详情,请参阅Modifier.drawWithCache
文档。
上例绘制了占整个区域 ⅔ 大小的多行文本,并带有矩形背景
如果调整约束条件、字体大小或任何会影响测量尺寸的属性,系统就会报告新的尺寸。您可以为 width
和 height
设置固定大小,然后文本会遵循设定的 TextOverflow
。
@Composable
fun MeasurerTextWithOverflow() {
val textMeasurer = rememberTextMeasurer()
Spacer(
modifier = Modifier
.drawWithCache {
val measuredText =
textMeasurer.measure(
AnnotatedString(longTextSample),
constraints = Constraints.fixed(
width = (size.width / 3f).toInt(),
height = (size.height / 3f).toInt()
),
overflow = TextOverflow.Ellipsis,
style = TextStyle(fontSize = 18.sp)
)
onDrawBehind {
drawRect(color = Color.Cyan, size = measuredText.size.toSize())
drawText(measuredText)
}
}
.fillMaxSize()
)
}
可组合项的绘制区域固定为 1⁄3 高度和 1⁄3 宽度,并将 TextOverflow
设置为 TextOverflow.Ellipsis
末尾带有省略号:
如需详细了解文本,请参阅文本文档。
访问 Canvas
对象
使用 DrawScope
时,您无法直接访问 Canvas
对象。您可以使用 DrawScope.drawIntoCanvas()
获取 Canvas
对象本身的访问权限,以调用函数。
@Composable
fun CanvasObjectSample() {
val drawable = ShapeDrawable(OvalShape())
val paint = Paint().asFrameworkPaint().apply {
this.color = Color.Blue.toArgb()
this.textSize = with(LocalDensity.current) { 24.sp.toPx() }
}
Spacer(
modifier = Modifier
.padding(16.dp)
.fillMaxSize()
.drawWithContent {
drawIntoCanvas { canvas ->
// 直接访问 Canvas 对象绘制图形
drawable.setBounds(0, 0, 100.dp.roundToPx(), 100.dp.roundToPx())
drawable.draw(canvas.nativeCanvas)
// 直接访问 Canvas 对象绘制文字
canvas.nativeCanvas.drawText("Hello https://itmob.cn", 0F, 130.dp.toPx(), paint)
}
},
)
}
更多内容
如需详细了解如何在 Compose 中绘制,请参阅以下资源:
- 图形修饰符 - 了解不同类型的绘制修饰符。
- Brush - 了解如何自定义内容的绘制方式。
- Compose 中的自定义布局和图形 - 2022 年 Android 开发者峰会:了解如何在 Compose 中使用布局和图形构建自定义界面。
- JetLagged 示例:显示了如何绘制自定义图表的 Compose 示例。