Android 中的范围裁切和几何变换

真的很抽象,很难理解。

范围裁切

Canvas类中有很多用于范围裁切的函数,首先来介绍一下clipRect(),这是一个用于裁剪矩形区域的函数,只有在指定的矩形区域内绘制的内容才会被显示出来。该函数接收 4 个参数,代表所裁剪矩形的位置(左上右下),这和定义一个矩形是一致的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val imageSize = 150f.px // 图片大小

private val bitmap = betterGetBitmap(imageSize.toInt(), R.drawable.dedsec_logo) // 获取图片

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.clipRect(
imageMargin,
imageMargin,
imageMargin + imageSize / 2f,
imageMargin + imageSize / 2f
)
canvas.drawBitmap(bitmap, imageMargin, imageMargin, paint)
}

/**
* 根据需求加载图片,以节省资源提高性能
* @param requireWidth 需要返回的图片的宽度
* @param picId 需要读取的图片
*/
private fun betterGetBitmap(requireWidth: Int, picId: Int): Bitmap {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true // 只获取图片的宽度和高度信息,而不加载整个图片
BitmapFactory.decodeResource(resources, picId, this) // 读取宽度和高度
inJustDecodeBounds = false // 置为 false ,以便后续加载整个图片
inDensity = outWidth // 设置读取到的图片的密度
inTargetDensity = requireWidth // 设置目标密度
}
return BitmapFactory.decodeResource(resources, picId, options) // 按照目标重新读取图片并返回
}

/**
* 为 Float 类型的数据定义一个扩展属性,将这个值的 dp 单位转换为 px 并返回
*/
val Float.px
get() = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, this, Resources.getSystem().displayMetrics
)

程序运行,将会看到图片被裁成原本大小的四分之一。

接下来是clipPath()clipPath()允许创建更复杂的裁剪区域,这与它所接收的参数是一个 Path 对象有关,和clipRect()一样,只有在该区域内绘制的内容才会被显示出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 省略重复代码

private val path = Path().apply {
addOval(
imageMargin,
imageMargin,
imageMargin + imageSize,
imageMargin + imageSize,
Path.Direction.CW
)
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.clipPath(path)
canvas.drawBitmap(bitmap, imageMargin, imageMargin, paint)
}

// 省略重复代码

在上面的代码中,定义了一个 Path 对象,并向其中添加了一个椭圆,随后为这个椭圆设置了位置参数,使其成为矩形图片的内切圆,最后调用clipPath()并将这个 Path 对象传入以实现裁切效果,最终的效果就好像各大社交 app 中的头像一样,把一个矩形的图片限制在一个圆形的头像框内。

值得一提的是,在先前的笔记中,这种将矩形裁成其内切圆的效果,也可以通过 Xfermode 的混合操作来实现,它和clipPath()的差距就在于裁切后的图片是否含有锯齿。对于clipPath()来说,是有锯齿的,这是因为clipPath()会按照 Path 对象提供的路径进行严格裁切,所裁切出来的区域存在的锯齿是无法通过 Paint 对象的ANTI_ALIAS_FLAG来进行修正的。反观 Xfermode 则可以在使用 Paint 对象进行绘制时向其设置抗锯齿属性,让最终效果看起来更平滑。

此外还有clipOutRect()clipOutPath(),这两个函数的效果与clipRect()clipPath()是相反的,也就是在被裁切的区域之外绘制的内容才会被显示出来,这里就不记录用例了。

几何变换

