简单实现手势缩放version 1

1,062 阅读6分钟

效果:

飞书20221004-000201.gif

一、目标:构造一个用于展示图片的控件,且可以手势缩放。

二、类图:继承ImageView

classDiagram
ImageView <|-- GestureImageView
onScaleGestureListener <|.. GestureImageView
onGlobalLayoutListener <|.. GestureImageView

class GestureImageView{
-ScaleGestureDetector scaleGestureDetector
-Matrix gestureMatrix
-FloatArray[] matrixValueArr
-float stdScale
-int MAX_SCALE
-int MIN_SCALE

-getScale() float
-getMatrixRectf() RectF
-adjustBorderWhenScale()
}

class onScaleGestureListener {
    <<interface>>
    +onScale(ScaleGestureDetector detector)
    +onScaleBegin(ScaleGestureDetector detector)
    +onScaleEnd(ScaleGestureDetector detector)
}

class onGlobalLayoutListener {
<<interface>>
    +onGlobalLayout()
}

三、思路

3.1 放缩思路 (使用 Matrix )

这里的 Matrix 并不是指图片矩阵,而是操作变换矩阵,怎么理解呢? 这个 Matrix 是个 3x3 的三维矩阵,这 3x3 的各个值的意义如下图: 我们只需要关注 Scale(缩放)、 Trans(平移)即可。我们可以通过操作这矩阵上的几个值来对图片进行缩放、平移、错切等操作。 image.png 关于Matrix更详细的内容看如下链接: Matrix简介

3.2 图片初始位置调整

要使用 Matrix,就要把当前 View 的 scaleType 设置为 ScaleType.Matrix
可以在xml上设置:
android:scaleType="matrix"
也可以在代码中设置:

mImageView.scaleType = ImageView.ScaleType.MATRIX
mImageView.imageMatrix = Matrix()

但是 ScaleType 为 ScaleType.Matrix 的效果是:保证图片的大小,超出图片的部分不展示。图片放置的起点,是左上角。

因此要对图片进行初始位置调整,使得它居中显示。那什么时候进行调整呢,最好肯定是在绘制前就把它调整好,那么这时候就用到了

ViewTreeObserver#OnGlobalLayoutListener#onGlobalLayout

这个接口会在布局发现变化时调用,「一个view的有效requestLayout,会导致触发onGlobalLayout」 因此在 我们的 View 初始化的时候,会调用一次,然后我们在 onGlobalLayout 里编写图片初始位置的调整逻辑即可:

步骤一:算出图片的尺寸

val dw = drawable.intrinsicWidth
val dh = drawable.intrinsicHeight

为什么使用 Drawable#getInstrinsicWidth 和 Drawable#getInstrinsicHeight 来获取图片的宽度和高度详看此链接

步骤二:计算出图片缩放比例

当设置了 ImageView的ScaleType为 ImageView.ScaleType.MATRIX 后,图片展示在我们的ImageView中往往出现以下三种异常情况:(蓝色: ImageView,黄色:展示的图片)

image.png 当面对这三种异常情况时,我们就必须算出图片的缩放比例Scale,然后对图片进行缩放,以达到图片能在ImageView的中间展示的目的。
代码如下:

val dw = drawable.intrinsicWidth
val dh = drawable.intrinsicHeight

var scale = 1.0f
if (dw > width && dh <= height) { // 当图片宽度超出ImageView宽度,但图片高度不超出
    scale = width * 1.0f / dw
} else if (dh > height && dw <= width) { // 当图片高度超出ImageView高度,但图片宽度不超出
    scale = height * 1.0f / dh
} else if (dw > width && dh > height) { // 当图片宽度和高度都超出ImageView
    scale = 1 / max(dw * 1.0f / width, dh * 1.0f / height)
}

