返回

安卓ImageView setImageMatrix对ShapeDrawable失效?原因与解决办法

Android

搞定 Android ImageView 的 setImageMatrix 不生效问题

你是不是也遇到了 ImageView 设置了 scaleType="matrix",然后兴冲冲地调用 setImageMatrix(matrix),结果发现图片纹丝不动?无论是缩放、平移还是镜像,统统失效,就像下面这位朋友遇到的情况:

// 期望缩放图片
val m = Matrix()
m.setScale(0.5f, 0.5f)
// m.setTranslate(30f, 30f) // 平移也不好使
imageView.setImageMatrix(m) // 调用了但没效果
imageView.invalidate()      // 刷新也没用

// XML 设置
<ImageView
    android:id="@+id/sun"
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:scaleType="matrix"  // 明确指定了 matrix
    android:src="@drawable/sun" /> // 源是个 ShapeDrawable (oval)

用的图片资源还是个 ShapeDrawable

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="oval">
    <solid android:color="@color/bright_sun" />
</shape>

尝试了在 onPreDraw 里设置,在点击事件里设置,各种组合都试了,就是没反应。别急,这事儿挺常见的,咱们来分析分析是咋回事,再看看怎么解决。

为什么 setImageMatrix 失效了?

问题的关键往往不在于 setImageMatrix 这个方法本身,也不在于 scaleType="matrix" 这个设置,而在于你给 ImageView 设置的那个 Drawable 是什么类型。

核心症结:Drawable 不配合

ImageViewsetImageMatrix 方法,其设计初衷主要是为了配合那些具有“内禀尺寸”(intrinsic dimensions)的 Drawable,最典型的就是 BitmapDrawable。当你给 ImageView 一个 BitmapDrawable,并且设置 scaleType="matrix" 时,ImageView 在绘制阶段会:

  1. 获取你通过 setImageMatrix 设置的 Matrix
  2. 将这个 Matrix 应用(concat)到 Canvas 上。
  3. 然后让 BitmapDrawable 在这个变换后的 Canvas 上绘制自己。

这样一来,Bitmap 就按照你期望的 Matrix 进行了变换(缩放、平移、旋转、镜像等)。

但是!当你使用 ShapeDrawable 或者某些其他类型的 Drawable(比如 ColorDrawable)时,情况就不同了。ShapeDrawable 这类 Drawable 通常没有明确的“原始图片尺寸”。它们绘制自己的逻辑,更多是依赖于 View 通过 setBounds() 方法告诉它的可用绘制区域有多大。它在自己的 draw(Canvas canvas) 方法里,直接在给定的 bounds 内绘制形状,不太会去主动关心 ImageView 可能通过 setImageMatrix 传过来的那个 Matrix 小纸条。

简单说:setImageMatrix 设定了一个变换规则,但 ShapeDrawable 这哥们儿画图时,没按这个规则来,直接按自己的理解(主要看 bounds)画完了事。ImageView 本身并没有强制所有 Drawable 都必须遵守这个 Matrix

scaleType 的影响

你设置 scaleType="matrix" 是绝对正确的,这步操作告诉了 ImageView:“老大,待会儿绘图的时候,记得用我(通过 setImageMatrix)给你的那个 Matrix 哦!”。如果 scaleType 不是 matrix(比如是 fitCenter),那 ImageView 会用内置的逻辑去计算一个 Matrix 来让 Drawable 适应视图,你再调用 setImageMatrix 设置的 Matrix 就会被覆盖或忽略。

所以,scaleType="matrix" 是必要条件,但不是充分条件。还需要 Drawable 类型本身支持或者说能响应这个 Matrix

解决方案

知道了问题根源,解决起来就思路清晰了。下面提供几种可行的方法,各有优劣,可以根据你的具体需求选择。

方案一:将 ShapeDrawable 转为 BitmapDrawable

这是最直接也最符合 setImageMatrix 设计意图的方法。既然 ShapeDrawable 不听话,咱就把它变成听话的 BitmapDrawable

原理:

先将 ShapeDrawable 绘制到一个临时的 Bitmap 上,然后用这个 Bitmap 创建一个 BitmapDrawableBitmapDrawablesetImageMatrix 的“老朋友”了,能很好地配合工作。

操作步骤与代码示例 (Kotlin):

import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.ShapeDrawable
import androidx.core.content.ContextCompat