View 和 Canvas 有各自的坐标系,几何变换改变的是 Canvas 的坐标系,二维变换通常用到这四个方法:

  • translate(float dx, float dy): 用于平移坐标系,它将坐标系沿着 x 轴和 y 轴分别平移 dx 和 dy 个像素,之后所有的绘制操作都会在新的坐标系上进行。
  • rotate(float degrees): 用于旋转坐标系,它将坐标系以原点为中心旋转 degrees 度,之后所有的绘制操作都会在新的坐标系上进行。
    • rotate(float degrees, float px, float py):三参数版本,坐标系将以横坐标为 px 纵坐标为 py 的点为中心旋转 degrees 度。
  • scale(float sx, float sy): 用于缩放坐标系,它将坐标系沿着 x 轴和 y 轴分别缩放 sx 和 sy 倍,之后所有的绘制操作都会在新的坐标系上进行。
  • skew(float sx, float sy): 用于扭曲坐标系,它将坐标系沿着 x 轴和 y 轴分别扭曲 sx 和 sy 度,之后所有的绘制操作都会在新的坐标系上进行。

这些方法的作用对象都是 Canvas 坐标系,而不是图像或者图形本身,所以在进行多次变换的情况下,不同的调用顺序将会出现不一样的结果,就比如先平移后旋转和先旋转后平移是完全不一样的,所以调用这些方法的顺序就显得尤为讲究。

这里有一个技巧,对于如何在变换后仍然能绘制出理想结果也许有帮助:如果能够清楚地知道每次调用变换方法后,新的 Canvas 坐标系的位置,那么就可以按照正常的思路来进行变换;反之,如果关注点在于图像,不考虑坐标系的变换,或者说假设坐标系一直位于起始位置没动过,那么就可以将原来的写法倒过来写,就能得到理想结果。

Matrix

上述提到的translate(float dx, float dy)等方法都是 Canvas 中用于进行二维变换的方法,而Matrix类也有一系列方法可以提供二维变换:

  • setTranslate(float dx, float dy):清除 Matrix 中的所有其它变换,然后设置新的平移变换。两个参数分别表示 x 和 y 方向上的位移。
    • preTranslate(float dx, float dy):将新的平移变换与 Matrix 中的现有变换进行前乘,即新的平移变换会首先被应用。该方法在实现复合变换时会用到,两个参数分别表示 x 和 y 方向上的位移。
    • postTranslate(float dx, float dy):与上面的相反,即后乘,该变换会靠后被应用。
  • setRotate():设置旋转变换。
    • preRotate():同上。
    • postRotate():同上。
  • setScale():设置缩放变换。
    • preScale():同上。
    • postScale():同上。
  • setSkew():设置斜切变换。
    • preSkew():同上。
    • postSkew():同上。

Matrix 的变换更适合被复用,因为它的作用对象并不一定是 Canvas 坐标系,简单来说就是谁应用了 Matrix ,谁就会变换。例如将其应用给 Bitmap :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private val imageSize = 150f.px  // 图片大小
private val bitmap = betterGetBitmap(imageSize.toInt(), R.drawable.dedsec_logo) // 获取图片

// 创建一个 Matrix 并进行变换
private val matrix = Matrix().apply {
postRotate(45F) // 进行旋转变换
postScale(1.5F, 1.5F) // 进行缩放变换
}

// 通过创建一个新的 Bitmap 来把 Matrix 应用到其之上
private val transformedBitmap =
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(transformedBitmap, 0F, 0F, null) // 绘制 Bitmap
}

// 省略重复代码

或者也可以将其应用给 Canvas ,如果是这样的话,所有的变换又将会影响到坐标系了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private val imageSize = 150f.px  // 图片大小
private val bitmap = betterGetBitmap(imageSize.toInt(), R.drawable.dedsec_logo) // 获取图片

// 创建一个 Matrix 并进行变换
private val matrix = Matrix().apply {
postRotate(45F) // 进行旋转变换
postScale(1.5F, 1.5F) // 进行缩放变换
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.setMatrix(matrix) // 先应用 Matrix 再绘制 Bitmap
// canvas.concat(matrix)
canvas.drawBitmap(bitmap, 0F, 0F, null) // 绘制 Bitmap
}

// 省略重复代码

要将 Matrix 应用给 Canvas 一般有两个方法可以调用,分别是setMatrix()concat(),前者是将 Canvas 的当前变换矩阵替换为指定的 Matrix ,后者则是将指定的 Matrix 与 Canvas 的当前变换矩阵进行合并。由于上面的代码中,Canvas 本身也没有发生变换,所以调用两个方法得到的效果都是一致的。