gestureMatrix.postTranslate((width - dw) / 2f, (height - dh) / 2f) // 让图片在ImageView居中的位置被放大
gestureMatrix.postScale(scale, scale, width / 2f, height / 2f) // 用计算出的 Scale 进行缩放
imageMatrix = gestureMatrix // 把带有缩放、平移操作的矩阵应用到ImageVIew中

整体 onGlobalLayout 的代码:

override fun onGlobalLayout() {
    // 调整只调整一次 ,否则多次利用计算出来的scale来放缩是不合理的
    this.viewTreeObserver.removeOnGlobalLayoutListener(this)
    drawable ?: return
    val dw = drawable.intrinsicWidth
    val dh = drawable.intrinsicHeight

    var scale = 1.0f
    if (dw > width && dh <= height) {
        scale = width * 1.0f / dw
    } else if (dh > height && dw <= width) {
        scale = height * 1.0f / dh
    } else if (dw > width && dh > height) {
        scale = 1 / max(dw * 1.0f / width, dh * 1.0f / height)
    }

    gestureMatrix.postTranslate((width - dw) / 2f, (height - dh) / 2f)
    gestureMatrix.postScale(scale, scale, width / 2f, height / 2f)
    imageMatrix = gestureMatrix

    gestureMatrix.getValues(matrixValueArr)
    stdScale = matrixValueArr[Matrix.MSCALE_X] // 标准比例:后面有用,会讲
}

gestureMatrix 是 Matrix 的对象,用于表示当前图片的操作矩阵。

3.3 手势放缩

3.3.1 得到已有的放缩比例

手势放缩其实原生已经做了一套接口了,我们只需要实现 ScaleGestureDetector#OnScaleGestureListener的接口即可,主要是其中的 onScale 方法。 在缩放前,我们要定义2个float变量 MAX_SCALE 和 MIN_SCALE, 从名字上来看,就知道是代表着最大缩放比例和最小缩放比例。例如 MIN_SCALE = 0.5 即表示图片缩放不能小于图片初始尺寸的0.5倍。
而当前图片被缩放的比例是放在 gestureMatrix 中的,因此一般来说这样即可:

private fun getScale(): Float {
    gestureMatrix.getValues(matrixValueArr) // 把 3x3 矩阵的值放到 1x9 的列表中
    return matrixValueArr[Matrix.MSCALE_X] // 获取 gestureMatrix 中代表放缩的比例
}

但是这样拿到的比例(例如得到0.5)真的可以直接用吗? 答案是不行的,因为这里的 0.5 是表示跟图片的初始状态(下左)对比缩小了 0.5 倍,但是我们知道的是,我们在 onGlobalLayout的时候是进行过一次大小和位置调整的(本文章的 3.2 章节), 经过调整后,左下才会变成居中(下右):

而我们设置的 MIN_SCALE、 MAX_SCALE 的倍数是针对变成居中后的。因此我们获取到 gestureMatrix 的缩放比例后,还要除「标准比例:stdScale」 ,这个 stdScale 在上面 3.2#步骤二 中,在 onGlobalLayout 中有计算得到。因此获取比例的代码为:

private fun getScale(): Float {
    gestureMatrix.getValues(matrixValueArr)
    return matrixValueArr[Matrix.MSCALE_X] / stdScale // 除标准比例
}

3.3.2 通过伸缩比率设置新的缩放比例

当进行手势缩放时,系统会多次回调 ScaleGestureListener#onScale 方法。 onScale 方法的签名是:

public boolean onScale(ScaleGestureDetector detector);

我们可以通过 ScaleGestureDetector#getScaleFactor 获取伸缩比率:scaleFactor。
scaleFactor 其实等于 当前手势的两个触点之间的距离 / 上一次手势的两个触点之间的距离。通俗地说,若现在的手势说放大图片手势,即手指往外滑动,那么 onScale 中返回的 ScaleFactor 的值会大于1.0,反之则会小于1.0,这个值基本在 0~1.5 之间。然后我们可以通过:

newScale = oldScale * scaleFactor