// 假设你的 ImageView 实例叫 imageView, ShapeDrawable 资源 ID 是 R.drawable.sun

// 1. 获取 ShapeDrawable
val shapeDrawable: Drawable? = ContextCompat.getDrawable(context, R.drawable.sun)

if (shapeDrawable != null) {
    // 2. 决定 Bitmap 的尺寸 (可以根据 ImageView 的尺寸或预设值)
    //    这里用 ImageView 的尺寸为例,注意在获取尺寸前确保 View 已经测量布局完成
    //    更稳妥的做法可能是在 onSizeChanged 或使用 ViewTreeObserver 获取准确尺寸
    //    或者,直接指定一个固定尺寸
    val width = imageView.width.takeIf { it > 0 } ?: 100 // fallback size
    val height = imageView.height.takeIf { it > 0 } ?: 100 // fallback size

    if (width > 0 && height > 0) {
        // 3. 创建一个 Bitmap
        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)

        // 4. 创建一个基于该 Bitmap 的 Canvas
        val canvas = Canvas(bitmap)

        // 5. 设置 ShapeDrawable 的绘制边界
        //    非常重要:ShapeDrawable 需要知道在哪里绘制
        shapeDrawable.setBounds(0, 0, width, height)

        // 6. 将 ShapeDrawable 绘制到 Canvas 上 (也就是绘制到 Bitmap 上)
        shapeDrawable.draw(canvas)

        // 7. 创建 BitmapDrawable
        val bitmapDrawable = BitmapDrawable(context.resources, bitmap)

        // 8. 将 BitmapDrawable 设置给 ImageView
        //    记得 scaleType 仍然是 "matrix"
        imageView.scaleType = ImageView.ScaleType.MATRIX // 再次确认
        imageView.setImageDrawable(bitmapDrawable)

        // 9. 现在可以愉快地设置 Matrix 了
        val m = Matrix()
        // 例如,实现水平镜像
        m.setScale(-1f, 1f, width / 2f, height / 2f) // 以中心点镜像
        // 或者缩放
        // m.setScale(0.5f, 0.5f, width / 2f, height / 2f) // 以中心点缩放 50%

        imageView.imageMatrix = m // 注意是 imageMatrix 属性 或 setImageMatrix(m) 方法
        // imageView.invalidate() // imageMatrix 属性的 setter 通常内部会调用 invalidate
    } else {
        // 处理无法获取有效尺寸的情况
        Log.e("ImageMatrixFix", "ImageView dimensions are zero, cannot create Bitmap.")
    }
} else {
    // 处理 Drawable 未找到的情况
    Log.e("ImageMatrixFix", "ShapeDrawable not found.")
}

注意事项与建议:

  • Bitmap 内存: 创建 Bitmap 会消耗内存。如果 ImageView 尺寸很大,或者需要频繁变换形状/颜色并重新生成 Bitmap,要注意内存开销和潜在的 OOM 风险。
  • 绘制时机: 获取 imageView.widthimageView.height 需要在 View 测量布局之后。如果在 Activity/FragmentonCreate / onViewCreated 中直接执行,可能得到 0。可以考虑使用 ViewTreeObserver.OnGlobalLayoutListenerpost { ... } 来延迟执行获取尺寸和转换的操作。
  • 缓存: 如果形状和颜色是固定的,生成的 BitmapDrawable 可以缓存起来复用,避免重复创建 Bitmap
  • 图片质量: Bitmap.Config.ARGB_8888 提供最好的质量但占用内存最多。根据需要可以考虑 ARGB_4444RGB_565(如果不需要透明度)。

进阶技巧:

  • 为啥 BitmapDrawable 就行?因为它有明确的“内禀宽高”(intrinsic width/height),即 Bitmap 的原始尺寸。ImageView 在应用 Matrix 时,知道这个原始尺寸,可以精确计算变换后的绘制方式。ShapeDrawable 没有这个内禀尺寸的概念。
  • 可以封装一个工具方法 fun shapeToBitmapDrawable(context: Context, drawableId: Int, width: Int, height: Int): BitmapDrawable? 来简化转换过程。

方案二:自定义 ImageView 并重写 onDraw

如果你不想做 Drawable 转换,或者需要更精细地控制绘制过程,可以创建一个 ImageView 的子类,重写它的 onDraw 方法。

原理:

直接在绘制阶段(onDraw)介入,获取到 Canvas 对象后,在调用父类(super.onDraw)或者自己手动绘制 Drawable 之前,先对 Canvas 应用我们想要的 Matrix 变换。这样一来,后续所有的绘制操作都会在这个变换后的坐标系下进行。

操作步骤与代码示例 (Kotlin):

  1. 创建自定义 ImageView 类:

    import android.content.Context
    import android.graphics.Canvas
    import android.graphics.Matrix
    import android.util.AttributeSet
    import androidx.appcompat.widget.AppCompatImageView
    
    class TransformableImageView @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
    ) : AppCompatImageView(context, attrs, defStyleAttr) {
    
        private val transformMatrix = Matrix()
        private var matrixApplied = false // 标记是否应用了自定义 Matrix
    
        // 提供一个方法来设置你想要的变换矩阵
        fun setTransformMatrix(matrix: Matrix?) {
            transformMatrix.set(matrix ?: Matrix()) // 如果传入 null,则重置为单位矩阵
            matrixApplied = matrix != null && !matrix.isIdentity // 只有非单位矩阵才算应用了
            invalidate() // 请求重绘
        }
    
        // 获取当前应用的变换矩阵(可选)
        fun getTransformMatrix(): Matrix {
            return Matrix(transformMatrix) // 返回副本防止外部修改
        }
    
        override fun onDraw(canvas: Canvas) {
            // 关键点:在绘制内容前应用 Matrix
            if (matrixApplied && drawable != null) {
                // 保存当前的 Canvas 状态 (包括 Matrix)
                val saveCount = canvas.save()
    
                // 将我们的变换矩阵应用到 Canvas 上
                // concat 是在 Canvas 原有变换基础上再乘上我们的矩阵
                canvas.concat(transformMatrix)
    
                // 让父类 ImageView 继续绘制它的 Drawable
                // 因为 Canvas 已经被变换了,所以 Drawable 的绘制自然就被影响了
                super.onDraw(canvas)
    
                // 恢复 Canvas 状态到 save() 之前,避免影响后续其他 View 的绘制
                canvas.restoreToCount(saveCount)
            } else {
                // 如果没有设置自定义 Matrix 或没有 Drawable,就按默认方式绘制
                super.onDraw(canvas)
            }
        }
    
        // 注意:当使用这种方法时,XML 中的 android:scaleType 就不再是必须为 "matrix" 了。
        // 因为我们是在 onDraw 中手动应用 Matrix。你可以设置成 fitCenter 等,
        // super.onDraw(canvas) 会先按 scaleType 处理,然后我们的 Matrix 会再作用于其上。
        // 如果想完全由你的 Matrix 控制,可以在 onDraw 里不调用 super.onDraw(),
        // 而是自己获取 drawable,设置 bounds,然后调用 drawable.draw(canvas)。
        // 但那样会失去 ImageView 内置 scaleType 的便利性。
    
        // 示例:手动绘制 Drawable (替代 super.onDraw(canvas))
        /*
        private fun drawManually(canvas: Canvas) {
            val drawable = drawable ?: return
            // 假设我们想让 Drawable 在应用 Matrix 后居中显示在 View 内
            // 这里计算 bounds 会比较复杂,需要考虑 Matrix 的影响
            // 为了简单起见,这里仍然使用 View 的尺寸作为初始 bounds
            // 然后依赖 Canvas 的 Matrix 来完成变换
    
            val viewWidth = width - paddingLeft - paddingRight
            val viewHeight = height - paddingTop - paddingBottom
            drawable.setBounds(paddingLeft, paddingTop, paddingLeft + viewWidth, paddingTop + viewHeight)
    
            val saveCount = canvas.save()
            canvas.concat(transformMatrix)
            drawable.draw(canvas)
            canvas.restoreToCount(saveCount)
        }
        */
    }
    
  2. 在 XML 布局中使用自定义 ImageView:

    <com.yourpackage.TransformableImageView
        android:id="@+id/customSunImageView"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_gravity="center"
        android:src="@drawable/sun"
        app:scaleType="fitCenter"  <!-- 注意这里不一定非要是 matrix  -->
        />
        <!-- 或者保持 android:scaleType="matrix" 然后完全自己控制 Matrix -->
    
  3. 在代码中设置 Matrix:

    val customImageView = findViewById<TransformableImageView>(R.id.customSunImageView)
    
    val m = Matrix()
    // 实现水平镜像
    m.setScale(-1f, 1f, customImageView.width / 2f, customImageView.height / 2f)
    // 或者缩放
    // m.setScale(0.5f, 0.5f, customImageView.width / 2f, customImageView.height / 2f)
    
    customImageView.setTransformMatrix(m)
    