三维变换

三维变换通常会使用到Camera类,Camera类是 Android 提供的一个用于实现三维变换效果的类,它可以实现从相机视角的角度来对 Canvas 进行平移、旋转、缩放等操作。进一步说,Camera 的变换操作将默认以坐标系原点为中心进行,并且其所产生的变换不是针对具体某一个物体,而是会影响整个场景中的所有物体。

三维变换涉及到三维(Camera)坐标系,其具有以下特点:

  • 增加了一个垂直于屏幕的 z 轴,指向屏幕内的为正方向。
  • 原点(三轴交汇处)默认在 View 左上角。
  • 存在一个 Camera 模型,可以将其想象成用户的眼睛,其位于屏幕外的 z 轴上,对 Camera 坐标系上的图像进行投影,投射到 Canvas 坐标系上的内容将是最终的绘制结果。
  • y 轴的正方向由往下为正变为了往上为正。

接下来通过一个旋转 x 轴从而达到翻起图形的效果的例子来说明一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private val imageSize = 150F.px  // 图片大小
private val imageMargin = 50F.px // 图片与屏幕左侧和顶部的距离
private val bitmap = betterGetBitmap(imageSize.toInt(), R.drawable.dedsec_logo) // 获取图片

// 初始化一个 Camera 并沿着 x 轴进行旋转
private val camera = Camera().apply {
rotateX(30F)
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

camera.applyToCanvas(canvas) // 将 Camera 对象与 Canvas 联系起来
canvas.drawBitmap(bitmap, imageMargin, imageMargin, null) // 绘制 Bitmap
}

// 省略重复代码

在上面的代码中,尝试通过调用 Camera 对象的rotateX()来让 Camare 坐标系沿 x 轴进行旋转。程序运行后,可以看到在 Canvas 上绘制的矩形确实实现了翻起的效果,但是整个图像整体又过于偏右了,因为理想中的结果是,这个矩形在被翻转后,将会变成一个等腰梯形。

为什么结果应该是等腰梯形?这主要是前面提到的 Camera 会对其坐标系上的图像进行投影,而在现实生活中,人眼在观察物体的时候是没有投影的,就算把一个本子或者是一张纸放在我们面前进行 x 轴翻转,最终的结果也仍然是矩形。

但为什么实际运行的结果又不是等腰梯形?这是因为理想中的变换操作是建立在 Camera 坐标系的原点位于图形或图像中心进行的,但实际上前面也提到,原点默认位于屏幕左上角,又因为 Camera 是位于原点的 z 轴上的,所以当往右投影时,投射出来的结果会往右倾斜就好理解了。

正确的做法应该是让 Camera 坐标系的原点处在 Bitmap 的中心,这样投射出来的结果就是正确的了。而为了达到这个目的,方法之一是:

  1. 移动 Canvas 坐标系,让 z 轴刚好贯穿 Bitmap 的中心。
  2. 调用applyToCanvas()进行投射。
  3. 投射完成后再把 Canvas 坐标系移回原处。

根据上面的思路修改前面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 省略重复代码

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

// 将 Canvas 坐标系向左上角移动,让图形中心与原点重合
canvas.translate(-(imageMargin + imageSize / 2), -(imageMargin + imageSize / 2))

// 进行投射
camera.applyToCanvas(canvas)

// 将 Canvas 坐标系移动回原来的位置
canvas.translate(imageMargin + imageSize / 2, imageMargin + imageSize / 2)

// 绘制 Bitmap
canvas.drawBitmap(bitmap, imageMargin, imageMargin, null)
}

// 省略重复代码

即便思路很清晰,程序运行却仍然没有得到理想的结果。这是因为三维变换一般都会用到前面提到的技巧,也就是在正常思路下的代码写完以后,按照正常思路,把代码再「倒着写」一遍,这样一来,上面的代码就会变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 省略重复代码

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