来获得新的放缩比例。(oldScale即在 gestureMatrix 中获取的 Scale)
gestureMatrix.getValues(matrixValueArr)
val scale = matrixValueArr[Matrix.MSCALE_X] / stdScale // 获取老的 Scale

var scaleFactor = scaleGestureDetector?.scaleFactor  // 获取此次滑动的伸缩比率
scaleFactor?.also {
    if ((it > 1.0f && scale < MAX_SCALE) || (scale > MIN_SCALE)) {
        if ( it * scale < MIN_SCALE) {   // 若计算出的新的 Scale 小于 MIN_SCALE
            scaleFactor = MIN_SCALE / scale 
        } else if ( it * scale > MAX_SCALE) {   // 若计算出的新的 Scale 大于 MAX_SCALE
            scaleFactor = MAX_SCALE / scale
        }

        gestureMatrix.postScale(  // 设置新的 Scale
            scaleFactor!!,
            scaleFactor!!,
            scaleGestureDetector?.focusX ?: width / 2f, // 执行放缩中心的 X 坐标
            scaleGestureDetector?.focusY ?: height / 2f
        )
        imageMatrix = gestureMatrix
    }
}

上面的代码是设置新的放缩率。但这就做完了吗?如果只做到这一步就停止的话,那么你会发现在放缩过程中,图片的位置会变来变去。如下:

要解决这问题,就需要额外做一次位置回归。

3.3.3 边界位置回归

这一个步骤是让放缩后的图片能在 ImageView 中居中显示。(若放大后的图片宽和高都超过 ImageView 则不需要居中)。居中步骤如下A、B、C:

image.png 当图片放大的时候,若宽高大于ImageView 时不居中,但宽或者高小于ImageView 时居中,则可以达到能放大特定区域,且缩小时,能让图片缩小回原本居中的位置。

3.3.3.1 用 RectF 表征放缩后的图片边界并挪到ImageView的左上角(A->B)

此步过程分为:

  1. 用图片的原始宽高和初始位置(左上角)来初始化 RectF
  2. 得到的 rectF 应用 gestureMatrix ,得到放缩变换后的 RectF(即边界)
private fun getMatrixRectF(): RectF {
    val matrix = gestureMatrix
    val rect = RectF()
    drawable?.also {
        rect.set(0f, 0f, it.intrinsicWidth.toFloat(), it.intrinsicHeight.toFloat())
        matrix.mapRect(rect)
    }
    return rect
}

3.3.3.2 在ImageView中居中显示

private fun adjustBorderWhenScale() {
    val rect = getMatrixRectF()
    var deltaX = 0f
    var deltaY = 0f

    if (rect.width() >= width) {
        if (rect.left > 0) {
            deltaX = -rect.left
        }

        if (rect.right < width) {
            deltaX = width - rect.right
        }
    }

    if (rect.height() >= height) {
        if (rect.top > 0) {
            deltaY = -rect.top
        }

        if (rect.bottom < height) {
            deltaY = height - rect.bottom
        }
    }

    if (rect.width() < width) {
        deltaX = width * 0.5f - rect.right + 0.5f * rect.width()
    }

    if (rect.height() < height) {
        deltaY = height * 0.5f - rect.bottom + 0.5f * rect.height();
    }

    gestureMatrix.postTranslate(deltaX, deltaY)
}

整体代码: GestureImageView

用这个 GestureImageView 当ImageView 使用,就可以实现放缩功能。

import android.content.Context
import android.graphics.Matrix
import android.graphics.RectF
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.ViewTreeObserver
import android.widget.ImageView
import androidx.appcompat.widget.AppCompatImageView
import kotlin.math.max

