本文作者
作者:dafay
链接:
https://www.dafaycoding.com/article/android-basic-zoomimageview
本文由作者授权发布。
ZoomImageView 是一个自定义的 ImageView 控件,用于实现对图片的手势缩放、双击缩放以及放大后的平移查看等功能。在我之前的 MeetPhoto 项目中,图片预览功能使用了一个开源 ZoomImageView 控件(这个控件基于 PhotoView 实现,因其代码量较少而选择它)。但我发现这个控件在某些方面的用户体验并不理想,所以我决定对其进行优化,便是这篇博客的由来。
https://github.com/idea007/MeetPhoto
https://github.com/tenthbitinc/ZoomImageView?tab=readme-ov-file
https://github.com/Baseflow/PhotoView
问题二:双击缩放到最小动画不自然
思路
/**
* 实现功能
* 1. scaleType = MATRIX
* 1. 图片双击放大或缩小,点击点为缩放中心点(支点)
* 2. 拖动
*/
class Zoom01ImageView @kotlin.jvm.JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
private val gestureDetector: GestureDetector
private val originMatrix = Matrix() // 默认单位矩阵
private val suppMatrix = Matrix() // 默认单位矩阵
private var minZoom = DEFAULT_MIN_ZOOM
private var maxZoom = DEFAULT_MAX_ZOOM
private var zoomAnim = ValueAnimator().apply { duration = DEFAULT_ANIM_DURATION }
private val pivotPointF = PointF(0f, 0f)
init {
scaleType = ScaleType.MATRIX
gestureDetector = GestureDetector(context, MySimpleOnGestureListener())
setOnTouchListener(object : OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(event)
}
})
}
/**
* 处理双击事件,双击执行缩放动画
*/
private fun dealOnDoubleTap(e: MotionEvent) {
zoomAnim.removeAllUpdateListeners()
zoomAnim.cancel()
// 点击的点设置为缩放的中心点
pivotPointF.set(e.x, e.y)
val animatorUpdateListener = object : ValueAnimator.AnimatorUpdateListener {
override fun onAnimationUpdate(animation: ValueAnimator) {
val tempValue = animation.animatedValue as Float
suppMatrix.zoomTo(tempValue, pivotPointF.x, pivotPointF.y)
applyToImageMatrix()
}
}
val currZoom = suppMatrix.scaleX()
val endZoom = if (Math.abs(currZoom - maxZoom) > Math.abs(currZoom - minZoom)) maxZoom else minZoom
zoomAnim.setFloatValues(currZoom, endZoom)
zoomAnim.addUpdateListener(animatorUpdateListener)
zoomAnim.start()
}
/**
* 处理拖动(平移)事件
*/
private fun dealOnScroll(distanceX: Float, distanceY: Float) {
suppMatrix.translateBy(-distanceX, -distanceY)
applyToImageMatrix()
}
/**
* 应用于 ImageView 的 matrix,为了思路清晰,这里先频繁创建对象 drawMatrix
*/
fun applyToImageMatrix() {
val drawMatrix = Matrix()
drawMatrix.set(originMatrix)
// drawMatrix = suppMatrix * originMatrix
drawMatrix.postConcat(suppMatrix)
imageMatrix = drawMatrix
}
inner class MySimpleOnGestureListener : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean {
// 返回 true,GestureDetector 一系列手势才能响应
return true
}
override fun onDoubleTap(e: MotionEvent): Boolean {
dealOnDoubleTap(e)
return super.onDoubleTap(e)
}
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
dealOnScroll(distanceX, distanceY)
return super.onScroll(e1, e2, distanceX, distanceY)
}
}
}
// 为了思路清晰,这里数组没有提取处理
fun Matrix.scaleX(): Float {
val values = FloatArray(9)
this.getValues(values)
return values[Matrix.MSCALE_X]
}
fun Matrix.scaleY(): Float {
val values = FloatArray(9)
this.getValues(values)
return values[Matrix.MSCALE_Y]
}
fun Matrix.translateBy(dx: Float, dy: Float) {
this.postTranslate(dx, dy)
}
fun Matrix.translateTo(x: Float, y: Float) {
this.postTranslate(-this.transX() + x, -this.transY() + y)
}
fun Matrix.zoomBy(factor: Float, pivotX: Float, pivotY: Float) {
this.postScale(factor, factor, pivotX, pivotY)
}
fun Matrix.zoomTo(zoom: Float, pivotX: Float, pivotY: Float) {
this.postScale(zoom / this.scaleX(), zoom / this.scaleY(), pivotX, pivotY)
}
/**
* 实现功能
* 1. 支持双指缩放
* 问题:
* 1. scaleGestureDetector、gestureDetector 响应
* 2. 可添加 onScroll、onScale 的触发 scaledTouchSlop 限定
*/
class Zoom02ImageView @kotlin.jvm.JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
// 手势检测器
private val gestureDetector: GestureDetector
private val scaleGestureDetector: ScaleGestureDetector
...
init {
// scaleType 图像边界缩放到此视图边界的选项,MATRIX 绘图时使用图像矩阵进行缩放
scaleType = ScaleType.MATRIX
val multiGestureDetector = MultiGestureDetector()
gestureDetector = GestureDetector(context, multiGestureDetector)
scaleGestureDetector = ScaleGestureDetector(context, multiGestureDetector)
setOnTouchListener(object : View.OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean {
if (gestureDetector.onTouchEvent(event)) {
return true
}
return scaleGestureDetector.onTouchEvent(event)
}
})
}
...
/**
* 处理双指缩放事件
*/
private fun dealOnScale(detector: ScaleGestureDetector) {
val currScale: Float = suppMatrix.scaleX()
var scaleFactor = detector.scaleFactor
if ((currScale >= maxZoom && scaleFactor > 1f) || (currScale <= minZoom && scaleFactor < 1f)) {
return
}
suppMatrix.zoomBy(scaleFactor, detector.focusX, detector.focusY)
applyToImageMatrix()
}
...
/**
* 同时实现 SimpleOnGestureListener、OnScaleGestureListener 接口
*/
inner class MultiGestureDetector : GestureDetector.SimpleOnGestureListener(),
ScaleGestureDetector.OnScaleGestureListener {
override fun onDoubleTap(e: MotionEvent): Boolean {
dealOnDoubleTap(e)
return super.onDoubleTap(e)
}
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
dealOnScroll(distanceX, distanceY)
return super.onScroll(e1, e2, distanceX, distanceY)
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
dealOnScale(detector)
return true
}
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
}
}
}
/**
* 实现功能
* 1. 初始化处理
* 问题:
* 1. 同一张图片的高清、低清切换的问题(图片宽高比一样),例如执行放大过程中切换
*/
class Zoom03ImageView @kotlin.jvm.JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
// 手势检测器
private val gestureDetector: GestureDetector
private val scaleGestureDetector: ScaleGestureDetector
// 默认类似 fitCenter 显示效果时的矩阵
private val originMatrix = Matrix()
private val suppMatrix = Matrix()
private var minZoom = DEFAULT_MIN_ZOOM
private var maxZoom = DEFAULT_MAX_ZOOM
private var zoomAnim = ValueAnimator().apply { duration = DEFAULT_ANIM_DURATION }
private val pivotPointF = PointF(0f, 0f)
init {
val multiGestureDetector = MultiGestureDetector()
gestureDetector = GestureDetector(context, multiGestureDetector)
scaleGestureDetector = ScaleGestureDetector(context, multiGestureDetector)
setOnTouchListener(object : View.OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean {
if (gestureDetector.onTouchEvent(event)) {
return true
}
return scaleGestureDetector.onTouchEvent(event)
}
})
}
override fun setImageDrawable(drawable: Drawable?) {
super.setImageDrawable(drawable)
updateOriginMatrix(drawable)
}
override fun setImageBitmap(bm: Bitmap?) {
super.setImageBitmap(bm)
updateOriginMatrix(drawable)
}
override fun setImageResource(resId: Int) {
super.setImageResource(resId)
updateOriginMatrix(drawable)
}
override fun setImageURI(uri: Uri?) {
super.setImageURI(uri)
updateOriginMatrix(drawable)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updateOriginMatrix(drawable)
}
/**
* 计算图片显示类似 fitCenter 效果时的矩阵(忽视 pading,只处理 fitCenter 这一种显示模式)
*/
private fun updateOriginMatrix(drawable: Drawable?) {
drawable ?: return
if (width <= 0) {
return
}
val viewWidth = width.toFloat()
val viewHeight = height.toFloat()
val drawableWidth = drawable.intrinsicWidth
val drawableHeight = drawable.intrinsicHeight
originMatrix.reset()
val tempSrc = RectF(0f, 0f, drawableWidth.toFloat(), drawableHeight.toFloat())
val tempDst = RectF(0f, 0f, viewWidth, viewHeight)
originMatrix.setRectToRect(tempSrc, tempDst, Matrix.ScaleToFit.CENTER)
applyToImageMatrix()
}
...
}
/**
* 实现功能
* 边界处理,左上右下移动超出边界时进行矫正
*/
class Zoom04ImageView @kotlin.jvm.JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
...
/**
* 在显示之前,进行边界矫正,对 suppMatrix 进行调整
*/
private fun correctSuppMatrix() {
// 目标 matrix
val tempMatrix = Matrix(originMatrix).apply { postConcat(suppMatrix) }
// 得到 matrix 的 rect
val tempRectF = getDrawMatrixRect(tempMatrix)
tempRectF ?: return
var deltaX = 0f
var deltaY = 0f
if (tempRectF.height() < height) {
deltaY = ((height - tempRectF.height()) / 2) - tempRectF.top
} else if (tempRectF.top > 0) {
deltaY = -tempRectF.top
} else if (tempRectF.bottom < height) {
deltaY = height - tempRectF.bottom
}
if (tempRectF.width() <= width) {
deltaX = ((width - tempRectF.width()) / 2) - tempRectF.left
} else if (tempRectF.left > 0) {
deltaX = -tempRectF.left
} else if (tempRectF.right < width) {
deltaX = width - tempRectF.right
}
suppMatrix.translateBy(deltaX, deltaY)
}
private fun getDrawMatrixRect(matrix: Matrix): RectF? {
val d = drawable
if (null != d) {
val tempRect = RectF()
// 什么新奇的写法
tempRect[0f, 0f, d.intrinsicWidth.toFloat()] = d.intrinsicHeight.toFloat()
matrix.mapRect(tempRect)
return tempRect
}
return null
}
/**
* 应用于 ImageView 的 matrix,为了思路清晰,这里先频繁创建对象 drawMatrix
*/
private fun applyToImageMatrix() {
// 在应用之前,进行边界矫正
correctSuppMatrix()
val drawMatrix = Matrix()
drawMatrix.set(originMatrix)
// drawMatrix = suppMatrix * originMatrix
drawMatrix.postConcat(suppMatrix)
debug("originMatrix:${originMatrix} suppMatrix:${suppMatrix} drawMatrix:${drawMatrix}")
imageMatrix = drawMatrix
}
...
}
图片放大可拖动时,快速滑动响应 onFling 事件,基于 Android Scroller 滚动特性进行处理。
https://www.dafaycoding.com/article/android-basic-scroller
/**
* 实现功能
* 快速滑动 fling 处理
*/
class Zoom05ImageView @kotlin.jvm.JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
...
// 用来执行 onFling 动画
private var overScroller: OverScroller
private var startTime: Long = 0
private val choreographer = Choreographer.getInstance()
private var currentX = 0
private var currentY = 0
init {
overScroller = OverScroller(context)
scaleType = ScaleType.MATRIX
val multiGestureDetector = MultiGestureDetector()
gestureDetector = GestureDetector(context, multiGestureDetector)
scaleGestureDetector = ScaleGestureDetector(context, multiGestureDetector)
setOnTouchListener(object : View.OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean {
if (gestureDetector.onTouchEvent(event)) {
return true
}
return scaleGestureDetector.onTouchEvent(event)
}
})
}
...
private fun dealOnFling(e2: MotionEvent, velocityX: Float, velocityY: Float) {
val rect = getDrawMatrixRect(imageMatrix) ?: return
val startX = Math.round(-rect.left)
val minX: Int
val maxX: Int
val minY: Int
val maxY: Int
if (width < rect.width()) {
minX = 0
maxX = Math.round(rect.width() - width)
} else {
maxX = startX
minX = maxX
}
val startY = Math.round(-rect.top)
if (height < rect.height()) {
minY = 0
maxY = Math.round(rect.height() - height)
} else {
maxY = startY
minY = maxY
}
currentX = startX
currentY = startY
if (!((startX != maxX) || (startY != maxY))) {
return
}
overScroller.fling(startX, startY, -velocityX.toInt(), -velocityY.toInt(), minX, maxX, minY, maxY, 0, 0)
startFlingAnim()
}
private fun startFlingAnim() {
startTime = AnimationUtils.currentAnimationTimeMillis()
postNextFrame()
}
private fun postNextFrame() {
if (overScroller.computeScrollOffset()) {
val currX = overScroller.currX
val currY = overScroller.currY
suppMatrix.translateBy((currentX - currX).toFloat(), (currentY - currY).toFloat())
applyToImageMatrix()
currentX = currX
currentY = currY
choreographer.postFrameCallback {
postNextFrame()
}
}
}
...
inner class MultiGestureDetector : GestureDetector.SimpleOnGestureListener(),
ScaleGestureDetector.OnScaleGestureListener {
override fun onDoubleTap(e: MotionEvent): Boolean {
dealOnDoubleTap(e)
return super.onDoubleTap(e)
}
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
dealOnScroll(distanceX, distanceY)
return super.onScroll(e1, e2, distanceX, distanceY)
}
override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
dealOnFling(e2, velocityX, velocityY)
return super.onFling(e1, e2, velocityX, velocityY)
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
dealOnScale(detector)
return true
}
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
}
}
}
这样做的副作用很明显,我们已经计算出了可用来绘图的 Matrix,却又要反回去计算出 suppMatrix,导致额外的计算量,如果过程不优雅,那结果一定不优雅,这种实现思路并不好。
/**
* 实现功能
* 1. 解决双击缩放到最小过程中,动画过渡不自然的问题(边界矫正太过生硬),
* 解决思路:缩放动画,起始 matrix 和 终止 matrix 过程中,povit 支点也要随着动画进度做改变
*/
class Zoom06ImageView @kotlin.jvm.JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
...
/**
* 处理双击事件,双击执行缩放动画
*/
private fun dealOnDoubleTap(e: MotionEvent) {
playZoomAnim(e.x, e.y)
}
/**
* 改变动画实现方式
*/
fun playZoomAnim(pivotX: Float, pivotY: Float) {
zoomAnim.removeAllUpdateListeners()
zoomAnim.cancel()
// 点击的点设置为缩放的支点
pivotPointF.set(pivotX, pivotY)
val startZoom = suppMatrix.scaleX()
val endZoom = if (Math.abs(startZoom - maxZoom) > Math.abs(startZoom - minZoom)) maxZoom else minZoom
val startMatrix = Matrix(imageMatrix)
val endMatrix = Matrix(originMatrix).apply {
val tempSuppMatrix = Matrix(suppMatrix)
tempSuppMatrix.zoomTo(endZoom, pivotPointF.x, pivotPointF.y)
this.postConcat(tempSuppMatrix)
}
// 边界矫正
correctByViewBound(endMatrix).let {
endMatrix.translateBy(it.x, it.y)
}
val tmpPointArr = floatArrayOf(pivotX, pivotY)
MathUtils.computeNewPosition(tmpPointArr, imageMatrix, endMatrix)
val endPivotPointF = PointF(tmpPointArr[0], tmpPointArr[1])
val animatorUpdateListener = object : ValueAnimator.AnimatorUpdateListener {
override fun onAnimationUpdate(animation: ValueAnimator) {
val tempValue = animation.animatedValue as Float
val factor = (tempValue - startZoom) / (endZoom - startZoom)
debug("playZoomAnim factor=${factor}")
val currMatrix = MathUtils.interpolate(
startMatrix,
pivotPointF.x,
pivotPointF.y,
endMatrix,
endPivotPointF.x,
endPivotPointF.y,
factor
)
// suppMatrix * originMatrix = currMatrix; suppMatrix = currMatrix *(originMatrix 的逆矩阵)
val tmpMatrix = Matrix()
originMatrix.invert(tmpMatrix)
tmpMatrix.postConcat(currMatrix)
suppMatrix.set(tmpMatrix)
applyToImageMatrix(true)
}
}
zoomAnim.setFloatValues(startZoom, endZoom)
zoomAnim.addUpdateListener(animatorUpdateListener)
zoomAnim.start()
}
...
/**
* 对输入矩阵,依据 View 宽高进行调整,输出需要调整的平移量
*/
private fun correctByViewBound(srcMatrix: Matrix): PointF {
val tempPointF = PointF()
// 得到 matrix 的 rect
val tempRectF = getDrawMatrixRect(srcMatrix)
tempRectF ?: return tempPointF
var deltaX = 0f
var deltaY = 0f
if (tempRectF.height() < height) {
deltaY = ((height - tempRectF.height()) / 2) - tempRectF.top
} else if (tempRectF.top > 0) {
deltaY = -tempRectF.top
} else if (tempRectF.bottom < height) {
deltaY = height - tempRectF.bottom
}
if (tempRectF.width() <= width) {
deltaX = ((width - tempRectF.width()) / 2) - tempRectF.left
} else if (tempRectF.left > 0) {
deltaX = -tempRectF.left
} else if (tempRectF.right < width) {
deltaX = width - tempRectF.right
}
tempPointF.set(deltaX, deltaY)
return tempPointF
}
private fun getDrawMatrixRect(matrix: Matrix): RectF? {
val d = drawable
if (null != d) {
val tempRect = RectF()
// 新奇的写法
tempRect[0f, 0f, d.intrinsicWidth.toFloat()] = d.intrinsicHeight.toFloat()
debug("getDrawMatrixRect tempRect=${tempRect}")
matrix.mapRect(tempRect)
debug("getDrawMatrixRect tempRect=${tempRect}")
return tempRect
}
return null
}
/**
* 在显示之前,进行边界矫正,对 suppMatrix 进行调整
*/
private fun correctSuppMatrix() {
// 目标 matrix
val tempMatrix = Matrix(originMatrix).apply { postConcat(suppMatrix) }
correctByViewBound(tempMatrix).let {
suppMatrix.translateBy(it.x, it.y)
}
}
/**
* 应用于 ImageView 的 matrix,为了思路清晰,这里先频繁创建对象 drawMatrix
*/
private fun applyToImageMatrix(skipCorrect: Boolean = false) {
if (!skipCorrect) {
// 在应用之前,进行边界矫正
correctSuppMatrix()
}
val drawMatrix = Matrix()
drawMatrix.set(originMatrix)
// 即当前 Matrix 会乘以传入的 Matrix。 suppMatrix*originMatrix
drawMatrix.postConcat(suppMatrix)
imageMatrix = drawMatrix
}
...
}
/**
* 实现功能
* over zoom 处理(< minZoom)
*/
class Zoom07ImageView @kotlin.jvm.JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
...
private var overMinZoomRadio = 0.75f
init {
overScroller = OverScroller(context)
scaleType = ScaleType.MATRIX
val multiGestureDetector = MultiGestureDetector()
gestureDetector = GestureDetector(context, multiGestureDetector)
scaleGestureDetector = ScaleGestureDetector(context, multiGestureDetector)
setOnTouchListener(object : View.OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean {
if (gestureDetector.onTouchEvent(event)) {
return true
}
scaleGestureDetector.onTouchEvent(event)
if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMasked() == MotionEvent.ACTION_CANCEL) {
dealUpOrCancel(event)
}
return true
}
})
}
...
/**
* up or cancel 事件时执行还原缩放动画
*/
private fun dealUpOrCancel(event: MotionEvent) {
val currZoom = suppMatrix.scaleX()
if (currZoom < minZoom) {
val rect = getDrawMatrixRect(imageMatrix)
rect?.let { playZoomAnim(currZoom, minZoom, it.centerX(), it.centerY()) }
}
}
private fun playZoomAnim(startZoom: Float, endZoom: Float, pivotX: Float, pivotY: Float) {
zoomAnim.removeAllUpdateListeners()
zoomAnim.cancel()
// 点击的点设置为缩放的支点
pivotPointF.set(pivotX, pivotY)
val startMatrix = Matrix(imageMatrix)
val endMatrix = Matrix(originMatrix).apply {
val tempSuppMatrix = Matrix(suppMatrix)
tempSuppMatrix.zoomTo(endZoom, pivotPointF.x, pivotPointF.y)
this.postConcat(tempSuppMatrix)
}
// 边界矫正
correctByViewBound(endMatrix).let {
endMatrix.translateBy(it.x, it.y)
}
val tmpPointArr = floatArrayOf(pivotX, pivotY)
MathUtils.computeNewPosition(tmpPointArr, imageMatrix, endMatrix)
val endPivotPointF = PointF(tmpPointArr[0], tmpPointArr[1])
val animatorUpdateListener = object : ValueAnimator.AnimatorUpdateListener {
override fun onAnimationUpdate(animation: ValueAnimator) {
val tempValue = animation.animatedValue as Float
val factor = (tempValue - startZoom) / (endZoom - startZoom)
val currMatrix = MathUtils.interpolate(
startMatrix,
pivotPointF.x,
pivotPointF.y,
endMatrix,
endPivotPointF.x,
endPivotPointF.y,
factor
)
// suppMatrix * originMatrix = currMatrix; suppMatrix = currMatrix *(originMatrix 的逆矩阵)
val tmpMatrix = Matrix()
originMatrix.invert(tmpMatrix)
tmpMatrix.postConcat(currMatrix)
suppMatrix.set(tmpMatrix)
applyToImageMatrix(true)
}
}
zoomAnim.setFloatValues(startZoom, endZoom)
zoomAnim.addUpdateListener(animatorUpdateListener)
zoomAnim.start()
}
...
/**
* 处理双指缩放
*/
private fun dealOnScale(detector: ScaleGestureDetector) {
val currScale: Float = suppMatrix.scaleX()
var scaleFactor = detector.scaleFactor
if ((currScale >= maxZoom && scaleFactor > 1f) || (currScale <= minZoom * overMinZoomRadio && scaleFactor < 1f)) {
return
}
suppMatrix.zoomBy(scaleFactor, detector.focusX, detector.focusY)
applyToImageMatrix()
}
...
}
/**
* 实现功能
* 嵌套冲突处理
*/
class Zoom08ImageView @kotlin.jvm.JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
// 手势检测器
private val gestureDetector: GestureDetector
private val scaleGestureDetector: ScaleGestureDetector
...
private var scrollEdge = Edge.EDGE_NONE
private var allowParentInterceptOnEdge = true
private var pointerCount = 0
var viewTapListener: OnViewTapListener? = null
init {
overScroller = OverScroller(context)
scaleType = ScaleType.MATRIX
val multiGestureDetector = MultiGestureDetector()
gestureDetector = GestureDetector(context, multiGestureDetector)
scaleGestureDetector = ScaleGestureDetector(context, multiGestureDetector)
setOnTouchListener(object : View.OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean {
if (gestureDetector.onTouchEvent(event)) {
return true
}
scaleGestureDetector.onTouchEvent(event)
pointerCount = event.pointerCount
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
parent?.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
dealUpOrCancel(event)
}
}
return true
}
})
}
...
/**
* 处理拖动(平移)事件
*/
private fun dealOnScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float) {
if (allowParentInterceptOnEdge && !scaleGestureDetector.isInProgress) {
if (pointerCount < 2) {
if (scrollEdge == Edge.EDGE_BOTH || (scrollEdge == Edge.EDGE_LEFT && -distanceX >= 1f) || (scrollEdge == Edge.EDGE_RIGHT && -distanceX <= -1f)) {
parent?.requestDisallowInterceptTouchEvent(false)
}
}
}
suppMatrix.translateBy(-distanceX, -distanceY)
applyToImageMatrix()
}
...
/**
* 对输入矩阵,依据 View 宽高进行调整,输出需要调整的平移量
*/
private fun correctByViewBound(srcMatrix: Matrix): PointF {
val tempPointF = PointF()
// 得到 matrix 的 rect
val tempRectF = getDrawMatrixRect(srcMatrix)
tempRectF ?: return tempPointF
var deltaX = 0f
var deltaY = 0f
if (tempRectF.height() < height) {
deltaY = ((height - tempRectF.height()) / 2) - tempRectF.top
} else if (tempRectF.top > 0) {
deltaY = -tempRectF.top
} else if (tempRectF.bottom < height) {
deltaY = height - tempRectF.bottom
}
if (tempRectF.width() <= width) {
deltaX = ((width - tempRectF.width()) / 2) - tempRectF.left
scrollEdge = Edge.EDGE_BOTH
} else if (tempRectF.left > 0) {
deltaX = -tempRectF.left
scrollEdge = Edge.EDGE_LEFT
} else if (tempRectF.right < width) {
deltaX = width - tempRectF.right
scrollEdge = Edge.EDGE_RIGHT
} else {
scrollEdge = Edge.EDGE_NONE
}
tempPointF.set(deltaX, deltaY)
return tempPointF
}
...
}
9. 规范代码,细节调优
class ZoomImageView @kotlin.jvm.JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
// 手势检测器
private val gestureDetector: GestureDetector
private val scaleGestureDetector: ScaleGestureDetector
// 默认类似 fitCenter 显示模式时的矩阵
private val originMatrix = Matrix()
private val suppMatrix = Matrix()
// 绘画前辅助计算用
private val tempMatrix = Matrix()
private var minZoom = DEFAULT_MIN_ZOOM
private var midZoom = DEFAULT_MID_ZOOM
private var maxZoom = DEFAULT_MAX_ZOOM
private var zoomAnim = ValueAnimator().apply { duration = DEFAULT_ANIM_DURATION }
private val pivotPointF = PointF(0f, 0f)
// 用来执行 onFling 动画
private var overScroller: OverScroller
private var startTime: Long = 0
private val choreographer = Choreographer.getInstance()
private var currentX = 0
private var currentY = 0
private var overMinZoomRadio = 0.75f
private var scrollEdge = Edge.EDGE_NONE
private var allowParentInterceptOnEdge = true
private var pointerCount = 0
var viewTapListener: OnViewTapListener? = null
init {
overScroller = OverScroller(context)
scaleType = ScaleType.MATRIX
val multiGestureDetector = MultiGestureDetector()
gestureDetector = GestureDetector(context, multiGestureDetector)
scaleGestureDetector = ScaleGestureDetector(context, multiGestureDetector)
setOnTouchListener(object : View.OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean {
if (gestureDetector.onTouchEvent(event)) {
return true
}
scaleGestureDetector.onTouchEvent(event)
pointerCount = event.pointerCount
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
parent?.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
dealUpOrCancel(event)
}
}
return true
}
})
}
override fun setImageDrawable(drawable: Drawable?) {
super.setImageDrawable(drawable)
updateOriginMatrix(drawable)
}
override fun setImageBitmap(bm: Bitmap?) {
super.setImageBitmap(bm)
updateOriginMatrix(drawable)
}
override fun setImageResource(resId: Int) {
super.setImageResource(resId)
updateOriginMatrix(drawable)
}
override fun setImageURI(uri: Uri?) {
super.setImageURI(uri)
updateOriginMatrix(drawable)
}
fun setMinZoom(minZoom: Float) {
checkZoomLevels(minZoom, midZoom, maxZoom)
this.minZoom = minZoom
}
fun setMidZoom(midZoom: Float) {
checkZoomLevels(minZoom, midZoom, maxZoom)
this.midZoom = midZoom
}
fun setMaxZoom(maxZoom: Float) {
checkZoomLevels(minZoom, midZoom, maxZoom)
this.maxZoom = maxZoom
}
private fun checkZoomLevels(minZoom: Float, midZoom: Float, maxZoom: Float) {
if (minZoom >= midZoom) {
throw IllegalArgumentException("MinZoom should be less than MidZoom")
} else if (midZoom >= maxZoom) {
throw IllegalArgumentException("MidZoom should be less than MaxZoom")
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val newHeight: Int = ScreenUtils.getFullScreenHeight()
super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(newHeight, MeasureSpec.EXACTLY))
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updateOriginMatrix(drawable)
}
/**
* 计算图片显示类似 fitCenter 效果时的矩阵(忽视 pading,只处理 fitCenter 这一种显示模式)
*/
private fun updateOriginMatrix(drawable: Drawable?) {
drawable ?: return
if (width <= 0) {
return
}
val viewWidth = width.toFloat()
val viewHeight = height.toFloat()
val drawableWidth = drawable.intrinsicWidth
val drawableHeight = drawable.intrinsicHeight
originMatrix.reset()
val tempSrc = RectF(0f, 0f, drawableWidth.toFloat(), drawableHeight.toFloat())
val tempDst = RectF(0f, 0f, viewWidth, viewHeight)
originMatrix.setRectToRect(tempSrc, tempDst, Matrix.ScaleToFit.CENTER)
applyToImageMatrix()
}
/**
* 处理双击事件,双击执行缩放动画
*/
private fun dealOnDoubleTap(e: MotionEvent) {
animZoom(e.x, e.y)
}
private fun animZoom(pivotX: Float, pivotY: Float) {
val currZoom = suppMatrix.scaleX()
val endZoom = if (currZoom < midZoom) {
midZoom
} else if (currZoom < maxZoom) {
maxZoom
} else {
minZoom
}
playZoomAnim(currZoom, endZoom, pivotX, pivotY)
}
private fun dealUpOrCancel(event: MotionEvent) {
val currZoom = suppMatrix.scaleX()
if (currZoom < minZoom) {
val rect = getDrawMatrixRect(imageMatrix)
rect?.let { playZoomAnim(currZoom, minZoom, it.centerX(), it.centerY()) }
}
}
private fun playZoomAnim(startZoom: Float, endZoom: Float, pivotX: Float, pivotY: Float) {
zoomAnim.removeAllUpdateListeners()
zoomAnim.cancel()
// 点击的点设置为缩放的支点
pivotPointF.set(pivotX, pivotY)
val startMatrix = Matrix(imageMatrix)
val endMatrix = Matrix(originMatrix).apply {
val tempSuppMatrix = Matrix(suppMatrix)
tempSuppMatrix.zoomTo(endZoom, pivotPointF.x, pivotPointF.y)
this.postConcat(tempSuppMatrix)
}
// 边界矫正
correctByViewBound(endMatrix).let {
endMatrix.translateBy(it.x, it.y)
}
val tmpPointArr = floatArrayOf(pivotX, pivotY)
MathUtils.computeNewPosition(tmpPointArr, imageMatrix, endMatrix)
val endPivotPointF = PointF(tmpPointArr[0], tmpPointArr[1])
val currMatrix = Matrix()
val animatorUpdateListener = object : ValueAnimator.AnimatorUpdateListener {
override fun onAnimationUpdate(animation: ValueAnimator) {
val tempValue = animation.animatedValue as Float
val factor = (tempValue - startZoom) / (endZoom - startZoom)
currMatrix.set(
MathUtils.interpolate(
startMatrix,
pivotPointF.x,
pivotPointF.y,
endMatrix,
endPivotPointF.x,
endPivotPointF.y,
factor
)
)
// suppMatrix * originMatrix = currMatrix; suppMatrix = currMatrix *(originMatrix 的逆矩阵)
tempMatrix.reset()
originMatrix.invert(tempMatrix)
tempMatrix.postConcat(currMatrix)
suppMatrix.set(tempMatrix)
applyToImageMatrix(true)
}
}
zoomAnim.setFloatValues(startZoom, endZoom)
zoomAnim.addUpdateListener(animatorUpdateListener)
zoomAnim.start()
}
private fun dealOnFling(e2: MotionEvent, velocityX: Float, velocityY: Float) {
val rect = getDrawMatrixRect(imageMatrix) ?: return
val startX = Math.round(-rect.left)
val minX: Int
val maxX: Int
val minY: Int
val maxY: Int
if (width < rect.width()) {
minX = 0
maxX = Math.round(rect.width() - width)
} else {
maxX = startX
minX = maxX
}
val startY = Math.round(-rect.top)
if (height < rect.height()) {
minY = 0
maxY = Math.round(rect.height() - height)
} else {
maxY = startY
minY = maxY
}
currentX = startX
currentY = startY
if (!((startX != maxX) || (startY != maxY))) {
return
}
overScroller.fling(startX, startY, -velocityX.toInt(), -velocityY.toInt(), minX, maxX, minY, maxY, 0, 0)
startFlingAnim()
}
private fun startFlingAnim() {
startTime = AnimationUtils.currentAnimationTimeMillis()
postNextFrame()
}
private fun postNextFrame() {
if (overScroller.computeScrollOffset()) {
val currX = overScroller.currX
val currY = overScroller.currY
suppMatrix.translateBy((currentX - currX).toFloat(), (currentY - currY).toFloat())
applyToImageMatrix()
currentX = currX
currentY = currY
choreographer.postFrameCallback {
postNextFrame()
}
}
}
/**
* 处理拖动(平移)事件
*/
private fun dealOnScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float) {
if (allowParentInterceptOnEdge && !scaleGestureDetector.isInProgress) {
if (pointerCount < 2) {
if (scrollEdge == Edge.EDGE_BOTH || (scrollEdge == Edge.EDGE_LEFT && -distanceX >= 1f) || (scrollEdge == Edge.EDGE_RIGHT && -distanceX <= -1f)) {
parent?.requestDisallowInterceptTouchEvent(false)
}
}
}
suppMatrix.translateBy(-distanceX, -distanceY)
applyToImageMatrix()
}
/**
* 处理双指缩放
*/
private fun dealOnScale(detector: ScaleGestureDetector) {
val currScale: Float = suppMatrix.scaleX()
var scaleFactor = detector.scaleFactor
if ((currScale >= maxZoom && scaleFactor > 1f) || (currScale <= minZoom * overMinZoomRadio && scaleFactor < 1f)) {
return
}
suppMatrix.zoomBy(scaleFactor, detector.focusX, detector.focusY)
applyToImageMatrix()
}
/**
* 对输入矩阵,依据 View 宽高进行调整,输出需要调整的平移量
*/
private fun correctByViewBound(srcMatrix: Matrix): PointF {
val tempPointF = PointF()
// 得到 matrix 的 rect
val tempRectF = getDrawMatrixRect(srcMatrix)
tempRectF ?: return tempPointF
var deltaX = 0f
var deltaY = 0f
if (tempRectF.height() < height) {
deltaY = ((height - tempRectF.height()) / 2) - tempRectF.top
} else if (tempRectF.top > 0) {
deltaY = -tempRectF.top
} else if (tempRectF.bottom < height) {
deltaY = height - tempRectF.bottom
}
if (tempRectF.width() <= width) {
deltaX = ((width - tempRectF.width()) / 2) - tempRectF.left
scrollEdge = Edge.EDGE_BOTH
} else if (tempRectF.left > 0) {
deltaX = -tempRectF.left
scrollEdge = Edge.EDGE_LEFT
} else if (tempRectF.right < width) {
deltaX = width - tempRectF.right
scrollEdge = Edge.EDGE_RIGHT
} else {
scrollEdge = Edge.EDGE_NONE
}
tempPointF.set(deltaX, deltaY)
return tempPointF
}
private fun getDrawMatrixRect(matrix: Matrix): RectF? {
val d = drawable
if (null != d) {
val tempRect = RectF()
// 新奇的写法
tempRect[0f, 0f, d.intrinsicWidth.toFloat()] = d.intrinsicHeight.toFloat()
matrix.mapRect(tempRect)
return tempRect
}
return null
}
/**
* 在显示之前,进行边界矫正,对 suppMatrix 进行调整
*/
private fun correctSuppMatrix() {
// 目标 matrix
tempMatrix.set(originMatrix)
tempMatrix.postConcat(suppMatrix)
correctByViewBound(tempMatrix).let {
suppMatrix.translateBy(it.x, it.y)
}
}
/**
* 应用于 ImageView 的 matrix,为了思路清晰,这里先频繁创建对象 drawMatrix
*/
private fun applyToImageMatrix(skipCorrect: Boolean = false) {
if (!skipCorrect) {
// 在应用之前,进行边界矫正
correctSuppMatrix()
}
tempMatrix.set(originMatrix)
// 即当前 Matrix 会乘以传入的 Matrix。 suppMatrix*originMatrix
tempMatrix.postConcat(suppMatrix)
imageMatrix = tempMatrix
}
inner class MultiGestureDetector : GestureDetector.SimpleOnGestureListener(),
ScaleGestureDetector.OnScaleGestureListener {
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
viewTapListener?.onViewTap(this@ZoomImageView, e.x, e.y)
return super.onSingleTapConfirmed(e)
}
override fun onLongPress(e: MotionEvent) {
super.onLongPress(e)
viewTapListener?.onLongClick(this@ZoomImageView)
}
override fun onDoubleTap(e: MotionEvent): Boolean {
dealOnDoubleTap(e)
return super.onDoubleTap(e)
}
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
e1?.let {
dealOnScroll(e1, e2, distanceX, distanceY)
}
return super.onScroll(e1, e2, distanceX, distanceY)
}
override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
dealOnFling(e2, velocityX, velocityY)
return super.onFling(e1, e2, velocityX, velocityY)
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
dealOnScale(detector)
return true
}
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
}
}
interface OnViewTapListener {
fun onViewTap(view: View?, x: Float, y: Float)
fun onLongClick(view: View?)
}
}
性能方面,空间、时间复杂度没有考虑(例如第 6 步中重复计算的问题),对大图可能导致内存溢出也未做处理。 手势交互是否足够灵敏?可以调整触摸滑动的最小距离(通过 getTouchSlop() 获取)来改善用户体验。动画过程中拖动等并行操作未做处理。
不支持 over scale (过度放大)、over scroll,因为整体思路和边界矫正并不合理,导致很难扩展 over scroll 功能。 不支持关闭,不支持 scaleType 等等(不需要的时候就先不要吧)。 双指捏合同时移动出现抖动的问题。 对比下面四个图片预览的功能,第一个第二个都能明显感到抖动,iPhone 体验最好,OPPO 上体验还是不错的,Pixel4 真的没想到这么拉,这个功能体验如果排序的话:iPhone > OPPO > Pixel4 > Demo。
分解
这篇博客也一样,为了解决 ZoomImageView 的问题,我们要弄清楚 Scroller 滑动特性、事件分发和 GestureDetector 、对 Matrix 运算也了解一二,学会了 geogebra 工具的使用。我们不只是得到了一个 ZoomImageView 控件,更有意义的是积木变成了粘土。现在这些粘土可以按照我们的意愿重新组合成新的积木了,我们可以拿来一个手势放大的视频控件,或是在鸿蒙、Flutter、QT 等上面实现一个 ”ZoomImageView”,在遇到头像裁剪、图片编辑等技术问题时,这些粘土一样可以派上用场。
https://www.geogebra.org/graphing
GestureViews (待挖掘的宝库)
https://github.com/alexvasilkov/GestureViews
俗说矩阵(bilibili 视频)
https://www.bilibili.com/video/BV1Vs4y1Z7HB/?p=1
ZoomImageView(示例代码)
最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
推荐阅读:
扫一扫 关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!