第4章 Canvas进阶操作:画布变换与裁剪机制
说实话,Canvas的变换操作是我刚学自定义View时最头疼的部分。那时候我总觉得,画个圆就画圆,干嘛还要平移旋转?直到我在项目中做一个地图标注功能,需要把图标旋转到特定角度...嗯,那次我彻底明白了变换的重要性。
4.1 画布变换:让绘图更灵活
Canvas的变换操作,说白了就是改变画布坐标系。你想想看,默认坐标系是左上角为原点,X轴向右,Y轴向下。但实际开发中,我们经常需要换个角度、换个位置来画东西。
4.1.1 平移(translate)
平移是最基础的变换。它把坐标系的原点移动到新的位置。
// 把原点移到 (100, 100)
canvas.translate(100f, 100f)
// 现在画一个圆,圆心在 (0, 0),实际显示在 (100, 100)
canvas.drawCircle(0f, 0f, 50f, paint)
我个人习惯用平移来做相对定位。比如画一个仪表盘,所有刻度都相对于中心点来画,这样代码清晰多了。
4.1.2 旋转(rotate)
旋转操作让画布绕某个点转动。注意,旋转会影响后续所有绘制。
// 绕原点旋转45度
canvas.rotate(45f)
// 或者绕指定点旋转
canvas.rotate(45f, centerX, centerY)
4.1.3 缩放(scale)
缩放可以改变坐标轴的单位长度。参数是X轴和Y轴的缩放比例。
// X轴放大2倍,Y轴不变
canvas.scale(2f, 1f)
// 或者整体缩放
canvas.scale(1.5f, 1.5f)
4.1.4 错切(skew)
错切可能用得少一些,但做倾斜效果时很管用。它让图形沿着某个方向拉伸。
// X轴错切0.5,Y轴不变
canvas.skew(0.5f, 0f)
错切的效果有点像把正方形拉成平行四边形。我在做3D翻转动画时用过它,配合旋转能模拟出透视效果。
4.2 save与restore机制:状态栈管理
这里我要重点说一下save和restore。很多初学者不理解为什么要用它们,觉得麻烦。其实它们解决了一个核心问题:变换的隔离。
你想想看,如果你在画一个复杂图形时做了平移和旋转,画完之后坐标系就变了。后面的绘制全都会受影响。这时候就需要save和restore来保存和恢复画布状态。
// 保存当前状态
canvas.save()
// 做一些变换
canvas.translate(100f, 100f)
canvas.rotate(45f)
// 画一个旋转后的矩形
canvas.drawRect(0f, 0f, 200f, 100f, paint)
// 恢复之前的状态
canvas.restore()
// 这里的坐标系还是原来的,不受影响
canvas.drawCircle(0f, 0f, 50f, paint)
我曾经犯过一个错误:在循环里调用了save但忘了restore,结果画布状态越来越乱,图形完全变形了。排查了半天才发现是状态栈不平衡。
4.3 Clip裁剪与Region
裁剪是Canvas另一个强大的功能。它让你只绘制指定区域内的内容,区域外的部分会被自动裁剪掉。
4.3.1 基本裁剪
// 裁剪一个矩形区域
canvas.clipRect(100f, 100f, 300f, 300f)
// 后续绘制只在这个区域内显示
canvas.drawBitmap(bitmap, 0f, 0f, paint)
4.3.2 路径裁剪
更灵活的是用Path来裁剪,可以裁剪出任意形状。
val path = Path()
path.addCircle(200f, 200f, 150f, Path.Direction.CW)
canvas.clipPath(path)
// 现在只有圆形区域内的内容会显示
4.3.3 Region操作
Region是Android中表示区域的高级类。它支持各种集合运算:交集、并集、差集等。
val region1 = Region(100, 100, 300, 300)
val region2 = Region(200, 200, 400, 400)
// 计算交集
val intersectRegion = Region()
intersectRegion.op(region1, region2, Region.Op.INTERSECT)
// 应用到Canvas
canvas.clipRegion(intersectRegion)
Region操作在复杂裁剪场景下特别有用。比如你要裁剪出一个圆环形状,就可以用两个圆的Region做差集。
setLayerType(LAYER_TYPE_SOFTWARE, null)。
4.4 实战:组合变换实现复杂效果
好了,理论说完了。我们来个实战例子:画一个旋转的齿轮。
class GearView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 4f
}
private var rotationAngle = 0f
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val centerX = width / 2f
val centerY = height / 2f
val radius = minOf(centerX, centerY) - 20f
// 保存初始状态
canvas.save()
// 平移到中心
canvas.translate(centerX, centerY)
// 旋转
canvas.rotate(rotationAngle)
// 画齿轮外圈
canvas.drawCircle(0f, 0f, radius, paint)
// 画齿轮齿
for (i in 0 until 12) {
canvas.save()
canvas.rotate(i * 30f)
canvas.drawRect(
-8f, radius - 20f,
8f, radius + 10f,
paint
)
canvas.restore()
}
// 画内圈
canvas.drawCircle(0f, 0f, radius * 0.6f, paint)
// 恢复初始状态
canvas.restore()
// 更新旋转角度
rotationAngle += 2f
if (rotationAngle >= 360f) {
rotationAngle = 0f
}
invalidate()
}
}
这个例子用到了平移、旋转、save/restore的嵌套使用。每个齿都是独立旋转后绘制的,互不影响。这就是状态栈管理的精髓。
4.5 避坑指南
最后,分享几个我踩过的坑:
- 变换顺序很重要:先平移再旋转和先旋转再平移,结果完全不同。我建议先平移再旋转,这样更符合直觉。
- save和restore要配对:我习惯在onDraw开头调用save,结尾调用restore。如果中间有嵌套,用try-finally保证配对。
- 裁剪后记得恢复:裁剪会影响后续所有绘制。如果只想裁剪一部分,一定要用save/restore包裹。
- 硬件加速限制:clipPath在硬件加速下可能失效。如果遇到问题,先试试关闭硬件加速。
嗯,Canvas的变换和裁剪就讲到这里。这些操作看起来简单,但组合起来能实现非常丰富的效果。下一章我们会讲Path的高级用法,到时候这些变换技巧会派上大用场。