class GestureImageView(context: Context, attrs: AttributeSet) : AppCompatImageView(context, attrs),
    ScaleGestureDetector.OnScaleGestureListener, ViewTreeObserver.OnGlobalLayoutListener {

    companion object {
        const val TAG = "GestureImageView"
        const val MIN_SCALE = 0.5f
        const val MAX_SCALE = 4.0F
    }

    private var scaleGestureDetector: ScaleGestureDetector? = null
    private var gestureMatrix = Matrix() // 表示操作的矩阵
    private val matrixValueArr = FloatArray(9)

    var stdScale = 0f

    private var once: Boolean = true
    private var initScale = 1.0f

    init {
        scaleType = ImageView.ScaleType.MATRIX
        scaleGestureDetector = ScaleGestureDetector(this.context, this)
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        viewTreeObserver.addOnGlobalLayoutListener(this)
    }

    override fun onScale(scaleGestureDetector: ScaleGestureDetector?): Boolean {
        val scale = getScale()
        var scaleFactor = scaleGestureDetector?.scaleFactor
        scaleFactor?.also {
            if ((it > 1.0f && scale < MAX_SCALE) || (scale > MIN_SCALE)) {
                if ( it * scale < MIN_SCALE) {
                    scaleFactor = MIN_SCALE / scale
                } else if ( it * scale > MAX_SCALE) {
                    scaleFactor = MAX_SCALE / scale
                }

                gestureMatrix.postScale(
                    scaleFactor!!,
                    scaleFactor!!,
                    scaleGestureDetector?.focusX ?: width / 2f,
                    scaleGestureDetector?.focusY ?: height / 2f
                )
                adjustBorderWhenScale()
                imageMatrix = gestureMatrix
            }
        }
        return true
    }

    override fun onScaleBegin(scaleGestureDetector: ScaleGestureDetector?): Boolean {
        return true
    }

    override fun onScaleEnd(scaleGestureDetector: ScaleGestureDetector?) {
    }

    private fun getScale(): Float {
        gestureMatrix.getValues(matrixValueArr)
        return matrixValueArr[Matrix.MSCALE_X] / stdScale
    }

    override fun onGlobalLayout() {
        this.viewTreeObserver.removeOnGlobalLayoutListener(this)
        drawable ?: return
        val dw = drawable.intrinsicWidth
        val dh = drawable.intrinsicHeight

        var scale = 1.0f
        if (dw > width && dh <= height) {
            scale = width * 1.0f / dw
        } else if (dh > height && dw <= width) {
            scale = height * 1.0f / dh
        } else if (dw > width && dh > height) {
            scale = 1 / max(dw * 1.0f / width, dh * 1.0f / height)
        }

        gestureMatrix.postTranslate((width - dw) / 2f, (height - dh) / 2f)
        gestureMatrix.postScale(scale, scale, width / 2f, height / 2f)
        imageMatrix = gestureMatrix

        gestureMatrix.getValues(matrixValueArr)
        stdScale = matrixValueArr[Matrix.MSCALE_X]
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        return scaleGestureDetector?.onTouchEvent(event) ?: super.onTouchEvent(event)
    }

    private fun adjustBorderWhenScale() {
        val rect = getMatrixRectF()
        var deltaX = 0f
        var deltaY = 0f

        if (rect.width() >= width) {
            if (rect.left > 0) {
                deltaX = -rect.left
            }

            if (rect.right < width) {
                deltaX = width - rect.right
            }
        }

        if (rect.height() >= height) {
            if (rect.top > 0) {
                deltaY = -rect.top
            }

            if (rect.bottom < height) {
                deltaY = height - rect.bottom
            }
        }

        if (rect.width() < width) {
            deltaX = width * 0.5f - rect.right + 0.5f * rect.width()
        }

        if (rect.height() < height) {
            deltaY = height * 0.5f - rect.bottom + 0.5f * rect.height();
        }

        gestureMatrix.postTranslate(deltaX, deltaY)
    }

    private fun getMatrixRectF(): RectF {
        val matrix = gestureMatrix
        val rect = RectF()
        drawable?.also {
            rect.set(0f, 0f, it.intrinsicWidth.toFloat(), it.intrinsicHeight.toFloat())
            matrix.mapRect(rect)
        }
        return rect
    }
}