注意事项与建议:

  • 性能: onDraw 方法会被频繁调用(例如,在动画期间)。确保 onDraw 内部的逻辑尽可能高效,避免创建新对象(如 Matrix 实例,transformMatrix 应该是成员变量)。
  • canvas.save()canvas.restore() 这对操作非常重要。save() 保存 Canvas 当前的状态(包括变换矩阵、裁剪区域等),restore() 恢复到最近一次 save() 时的状态。这能确保你的变换只影响当前 ImageView 的绘制,不会干扰到后面绘制的其他 View
  • canvas.concat(matrix) vs canvas.setMatrix(matrix) concat 是在 Canvas 当前已有的变换基础上 追加 新的变换(矩阵乘法)。setMatrix 则是 完全替换 Canvas 的变换矩阵。通常情况下,在 onDraw 里修改特定 View 的绘制,使用 concat 更为常见和安全,因为它能跟系统或其他效果(如 ViewtranslationX/Y, scaleX/Y 属性)叠加。
  • ImageView 其他属性的交互: 如果你选择调用 super.onDraw(canvas),那么 ImageViewscaleType 属性仍然会起作用,它会先应用自己的变换,然后你的 Matrix 再作用于结果之上。如果你想完全掌控,就需要跳过 super.onDraw,自己调用 drawable.draw(canvas),但这需要你自行处理 Drawable 的定位和尺寸问题(计算 bounds)。

进阶技巧:

  • 可以结合 Viewanimate() 方法或者 ValueAnimator 来实现平滑的 Matrix 变换动画。在动画的 onAnimationUpdate 回调中更新 transformMatrix 并调用 invalidate()
  • 考虑处理 paddingsuper.onDraw 通常会处理 padding,如果你自己调用 drawable.draw,需要手动在计算 bounds 或在应用 Matrix 前通过 canvas.translate(paddingLeft, paddingTop) 来偏移绘制内容。

方案三:使用 Canvas 操作(针对 ShapeDrawable 的特定场景)

如果你的最终目的只是在一个区域内画一个经过变换的形状,也许你根本不需要一个功能完整的 ImageView。一个更轻量级的自定义 View 可能更合适。

原理:

创建一个基础的 View 子类,在它的 onDraw 方法里,直接获取 ShapeDrawable,然后使用 Canvas 的变换方法(如 canvas.scale(), canvas.translate(), canvas.concat(matrix)) 来设置好坐标系,最后让 ShapeDrawable 在这个变换后的 Canvas 上绘制自己。

