Android 自定义 View:范围裁切和几何变换

范围裁切

范围裁切是 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 裁剪圆形会有明显的锯齿问题(我们通常会使用 XfermodeBitmapShader 来解决)。虽然从 Android 9.0 开始,硬件加速对此有优化,锯齿感已经不明显了,但和以上两种方案还是有所区别的。

因为 clipPath 是精确到像素的裁切,并没有颜色混合;而 XfermodeBitmapShader 是通过像素混合来达到真正的抗锯齿效果的。如果要平滑的边缘,最好使用 XfermodeBitmapShader,如果要精确边界,就可以使用 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()
}

运行效果:

相关推荐
枯骨成佛3 小时前
MTK Android 14 通过属性控制系统设置显示双栏或者单栏
android
jiushiapwojdap4 小时前
Flutter上手记:为什么我的按钮能同时在iOS和Android上跳舞?[特殊字符][特殊字符]
android·其他·flutter·ios
limuyang26 小时前
Android RenderScript-toolkit库,替换老式的脚本方式(常用于高斯模糊)
android
柿蒂7 小时前
产品需求驱动下的技术演进:动态缩放View的不同方案
android·kotlin·android jetpack
Andy_GF9 小时前
鸿蒙Next在蒲公英平台分发测试包
android·ios·harmonyos
恋猫de小郭10 小时前
iOS 26 正式版即将发布,Flutter 完成全新 devicectl + lldb 的 Debug JIT 运行支持
android·前端·flutter
幻雨様11 小时前
UE5多人MOBA+GAS 54、用户登录和会话创建请求
android·ue5
Just_Paranoid11 小时前
【SystemUI】锁屏来通知默认亮屏Wake模式
android·framework·systemui·keyguard·aod