范围裁切
范围裁切是 Canvas
的功能,它可以裁出一块区域,使得后续的绘制都会被限制在这个区域中。
我们先来绘制一张图片,代码如下:
kotlin
class CameraView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
companion object {
// 图片大小
val IMAGE_WIDTH = 150.dp
val IMAGE_HEIGHT = 150.dp
}
private val bitmap by lazy {
getAvatar(R.drawable.avatar, IMAGE_WIDTH.toInt())
}
private val paint by lazy {
Paint(Paint.ANTI_ALIAS_FLAG)
}
override fun onDraw(canvas: Canvas) {
val offsetX = IMAGE_WIDTH / 2
val offsetY = IMAGE_HEIGHT / 2
// 绘制图片
canvas.drawBitmap(bitmap, offsetX, offsetY, paint)
}
private fun getAvatar(@DrawableRes imageId: Int, targetWidth: Int): Bitmap {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeResource(resources, imageId, options)
options.inJustDecodeBounds = false
options.inDensity = options.outWidth
options.inTargetDensity = targetWidth
return BitmapFactory.decodeResource(resources, imageId, options)
}
}
运行效果:
然后,使用 Canvas
的裁剪方法 clipRect()
,让这张图片只显示下半部分。
kotlin
override fun onDraw(canvas: Canvas) {
val offsetX = IMAGE_WIDTH / 2
val offsetY = IMAGE_HEIGHT / 2
// 裁剪
canvas.clipRect(
offsetX,
offsetY + IMAGE_HEIGHT / 2,
offsetX + IMAGE_WIDTH,
offsetY + IMAGE_HEIGHT
)
// 绘制图片
canvas.drawBitmap(bitmap, offsetX, offsetY, paint)
}
效果:
和 clipRect()
同类的方法还有 clipPath()
、clipOutRect()
以及 clipOutPath()
等,我们只看 clipPath()
,因为 clipOutRect()
和 clipOutPath()
只是这两个方法的反向版本。
我们使用 clipPath()
来裁剪圆形头像,代码如下:
kotlin
private val path by lazy {
Path().apply {
val offsetX = IMAGE_WIDTH / 2
val offsetY = IMAGE_HEIGHT / 2
addOval(
offsetX,
offsetY,
offsetX + IMAGE_WIDTH,
offsetY + IMAGE_HEIGHT,
android.graphics.Path.Direction.CW
)
}
}
override fun onDraw(canvas: Canvas) {
val offsetX = IMAGE_WIDTH / 2
val offsetY = IMAGE_HEIGHT / 2
// 裁剪
canvas.clipPath(path)
// 绘制图片
canvas.drawBitmap(avatar, offsetX, offsetY, paint)
}
运行效果:
需要注意的是,在 Android 早期版本(Android 6.0 之前)中,直接使用 clipPath
裁剪圆形会有明显的锯齿问题(我们通常会使用 Xfermode
或 BitmapShader
来解决)。虽然从 Android 9.0 开始,硬件加速对此有优化,锯齿感已经不明显了,但和以上两种方案还是有所区别的。
因为 clipPath
是精确到像素的裁切,并没有颜色混合;而 Xfermode
或 BitmapShader
是通过像素混合来达到真正的抗锯齿效果的。如果要平滑的边缘,最好使用 Xfermode
或 BitmapShader
,如果要精确边界,就可以使用 clipPath
。
几何变换
Canvas 的变换
Canvas 几何变换相关的方法就只有以下四个:
-
translate(float dx, float dy)
: 平移,移动 Canvas 的坐标系原点。dx、dy 分别是 x、y 轴方向上移动的距离。
-
rotate(float degrees, float px, float py)
: 旋转,围绕中心点旋转 Canvas 的坐标系。px、py 是可选的,用于指定旋转的中心点,默认的中心点是坐标原点。
注意: 旋转操作,最好是平移画布原点到待旋转控件的旋转中心,再旋转,再进行绘制。
-
scale(float sx, float sy, float px, float py)
: 缩放,对 Canvas 的坐标系进行缩放。sx、sy 分别是缩放比例,大于 1 表示放大,小于 1 表示缩小。px、py 是可选的,用于指定缩放的中心点,默认的中心点是坐标原点。
-
skew(float sx, float sy)
: 错切/倾斜,让 Canvas 的坐标系倾斜。
在开始讲解 Canvas
几何变换的例子之前,我们先要明白,Canvas
变换的是自己的坐标系,而不是要绘制的内容。并且这些变换效果是累积的,每一次变换都是在上一次变换之后的坐标系基础上进行的。
例如,当 Canvas 的坐标系绕原点旋转 45 度后,在 (IMAGE_WIDTH,0) 处绘制了一张图片,其结果会是这样的。
明白了这点,我们来看看如何实现下图效果:
其实很简单,我们只需通过 translate()
对画布进行平移,然后在执行 rotate()
旋转即可。
kotlin
// 关键代码
val imageSize = 150.dp
canvas.translate(
imageSize,
-(imageSize * sqrt(2.0) / 2f - imageSize / 2f).toFloat()
)
canvas.rotate(45f, 0f, 0f)
canvas.drawBitmap(bitmap, 0f, 0f, paint)
这个过程是这样的:
蓝绿色区域即是 Canvas 的坐标系。
Matrix 的变换
我们也可以使用 Matrix 进行几何变换,它有两套写法。
-
preTranslate()/postTranslate()
-
preRotate()/postRotate()
-
preScale()/postScale()
-
preSkew()/postSkew()
pre
前缀表示变换是前乘的,作用于原始坐标系 ,和 Canvas
中不带前缀的方法效果相同;post
前缀表示后乘,作用于当前已经变换过的坐标系。
Camera
android.graphics.Camera
是一个"虚拟的 3D 摄像机",我们可以移动或旋转它,来实现 3D 的空间变换效果。
Camera 默认位于 (0,0,-8)
的位置 (单位是英寸,约等于 72 像素),正对着 y 轴正方向,View 画布在 z=0 的平面上。
这里的坐标系并不是我们之前认为的 View 或 Canvas 的坐标系,而是这样的:
z 轴正方向为屏幕向内,y 轴正方形为垂直向上,x 轴正方形为水平向右。
如果要实现下图中的向屏幕外翻转的效果,我们可以调用 Camera 的 rotateX(degrees)
方法来变换。
变换方法还有
rotate(x,y,z)
: ,rotateY(degrees)
: 绕 Y 轴旋转、rotateZ(degrees)
: 绕 Z 轴旋转、translate(x, y, z)
: 平移。
代码如下:
kotlin
companion object {
// 图片大小
val IMAGE_WIDTH = 150.dp
val IMAGE_HEIGHT = 150.dp
}
private val camera by lazy {
Camera()
}
override fun onDraw(canvas: Canvas) {
val offsetX = IMAGE_WIDTH / 2f
val offsetY = IMAGE_HEIGHT / 2f
canvas.save()
camera.rotateX(30f)
// 应用到 Canvas 上
camera.applyToCanvas(canvas)
canvas.drawBitmap(bitmap, offsetX, offsetY, paint)
canvas.restore()
}
但你会发现结果和预料的并不一样:
这是因为所有旋转都是围绕着坐标原点 (0,0,0) 进行的。它沿 X 轴旋转后,投射在画布上的结果,就是上图中的效果。
你也可以理解为是把一张纸的左上角顶住,然后进行翻转的效果。
所以,为了实现围绕图片中心旋转的效果。我们需要用到我们在二维旋转时提到的思路:先将 Canvas 的原点移到要旋转的中心,执行旋转,再将 Canvas 移回去。
为什么这样可行?因为在旋转时,Canvas 的坐标原点位于图片的中心,而 Camera 应用在 Canvas 上时,正好就在图片中心上方,所以此时的旋转,会沿着图片水平中心线旋转。
kotlin
override fun onDraw(canvas: Canvas) {
val offsetX = IMAGE_WIDTH / 2f
val offsetY = IMAGE_HEIGHT / 2f
// 图片的中心点坐标
val centerX = offsetX + IMAGE_WIDTH / 2f
val centerY = offsetY + IMAGE_HEIGHT / 2f
canvas.save()
// 移到图片旋转中心
canvas.translate(centerX, centerY)
// 执行旋转
camera.rotateX(30f)
camera.applyToCanvas(canvas)
// 移回去
canvas.translate(-centerX, -centerY)
// 绘制图片
canvas.drawBitmap(bitmap, offsetX, offsetY, paint)
canvas.restore()
}
运行效果:
另外,我们可以通过 setLocation(x,y,z)
来调整相机的位置。通常我们只改变 z 轴调整摄像机的远近(调用 setLocation(0, 0, z)
),来改变其透视效果。
默认 z 轴位置 -8 * 72
是一个固定的像素值,在高密度屏幕上,这会让 UI 元素显得非常近,导致透视效果加大。如果你想让它在不同像素密度的屏幕上显示的效果一致,可以乘上屏幕密度 (resources.displayMetrics.density
)。
kotlin
private val camera by lazy {
Camera().apply {
// -7f 可自由调整
setLocation(0f, 0f, -7f * resources.displayMetrics.density)
}
}
纸张翻页效果
我们来实现翻页效果:让图片的下半部分翻起。
我们可以先使用范围裁切绘制图片的上半部分,然后绘制下半部分时,再使用 Camera 进行变换。
我们先绘制上、下部分,代码如下:
kotlin
override fun onDraw(canvas: Canvas) {
val offsetX = IMAGE_WIDTH / 2f
val offsetY = IMAGE_HEIGHT / 2f
val centerX = offsetX + IMAGE_WIDTH / 2f
val centerY = offsetY + IMAGE_HEIGHT / 2f
// 绘制上半部分
canvas.save()
canvas.clipRect(offsetX, offsetY, offsetX + IMAGE_WIDTH, offsetY + IMAGE_HEIGHT / 2f)
canvas.drawBitmap(bitmap, offsetX, offsetY, paint)
canvas.restore()
// 绘制下半部分
canvas.save()
// 裁切
canvas.clipRect(
offsetX,
offsetY + IMAGE_HEIGHT / 2f,
offsetX + IMAGE_WIDTH,
offsetY + IMAGE_HEIGHT
)
canvas.drawBitmap(bitmap, offsetX, offsetY, paint)
canvas.restore()
}
然后,对下半部分进行变换。这里的关键是裁剪位置,要在三维变换之后,在执行绘制之前。
代码如下:
kotlin
override fun onDraw(canvas: Canvas) {
val offsetX = IMAGE_WIDTH / 2f
val offsetY = IMAGE_HEIGHT / 2f
val centerX = offsetX + IMAGE_WIDTH / 2f
val centerY = offsetY + IMAGE_HEIGHT / 2f
// 绘制上半部分
canvas.save()
canvas.clipRect(offsetX, offsetY, offsetX + IMAGE_WIDTH, offsetY + IMAGE_HEIGHT / 2f)
canvas.drawBitmap(bitmap, offsetX, offsetY, paint)
canvas.restore()
// 绘制下半部分
canvas.save()
canvas.translate(centerX, centerY)
camera.apply {
// Camera 和 Paint 等一样,都是有状态的
save()
rotateX(30f)
applyToCanvas(canvas)
restore()
}
canvas.translate(-centerX, -centerY)
// 裁切
canvas.clipRect(
offsetX,
offsetY + IMAGE_HEIGHT / 2f,
offsetX + IMAGE_WIDTH,
offsetY + IMAGE_HEIGHT
)
canvas.drawBitmap(bitmap, offsetX, offsetY, paint)
canvas.restore()
}
运行效果:
当然翻页效果,一般都是斜着的。我们只需使用 Canvas
的旋转让翻折线转正,再进行 Camera
的旋转和裁切,最后再转回来即可。
代码:
kotlin
override fun onDraw(canvas: Canvas) {
val offsetX = IMAGE_WIDTH / 2f
val offsetY = IMAGE_HEIGHT / 2f + 100f.dp
val centerX = offsetX + IMAGE_WIDTH / 2f
val centerY = offsetY + IMAGE_HEIGHT / 2f
// 绘制上半部分
canvas.save()
canvas.translate(centerX, centerY)
canvas.rotate(-30f)
// 因为旋转后,剪裁可能会裁剪掉不想裁剪的部分,所以要让裁剪区域尽可能大
canvas.clipRect(
-IMAGE_WIDTH,
-IMAGE_HEIGHT,
IMAGE_WIDTH,
0f
)
canvas.rotate(30f)
canvas.translate(-centerX, -centerY)
canvas.drawBitmap(bitmap, offsetX, offsetY, paint)
canvas.restore()
// 绘制下半部分
canvas.save()
canvas.translate(centerX, centerY)
canvas.rotate(-30f)
camera.apply {
// Camera 和 Paint 等一样,都是有状态的
save()
rotateX(30f)
applyToCanvas(canvas)
restore()
}
// 当前原点坐标在图片中心
canvas.clipRect(
-IMAGE_WIDTH,
0f,
IMAGE_WIDTH,
IMAGE_HEIGHT
)
canvas.rotate(30f)
canvas.translate(-centerX, -centerY)
canvas.drawBitmap(bitmap, offsetX, offsetY, paint)
canvas.restore()
}
运行效果: