效果:
一、目标:构造一个用于展示图片的控件,且可以手势缩放。
二、类图:继承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(平移)即可。我们可以通过操作这矩阵上的几个值来对图片进行缩放、平移、错切等操作。
关于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,黄色:展示的图片)
当面对这三种异常情况时,我们就必须算出图片的缩放比例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:
当图片放大的时候,若宽高大于ImageView 时不居中,但宽或者高小于ImageView 时居中,则可以达到能放大特定区域,且缩小时,能让图片缩小回原本居中的位置。
3.3.3.1 用 RectF 表征放缩后的图片边界并挪到ImageView的左上角(A->B)
此步过程分为:
- 用图片的原始宽高和初始位置(左上角)来初始化 RectF
- 得到的 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
}
}