不论是在传统的 View 体系下,还是在 Compose 中,“自定义 View” 都包含视图的测量、布局、绘制、触摸反馈以及动画。
本节我们先讲 Compose 的自定义绘制。Compose 提供了更加上层的 API,相比于原生的绘制 API 更简单、更直接。比如原生的 Canvas 和 Paint 在 Compose 中进行正常绘制时是用不到的,只有在使用原生独有的绘制功能(比如多维旋转)时才需要下沉到原生这一层去使用它们。
1、绘制常用 API
原生的自定义绘制需要重写自定义 View 的 onDraw(),而 Compose 的所有绘制工作都发生在 DrawModifier 中。比如,我要为 Text 设置黄色背景:
@Composable
fun CustomText() {
// drawBehind() 会在组件本身内容的后面的 Canvas 中进行绘制,因此常用于绘制背景
Text("Jetpack Compose", Modifier.drawBehind { drawRect(Color.Yellow) })
}
效果如下:
虽然调用 drawRect() 时只指定了要绘制的颜色,没有指定要绘制的矩形尺寸,但由于 Compose 为其指定了刚好绘制一个可以覆盖组件大小的默认值,为使用者提供了便利:
fun drawRect(
color: Color,
// 绘制的左上角相对于组件左上角的偏移,默认为 0
topLeft: Offset = Offset.Zero,
// 组件尺寸,默认为组件宽高减去左上角偏移
size: Size = this.size.offsetSize(topLeft),
/*@FloatRange(from = 0.0, to = 1.0)*/
alpha: Float = 1.0f,
style: DrawStyle = Fill,
colorFilter: ColorFilter? = null,
blendMode: BlendMode = DefaultBlendMode
)
而在原生的 onDraw() 中,调用 drawRect() 绘制矩形时必须要指定尺寸,并且需要传入一个 Paint 对象指定绘制的颜色以及是否填充等设置。这样一看,Compose 经过优化后的 API 确实要比原生的在易用性上要好一些。
在讲解 DrawModifier 原理时,我们提到过一个 drawWithContent(),在它的内部可以自定义绘制顺序,而且必须调用 drawContent() 绘制原本的内容:
@Composable
fun CustomText1() {
Text("Jetpack Compose", Modifier.drawWithContent {
// 绘制黄色背景
drawRect(Color.Yellow)
// 绘制原本内容
drawContent()
// 绘制红色中线
drawLine(
Color.Red,
Offset(0f, size.height / 2),
Offset(size.width, size.height / 2),
2.dp.toPx()
)
})
}
由于先绘制的会被后绘制的内容覆盖,所以 drawWithContent() 先绘制背景,再绘制原有内容,最后绘制上层的红色中线,效果如下:
假如想从空白开始完全自定义一个组件,而不是像上面的例子那样,基于某些组件进行绘制,可以使用 Box 或 Canvas:
@Composable
fun DrawFromBlank() {
Box(
Modifier
.size(40.dp)
.drawBehind {
// 绘制内容
}
)
Canvas(Modifier.size(40.dp)) {
// 绘制内容
}
}
使用 Canvas() 相比与 Box() 要更简洁一些,而且二者的底层逻辑是几乎一样的。Box 内使用一个 UI 内容为空的 Layout(),测量策略是 EmptyBoxMeasurePolicy:
@Composable
fun Box(modifier: Modifier) {
Layout({}, measurePolicy = EmptyBoxMeasurePolicy, modifier = modifier)
}
而 Canvas() 使用的是测量策略为 SpacerMeasurePolicy 的 Layout():
@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
Spacer(modifier.drawBehind(onDraw))
@Composable
@NonRestartableComposable
fun Spacer(modifier: Modifier) {
Layout({}, measurePolicy = SpacerMeasurePolicy, modifier = modifier)
}
除了具体的测量策略不同之外,其余部分都是一样的,并且使用 Canvas() 的代码会更简洁一些。
注意这个 Canvas() 是 androidx.compose.foundation 包下的 Composable 函数,不是 androidx.compose.ui.graphics 包下的用于创建可以绘制 ImageBitmap 的 Canvas 对象的那个 Canvas 函数,更不是我们熟悉的原生的 android.graphics 包下的 Canvas 类。
通过以上示例代码,能够看出 Compose 的自定义绘制在易用性上要比原生好一些,主要体现在三点:
- 对于一些绘制工作,Compose 通过提供函数默认值的方式可以让你少写一些参数
- 原生的所有绘制都必须要用到 Canvas 对象,而 Compose 绘制在大多数情况下(除了一些比较特殊的,如三维变换)无需使用原生的 Canvas
- 原生的绘制需要通过 Paint 指定绘制风格,如颜色、是否填充等等,而 Compose 是通过函数参数的形式来设置这些参数,不再需要 Paint
2、图像旋转示例
前面提到过 Compose 现有 API 无法进行多维度旋转效果的绘制,现在我们通过一个例子来讲解,Compose 如何绘制 Bitmap,如何使用原生 Canvas 实现多维度旋转的效果。
2.1 Compose 实现图像旋转
首先,我们要先用 Compose 的 API 绘制出一个 Bitmap:
@Composable
fun CustomImage() {
val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
Canvas(Modifier.size(100.dp)) {
// dstSize 将图片尺寸调整到 Canvas 相同的尺寸
drawImage(bitmap, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()))
}
}
这里我们稍作讲解,imageResource() 会根据参数传入的资源加载一个 ImageBitmap。ImageBitmap 是一个接口,它在 Compose 中的实现类是 AndroidImageBitmap,实际上加载的就是通过 Android 原生的 Drawable API 加载资源,拿到原生的 Bitmap 对象再装到 Compose 中。
在示例代码中,imageResource() 的调用没有放在 Canvas() 内,这是因为 imageResource() 需要是一个 Composable 函数,它需要在 @Composable 环境中才能被调用,而 Canvas() 的尾随 lambda 参数并没有提供这个环境:
// onDraw 参数并不是 @Composable 函数
@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
Spacer(modifier.drawBehind(onDraw))
因此,需要将 imageResource() 的调用提到 Canvas() 之外,并且不用 remember() 包住它,因为 imageResource() 内部在返回结果上已经使用了 remember():
@Composable
fun ImageBitmap.Companion.imageResource(@DrawableRes id: Int): ImageBitmap {
val context = LocalContext.current
val value = remember { TypedValue() }
context.resources.getValue(id, value, true)
// 资源路径,只要资源路径不变,无论经历多少次重组,返回结果都是最初返回的结果
val key = value.string!!.toString() // image resource must have resource path.
return remember(key) { imageResource(context.resources, id) }
}
接下来我们再看,使用 Compose API 如何对图片进行单一维度上的旋转:
@Composable
fun CustomImage() {
val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
Canvas(Modifier.size(100.dp)) {
rotate(45f) {
drawImage(bitmap, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()))
}
// 出了 rotate() 的范围,在其余位置绘制时就是没有被旋转的状态
}
}
与原生的写法相比省去了旋转绘制完成后,恢复 Canvas 的操作:
import android.graphics.Color as AndroidGraphicsColor
class CustomView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint()
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
paint.color = AndroidGraphicsColor.YELLOW
// 变换之前先保存 canvas
canvas?.save()
canvas?.rotate(45f)
canvas?.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
// 变换之后需要恢复 canvas
canvas?.restore()
}
}
Compose 的 rotate() 实际上帮你做了恢复 Canvas 的操作,让你专注于内容绘制本身:
inline fun DrawScope.rotate(
degrees: Float,
pivot: Offset = center,
block: DrawScope.() -> Unit
) = withTransform({ rotate(degrees, pivot) }, block)
inline fun DrawScope.withTransform(
transformBlock: DrawTransform.() -> Unit,
drawBlock: DrawScope.() -> Unit
) = with(drawContext) {
// Transformation can include inset calls which change the drawing area
// so cache the previous size before the transformation is done
// and reset it afterwards
val previousSize = size
canvas.save() // 绘制前先保存 canvas
transformBlock(transform) // 变换
drawBlock() // 绘制
canvas.restore() // 绘制完成后恢复 canvas
size = previousSize
}
旋转绘制效果如下:
看似图片是被裁切了,但实际上是因为 Canvas 作为最外层组件只设置了预览框那么大,现在在 Canvas 外部加一个有内边距的 Box 显示所有内容:
@Composable
fun CustomImage() {
Box(Modifier.padding(30.dp)) {
val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
Canvas(
Modifier
.border(1.dp, Color.Red) // 为了看清 Canvas 的边界范围,加了红色边框
.size(100.dp)) {
rotate(45f) {
drawImage(
bitmap,
dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())
)
}
}
}
}
现在能看到,在 Canvas 边界之外的图片其实是有被绘制出来的:
Compose 跟 View 系统的一个区别是不会自动裁切组件的边缘。
需要注意的是,withTransform() 内用到的 canvas 是 DrawContext 接口内定义的 Canvas 接口实例,它是多层组件共用的一个 Canvas 对象。这样做不仅便于管理,而且节省性能,不像 View 中的 Canvas 那样,跨了 View 就不是同一个 Canvas 了。
此外,你也可以使用 Modifier 的 graphicsLayer() 指定在单一方向上的旋转角度:
@Composable
fun CustomImage1() {
Box(
Modifier
.padding(30.dp)
.graphicsLayer { rotationX = 30f }
) {
val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
Canvas(Modifier.size(100.dp)) {
drawImage(
bitmap,
dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())
)
}
}
}
效果如下:
使用 graphicsLayer() 虽然可以同时指定多个方向上的旋转,但是它的效果并不是沿着指定的轴旋转指定的度数:
@Composable
fun CustomImage1() {
Box(
Modifier
.padding(30.dp)
.graphicsLayer {
rotationX = 70f
rotationY = 70f
}
) {
val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
Canvas(Modifier.size(100.dp)) {
// drawImage 用于绘制位图
drawImage(
bitmap,
dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())
)
}
}
}
它的效果明显不是分别沿着 X 轴与 Y 轴各自旋转了 70°:
之所以会怎样,是因为 graphicsLayer() 底层使用的是 RenderNode 或者 View 的 API,具体来说:
- Android 5.0(API 21)系统之前,使用的是 View 的 API
- 从 Android 5.0(API 21)开始,使用 RenderNode 的 API,这个 API 在 API 29 开放给开发者使用
这两种 API 在支持多维度旋转时,并不是旋转我们要绘制的图像,而是去旋转画布的 Canvas,并且不是分别在各自的轴上旋转指定的角度,而是先在一个维度上旋转,然后在第一次旋转结果的基础上,再在第二个维度上旋转,因此得到的结果看起来会有些奇怪。
2.2 原生 Canvas 实现图像多维度旋转
既然完全使用 Compose 无法完成多维度的图像旋转,我们可以使用原生的 Canvas 来实现。
Compose 内原生 Canvas 的使用方法
DrawScope 的 drawIntoCanvas() 在它的 block 参数上提供了一个 Compose 的 Canvas 对象:
// block 函数的参数是 Compose 的 Canvas 接口
inline fun DrawScope.drawIntoCanvas(block: (Canvas) -> Unit) = block(drawContext.canvas)
Canvas 接口内定义了众多对 Canvas 本身进行的操作,以及借助 Canvas 进行绘制的函数:
@JvmDefaultWithCompatibility
interface Canvas {
// 对 Canvas 本身进行操作的函数
fun save()
fun restore()
fun saveLayer(bounds: Rect, paint: Paint)
fun translate(dx: Float, dy: Float)
fun scale(sx: Float, sy: Float = sx)
fun rotate(degrees: Float)
fun concat(matrix: Matrix)
fun clipPath(path: Path, clipOp: ClipOp = ClipOp.Intersect)
...
// 利用 Canvas 进行绘制的函数
fun drawLine(p1: Offset, p2: Offset, paint: Paint)
fun drawRect(
left: Float,
top: Float,
right: Float,
bottom: Float,
paint: Paint
)
fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint)
fun drawCircle(center: Offset, radius: Float, paint: Paint)
...
}
该接口有两个实现类 —— EmptyCanvas 与 AndroidCanvas。前者是对 Canvas 接口的空实现,它只是为了保证 DrawScope 内部的 Canvas 对象不为空(先给一个空实现作为默认值,等真正要用的时候再赋值一个有具体实现的 Canvas),所有接口方法内都抛出 UnsupportedOperationException:
internal class EmptyCanvas : Canvas {
override fun save() {
throw UnsupportedOperationException()
}
...
}
而 AndroidCanvas 内部持有原生的 Canvas 对象真正地实现了接口内定义的各种操作:
private val EmptyCanvas = android.graphics.Canvas()
@PublishedApi internal class AndroidCanvas() : Canvas {
// 原生的 Canvas
@PublishedApi internal var internalCanvas: NativeCanvas = EmptyCanvas
// 利用原生 Canvas 对应的 API 实现 Compose 的 Canvas 接口
override fun save() {
internalCanvas.save()
}
override fun restore() {
internalCanvas.restore()
}
override fun translate(dx: Float, dy: Float) {
internalCanvas.translate(dx, dy)
}
override fun scale(sx: Float, sy: Float) {
internalCanvas.scale(sx, sy)
}
...
}
Compose 提供了一个 Canvas 的扩展属性 nativeCanvas 帮助开发者可以拿到 Android 原生的 Canvas 对象,也就是 AndroidCanvas 的 internalCanvas 属性:
actual val Canvas.nativeCanvas: NativeCanvas
get() = (this as AndroidCanvas).internalCanvas
接下来看源码中如何使用 Canvas 接口,它被定义为 DrawContext 接口内的属性:
interface DrawContext {
var size: Size
val canvas: Canvas
val transform: DrawTransform
}
DrawContext 唯一的实现类是 CanvasDrawScope 内的匿名实现类,DrawParams 内的 canvas 属性是一个 EmptyCanvas:
class CanvasDrawScope : DrawScope {
@PublishedApi internal val drawParams = DrawParams()
override val drawContext = object : DrawContext {
override val canvas: Canvas
get() = drawParams.canvas
override var size: Size
get() = drawParams.size
set(value) {
drawParams.size = value
}
override val transform: DrawTransform = asDrawTransform()
}
@PublishedApi internal data class DrawParams(
var density: Density = DefaultDensity,
var layoutDirection: LayoutDirection = LayoutDirection.Ltr,
// 默认值是 EmptyCanvas,但它是 var 的,在真正用到它之前会重新赋值的
var canvas: Canvas = EmptyCanvas(),
var size: Size = Size.Zero
)
}
那么我们要使用的 drawIntoCanvas(block) 会将 drawContext.canvas 作为 block 的参数并执行 block,看似 block 的参数好像是一个 EmptyCanvas,在调用 Canvas 的相关函数时会抛出 UnsupportedOperationException。但实际上,在执行绘制操作之前,Compose 会为 DrawParams 的 canvas 属性重新赋值为 AndroidCanvas,因此在 drawIntoCanvas() 中可以放心地使用 Canvas 接口内定义的函数。
DrawParams 的 canvas 属性重新赋值为 AndroidCanvas 的源码过程有些绕,因为我们不是要讲解原理,而是要讲如何使用 drawIntoCanvas() 借助原生 Canvas 进行绘制。这过程中讲到一小部分源码是为了帮助你更好的理解 Compose 的 Canvas 接口与原生 Canvas 的关系以便更好的使用它来编写程序而已。
当然,为了验证 drawIntoCanvas() 的尾随 lambda 的参数确实是 AndroidCanvas,我们在该 lambda 的内部打印了参数 it 的值,结果证明它确实是一个 AndroidCanvas:
com.jetpack.compose I it is:androidx.compose.ui.graphics.AndroidCanvas@a2942d8
多维度旋转实现
了解了 Canvas 的相关内容,下面就可以开始进行旋转了。
首先,我们要在 Canvas() 内调用 drawIntoCanvas() 以获取原生的 Canvas 对象进行绘制:
@Composable
fun CustomImage2() {
// Compose 的 Paint,内部包含着原生的 Paint
val paint by remember { mutableStateOf(Paint()) }
Box(Modifier.padding(30.dp)) {
val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
Canvas(Modifier.size(100.dp)) {
// 提供原生 Canvas 对象
drawIntoCanvas { // it: Compose 的 Canvas,内部会调用原生 Canvas 进行绘制
it.drawImageRect(
bitmap,
dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()),
paint = paint
)
}
}
}
}
然后,还是先对一个维度进行旋转,假设我们让图片绕 X 轴旋转 45°:
@Composable
fun CustomImage2() {
val paint by remember { mutableStateOf(Paint()) }
val camera by remember { mutableStateOf(Camera()) }.apply {
// 对 Camera 在 X 轴旋转 45°
value.rotateX(45f)
}
Box(Modifier.padding(30.dp)) {
val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
Canvas(Modifier.size(100.dp)) {
drawIntoCanvas { // it: Canvas
// 平移 Canvas,实际是通过 Compose 的 Canvas 操纵原生 Canvas 实现的平移
it.translate(size.width / 2, size.height / 2)
// 应用 Camera 的变换到原生 Canvas 上
camera.applyToCanvas(it.nativeCanvas)
// 还原 Canvas
it.translate(-size.width / 2, -size.height / 2)
it.drawImageRect(
bitmap,
dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()),
paint = paint
)
}
}
}
}
对以上代码做出一些解释:
- Camera 是 android.graphics 包下的,而不是与硬件的相机相关的 android.hardware 包下的 Camera
- 由于 Camera 旋转的轴心默认为 (0, 0) 且不可更改,因此只能通过平移 Canvas 的方式实现对 Camera 旋转轴心的修改。并且在将 Camera 的变换应用到 Canvas 时,由于 applyToCanvas() 的参数是原生的 Canvas,因此需要传入 Compose 的 Canvas 接口的扩展属性 nativeCanvas(该属性源码已在上方贴出)
代码效果如下:
然后让 Canvas 在当前变换基础上旋转 45° 实现绕两个轴旋转的效果:
@Composable
fun CustomImage2() {
val paint by remember { mutableStateOf(Paint()) }
val camera by remember { mutableStateOf(Camera()) }.apply {
value.rotateX(45f)
}
Box(Modifier.padding(30.dp)) {
val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
Canvas(Modifier.size(100.dp)) {
drawIntoCanvas { // it: Canvas
it.translate(size.width / 2, size.height / 2)
it.rotate(-45f)
camera.applyToCanvas(it.nativeCanvas)
it.rotate(45f)
it.translate(-size.width / 2, -size.height / 2)
it.drawImageRect(
bitmap,
dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()),
paint = paint
)
}
}
}
}
效果如下:
最后添加动画效果:
@Composable
fun CustomImage2() {
val paint by remember { mutableStateOf(Paint()) }
val rotationAnimatable = remember { Animatable(0f) }
val camera by remember { mutableStateOf(Camera()) }
// 开启协程,执行动画
LaunchedEffect(Unit) {
rotationAnimatable.animateTo(360f, infiniteRepeatable(tween(2000)))
}
Box(Modifier.padding(30.dp)) {
val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
Canvas(Modifier.size(100.dp)) {
drawIntoCanvas { // it: Canvas
it.translate(size.width / 2, size.height / 2)
it.rotate(-45f)
// Camera 的旋转与还原
camera.save()
camera.rotateX(rotationAnimatable.value)
camera.applyToCanvas(it.nativeCanvas)
camera.restore()
it.rotate(45f)
it.translate(-size.width / 2, -size.height / 2)
it.drawImageRect(
bitmap,
dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()),
paint = paint
)
}
}
}
}
这里有一点需要说明的是,将绕 X 轴旋转的逻辑从 camera 的 apply() 中拿到了 drawIntoCanvas() 内,因为 Camera 的 rotateX() 每次调用旋转的度数都是基于上一次的位置,而不是 0。可动画的 value 是基于初始值的,不是基于上一次返回的值的增量,所以把 rotateX() 拿到 drawIntoCanvas() 后,可以通过 save() 与 restore() 操作保证 Camera 每次旋转都是相对于初始位置(0° 的位置),而不是上一次动画执行时的位置。
效果如下:
本例主要还是介绍当 Compose 的绘制 API 做不到一些功能时,可以通过 Compose 的 Canvas 获取原生 Canvas 来实现。除了三维旋转,文字的测量也可能会用到原生的 Canvas。