ZoomImageView 实现指南(上篇)
背景
ZoomImageView 是一个自定义的 ImageView 控件,用于实现对图片的手势缩放、双击缩放以及放大后的平移查看等功能。在我之前的 MeetPhoto 项目中,图片预览功能使用了一个开源 ZoomImageView 控件(这个控件基于 PhotoView 实现,因其代码量较少而选择它)。但我发现这个控件在某些方面的用户体验并不理想,所以我决定对其进行优化,便是这篇博客的由来。
其中两个体验问题见下图

问题一:高清图切换底清图时被重置

问题二:双击缩放到最小动画不自然
实现步骤
思路
本篇博客中 ZoomImageView 的实现思路可以简化为下面这个算式
⁍
- imageMatrix:ImageView 的图像矩阵,更新绘图
- suppMatrix:活动矩阵(支持矩阵),所有的操作(平移、缩放)作用于它
- originMatrix:原始矩阵,图像初始化时的矩阵
1. 实现放大和拖动的功能
首先设置 scaleType="matrix" ,这样 ImageView 绘图时才使用图像矩阵 imageMatrix ,添加手势检测器 GestureDetector,帮助我们捕获到双击和拖动事件,然后处理这些事件。
/** * 实现功能 * 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)}
2. 添加双指捏合操作
添加 ScaleGestureDetector ,修改 ImageView#onTouch ,同时响应 GestureDetector 和 ScaleGestureDetector,在缩放事件里添加矩阵缩放的操作。
/** * 实现功能 * 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) { } }}
3. 初始化处理
ImageView 初始化过程中,以类似 fitCenter (保证图片完整显示,图片高或宽按比例放缩到 View 的高或宽,居中显示)显示模式初始化 originMatrix 矩阵。
/** * 实现功能 * 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() } ...}
由于图片切换时只会切换 originMatrix,对应缩放、平移的操作都记录在 suppMatrix,初始显示效果是一样的 “fitCenter”——图片尺寸放大相应的 MSCALE_X 值同比例变小,最终的 imageMatrix 显示效果并不会改变,基于此便解决了第一个体验问题。如下图,在图片放大过程中切换同比例的高清图,显示效果是一致的。

4. 边界处理
在把 suppMatrix* originMatrix 赋值给 imageMatrix 之前,先对边界进行矫正,左上右下移动超出边界时通过 suppMatrix 平移抵消掉,矫正之后再应用于 imageMatrix 更新绘图。
/** * 实现功能 * 边界处理,左上右下移动超出边界时进行矫正 */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 } ...}
5. 添加 fling 效果
图片放大可拖动时,快速滑动响应 onFling 事件,基于 Android 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) { } } }
6. 解决缩放动画过渡不自然的问题
动画执行前后 pivot 支点位置变化和边界矫正的原因,导致开头第二个体验不好的问题,修改动画的实现来解决这个问题。
动画开始前,计算终止时的 endMatrix,对 endMatrix 矫正后求得 endPivotPointF 支点的位置,这样动画行进过程中,便可同步改变 pivot 的位置,同时动画执行过程中,跳过边界矫正。
/** * 实现功能 * 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 } ...}
7. Over Zoom 处理
在双指捏是允许 zoom < minZoom ,并在手指抬起时,通过动画还原到 minZoom 。
/** * 实现功能 * 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() } ...}
8. 嵌套在滚动控件内时事件冲突处理
事件冲突总体处理起来很轻松,down 事件时设置parent.requestDisallowInterceptTouchEvent(true) 优先由 ZoomImageView 响应处理,当 ZoomImageView 滑动到边界时设置parent.requestDisallowInterceptTouchEvent(false),在交由父级控件处理。
另外双指捏和与父级滚动控件谁先响应的冲突,这里直接简单处理,触屏手指数 >1 都由 ZoomImageView 响应。
/** * 实现功能 * 嵌套冲突处理 */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?) }}替换掉 MeetPhoto 中原来的控件,看下最终效果,撒花。。。✿✿ヽ(°▽°)ノ✿

总结
我们像上面那样一步一步地实现了一个自己的 ZoomImageView 控件,包含原始控件的功能,并解决掉最开始提到的两个体验问题,但依然存在许多问题(如何解决就留给 ZoomImageView 下篇再来讲述吧)。我把这些问题总结如下
- 性能方面,空间、时间复杂度没有考虑(例如第 6 步中重复计算的问题),对大图可能导致内存溢出也未做处理
- 手势交互是否足够灵敏?可以调整触摸滑动的最小距离(通过 getTouchSlop() 获取)来改善用户体验。动画过程中拖动等并行操作未做处理。
- 不支持 over scale (过度放大)、over scroll,因为整体思路和边界矫正并不合理,导致很难扩展 over scroll 功能
- 不支持关闭,不支持 scaleType 等等(不需要的时候就先不要吧)
- 双指捏合同时移动出现抖动的问题
对比下面四个图片预览的功能,第一个第二个都能明显感到抖动,iPhone 体验最好,OPPO 上体验还是不错的,Pixel4 真的没想到这么拉,这个功能体验如果排序的话:iPhone > OPPO > Pixel4 > Demo。




达到 iPhone 照片丝般顺滑的体验很有挑战,但我们至少可以实现媲美 OPPO 相册上的体验。然而,这篇博客介绍的方法已不再适用。
思考
分解
当我们遇到一个大问题的挑战时,可能当下并不具备解决它的能力,不妨试试分解它。我们把一个大问题拆分成若干个小问题,逐个击破,最终组合解决方案。就像通关游戏一样,我们需要先通过一个一个小的关卡,才能挑战最终的 boss。这种方式往往行之有效,并且在分解之后我们掌握了一个一个小的技能。
如果我们想了解发动机的工作原理,我们需要把发动机拆开,拆开需要那些工具?我们需要扳手、螺丝刀、需要起重设备和滑轮,然后我们把它拆开,不仅了解发动机的工作原理,在这个过程中,还会学会扳手、螺丝刀等工具的使用。
这篇博客也一样,为了解决 ZoomImageView 的问题,我们要弄清楚 Scroller 滑动特性、事件分发和 GestureDetector 、对 Matrix 运算也了解一二,学会了 geogebra 工具的使用。我们不只是得到了一个 ZoomImageView 控件,更有意义的是积木变成了粘土。现在这些粘土可以按照我们的意愿重新组合成新的积木了,我们可以拿来一个手势放大的视频控件,或是在鸿蒙、Flutter、QT 等上面实现一个 ”ZoomImageView”,在遇到头像裁剪、图片编辑等技术问题时,这些粘土一样可以派上用场。
这些便是对分解的另一种解释。
技术的时效性
多年之前使用 PhotoView 时就曾想对其进行重构,这个念头终于有一天奇怪地又带点遗憾地实现了,不免让人唏嘘。某项技术随着时间的推移变得不再引入注目,放到时代背景下,连同它所依附的行业都变得无足轻重,开发者所具备的经验和技能其实作为知识资产,和金融资产何其相似。也许我们需要像金融投资者那样管理自己的技能组合。除了年终时盘算自己一年下来可怜的收入,是否也可以盘算一下,哪些技能和经验,未来潜在的收益率更大。
彩蛋
2007年乔布斯在初代 iPhone 发布会上演示图片手势放大的片段