操作步骤与代码示例 (Kotlin):

  1. 创建自定义 View 类:

    import android.content.Context
    import android.graphics.Canvas
    import android.graphics.Matrix
    import android.graphics.drawable.ShapeDrawable
    import android.util.AttributeSet
    import android.view.View
    import androidx.core.content.ContextCompat
    
    class ShapeDrawingView @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
    ) : View(context, attrs, defStyleAttr) {
    
        private var shapeDrawable: ShapeDrawable? = null
        private val transformMatrix = Matrix()
    
        init {
            // 假设 R.drawable.sun 是 ShapeDrawable 资源 ID
            // 你也可以通过自定义属性来设置 Drawable 资源
            ContextCompat.getDrawable(context, R.drawable.sun)?.let {
                if (it is ShapeDrawable) {
                    shapeDrawable = it
                }
                // 或者如果 drawable 本身不是 ShapeDrawable,而是比如 VectorDrawable
                // 并且你知道如何绘制它,也可以处理
            }
        }
    
        fun setDrawable(drawable: ShapeDrawable?) {
            this.shapeDrawable = drawable
            requestLayout() // Drawable 可能影响尺寸计算
            invalidate()
        }
    
        fun setTransformMatrix(matrix: Matrix?) {
            transformMatrix.set(matrix ?: Matrix())
            invalidate()
        }
    
        override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
            // 基础实现:如果 Drawable 有内禀尺寸,可以用它们;
            // 对于 ShapeDrawable,通常没有,所以我们依赖布局参数或给个默认值
            val defaultSize = 100 // dp, 需要转换成 px
            val width = resolveSize(dpToPx(defaultSize), widthMeasureSpec)
            val height = resolveSize(dpToPx(defaultSize), heightMeasureSpec)
            setMeasuredDimension(width, height)
        }
    
        override fun onDraw(canvas: Canvas) {
            super.onDraw(canvas) // 绘制背景等
    
            shapeDrawable?.let { drawable ->
                // 设置 Drawable 的绘制边界,通常是 View 的内容区域
                val availableWidth = width - paddingLeft - paddingRight
                val availableHeight = height - paddingTop - paddingBottom
                drawable.setBounds(paddingLeft, paddingTop, paddingLeft + availableWidth, paddingTop + availableHeight)
    
                // 应用变换
                val saveCount = canvas.save()
                canvas.concat(transformMatrix)
    
                // 绘制 Drawable
                drawable.draw(canvas)
    
                // 恢复 Canvas
                canvas.restoreToCount(saveCount)
            }
        }
    
        private fun dpToPx(dp: Int): Int {
            return (dp * resources.displayMetrics.density + 0.5f).toInt()
        }
    }
    
  2. 在 XML 中使用:

    <com.yourpackage.ShapeDrawingView
        android:id="@+id/shapeView"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_gravity="center"
        />
        <!-- 注意:这里没有 src 属性,drawable 通常在代码中设置或通过自定义属性 -->
    
  3. 在代码中设置 Matrix 和(如果需要) Drawable:

    val shapeView = findViewById<ShapeDrawingView>(R.id.shapeView)
    
    // (可选) 如果没在 init 中设置或想更换 Drawable
    // val myShapeDrawable = ... // 创建或获取 ShapeDrawable
    // shapeView.setDrawable(myShapeDrawable)
    
    val m = Matrix()
    // 设置镜像变换
    val viewCenterX = shapeView.width / 2f
    val viewCenterY = shapeView.height / 2f
    m.setScale(-1f, 1f, viewCenterX, viewCenterY)
    
    shapeView.setTransformMatrix(m)
    

优点:

  • 更轻量,没有 ImageView 那么多特性和开销(如果你不需要的话)。
  • 完全掌控绘制逻辑。

缺点:

  • 失去了 ImageView 内置的 scaleType 等方便的功能。你需要自己处理 Drawable 的定位和尺寸适应(虽然对于 ShapeDrawable 来说,它本来就是填充 bounds 的)。
  • 需要自己处理 onMeasure 来确定 View 的尺寸,特别是 wrap_content 的情况。

进阶技巧:

  • 在这种方式下,ShapeDrawable 仍然是根据 setBounds 来决定其基础绘制区域的。Canvas 的变换发生在 Drawable.draw() 调用之前,效果上是改变了 Drawable 绘制时所处的坐标环境。
  • 可以扩展这个自定义 View,添加更多控制属性,比如直接提供 setScaleX, setRotation, setTranslateX 等方法来内部维护 transformMatrix

方案选择和考量

那么,这三种方案该选哪个呢?

  • 想最快解决 setImageMatrixShapeDrawable 不生效的问题,同时还想继续使用 ImageView
    优选 方案一:转为 BitmapDrawable 。这是最“对症下药”的办法,让 Drawable 变得能配合 setImageMatrix。注意内存开销。

  • 需要在 ImageView 上应用变换,但不想(或不能方便地)转换 Drawable,并且希望保留 ImageView 的大部分功能(比如 scaleType 参与计算)?
    考虑 方案二:自定义 ImageView 重写 onDraw 。它提供了在 ImageView 绘制流程中插入自定义变换的能力,相对灵活。

  • 需求仅仅是显示一个经过变换的形状,ImageView 的额外功能(如图层、复杂的 scaleType)并非必需?
    方案三:自定义 View 可能更简洁高效。代码更直接,没有 ImageView 的中间层。

性能方面,方案一的主要成本在于 Bitmap 的创建和内存占用。方案二和三的成本主要在 onDraw 的计算,只要 onDraw 里的逻辑不复杂、不频繁创建对象,性能通常很好。动画场景下,方案二和三通过更新 Matrixinvalidate() 是常见的做法。

希望以上分析和方案能帮你解决 ImageViewMatrix 的这点小“误会”,让你的图片变换随心所欲动起来!