// 4. 将 Canvas 坐标系移动回原来的位置
canvas.translate(imageMargin + imageSize / 2, imageMargin + imageSize / 2)

// 3. 进行投射
camera.applyToCanvas(canvas)

// 2. 将 Canvas 坐标系向左上角移动,让图形中心与原点重合
canvas.translate(-(imageMargin + imageSize / 2), -(imageMargin + imageSize / 2))

// 1. 先绘制 Bitmap
canvas.drawBitmap(bitmap, imageMargin, imageMargin, null)
}

// 省略重复代码

再次运行就可以得到想要的结果了。

移动 Camera

Camera类中,有一个方法叫setLocation(float x, float y, float z),该方法用于设定 Camera 的位置,默认位置为(0, 0, -8)。其中第三个参数,也就是垂直于屏幕的距离的远近,对绘制结果的影响最大,它的变化会影响投影时图像或图形的大小,所以这个方法经常用来对拥有不同屏幕像素密度的设备进行适配。至于另外两个参数,一般用处不大。

这个物理模型其实也好理解,离屏幕近,看到的东西就大,反之就小,默认值为-8就代表 Camera 默认就是在屏幕外的,已经有一个高度了,所以就算不手动调用setLocation(),也仍然可以实现一些 3D 效果,就比如上面的投影。但是一个写死的值是不能成为最佳方案的,为了适配上面提到的不同屏幕像素密度的设备,就需要进行以下修改:

1
2
3
4
5
6
7
8
// 省略重复代码

private val camera = Camera().apply {
rotateX(30F)
setLocation(0F, 0F, -8F * resources.displayMetrics.density)
}

// 省略重复代码

由于resources.displayMetrics.density是屏幕像素密度,会根据不同的设备而变化,故而-8F * resources.displayMetrics.density整体就是一个动态的值,至于-8其实也不一定是-8,也可以是-7或者-9,具体是哪个值可以根据具体的显示效果来调整,直到满意为止。

简单运用

接下来通过将范围裁切和几何变换进行配合,实现一个 Bitmap 对半折效果(从下往上翻):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 省略重复代码

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

canvas.save()
canvas.translate(imageMargin + imageSize / 2, imageMargin + imageSize / 2)
canvas.clipRect(-imageSize / 2, -imageSize / 2, imageSize / 2, 0F)
canvas.translate(-(imageMargin + imageSize / 2), -(imageMargin + imageSize / 2))
canvas.drawBitmap(bitmap, imageMargin, imageMargin, null)
canvas.restore()

// 保存当前 Canvas 状态
canvas.save()
// 将 Canvas 坐标系移动回原来的位置
canvas.translate(imageMargin + imageSize / 2, imageMargin + imageSize / 2)
// 进行投射
camera.applyToCanvas(canvas)
// 进行范围裁切,只保留 Bitmap 的下半部分
canvas.clipRect(-imageSize / 2, 0F, imageSize / 2, imageSize / 2)
// 将 Canvas 坐标系向左上角移动,让图形中心与原点重合
canvas.translate(-(imageMargin + imageSize / 2), -(imageMargin + imageSize / 2))
// 绘制 Bitmap
canvas.drawBitmap(bitmap, imageMargin, imageMargin, null)
// 恢复 Canvas 状态
canvas.restore()
}

// 省略重复代码

并没有现成的方法能够实现 Bitmap 对半折(一半翻起来另一半不翻)的效果,所以自行实现的思路就是在几何变换的基础上加上范围裁切,先裁切下半部分,然后进行翻转,最后裁切上半部分,将两部分重合即可。

这里额外需要提及的就是调用了 Canvas 的save()restore()来保存和恢复状态,这是因为在第一次裁切以后,Canvas 的可绘制区域已被改变,需要通过保存裁切前的状态,并在裁切完成后进行恢复,才能保证绘制下半部分时能正常工作。


Android 中的范围裁切和几何变换
http://example.com/post/Range-clipping-and-geometric-transformations-in-Android/
发布于
2023年5月15日
更新于
2023年5月16日
许可协议