Cursor blinking

ZoomImageView 实现指南(上篇)

Android 基础|字数 2,131|阅读时长≈ 6 分钟

背景

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

其中两个体验问题见下图

bug2.gif
bug2.gif

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

bug1.gif
bug1.gif

问题二:双击缩放到最小动画不自然

实现步骤

思路

本篇博客中 ZoomImageView 的实现思路可以简化为下面这个算式

  • imageMatrix:ImageView 的图像矩阵,更新绘图
  • suppMatrix:活动矩阵(支持矩阵),所有的操作(平移、缩放)作用于它
  • originMatrix:原始矩阵,图像初始化时的矩阵

1. 实现放大和拖动的功能

首先设置 scaleType="matrix" ,这样 ImageView 绘图时才使用图像矩阵 imageMatrix ,添加手势检测器 GestureDetector,帮助我们捕获到双击和拖动事件,然后处理这些事件。

Code
/** * 实现功能 * 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)        }    }}

方便矩阵操作,对其添加扩展函数。

Code
// 为了思路清晰,这里数组没有提取处理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)}
zoom01.gif
zoom01.gif

2. 添加双指捏合操作

添加 ScaleGestureDetector ,修改 ImageView#onTouch ,同时响应 GestureDetector 和 ScaleGestureDetector,在缩放事件里添加矩阵缩放的操作。

Code
/** * 实现功能 * 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) {        }    }}
zoom02.gif
zoom02.gif

3. 初始化处理

ImageView 初始化过程中,以类似 fitCenter (保证图片完整显示,图片高或宽按比例放缩到 View 的高或宽,居中显示)显示模式初始化 originMatrix 矩阵。

Code
/** * 实现功能 * 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()    }    ...}
zoom03.gif
zoom03.gif

由于图片切换时只会切换 originMatrix,对应缩放、平移的操作都记录在 suppMatrix,初始显示效果是一样的 “fitCenter”——图片尺寸放大相应的 MSCALE_X 值同比例变小,最终的 imageMatrix 显示效果并不会改变,基于此便解决了第一个体验问题。如下图,在图片放大过程中切换同比例的高清图,显示效果是一致的。

zoom031.gif
zoom031.gif

4. 边界处理

在把 suppMatrix* originMatrix 赋值给 imageMatrix 之前,先对边界进行矫正,左上右下移动超出边界时通过 suppMatrix 平移抵消掉,矫正之后再应用于 imageMatrix 更新绘图。

Code
/** * 实现功能 * 边界处理,左上右下移动超出边界时进行矫正 */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    }     ...}
zoom04.gif
zoom04.gif

5. 添加 fling 效果

图片放大可拖动时,快速滑动响应 onFling 事件,基于 Android Scroller 滚动特性进行处理。

Code
/** * 实现功能 * 快速滑动 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) {        }    } }
zoom05.gif
zoom05.gif

6. 解决缩放动画过渡不自然的问题

动画执行前后 pivot 支点位置变化和边界矫正的原因,导致开头第二个体验不好的问题,修改动画的实现来解决这个问题。

动画开始前,计算终止时的 endMatrix,对 endMatrix 矫正后求得 endPivotPointF 支点的位置,这样动画行进过程中,便可同步改变 pivot 的位置,同时动画执行过程中,跳过边界矫正。

!icon 这样做的副作用很明显,我们已经计算出了可用来绘图的 Matrix,却又要反回去计算出 suppMatrix,导致额外的计算量,如果过程不优雅,那结果一定不优雅,这种实现思路并不好。
Code
/** * 实现功能 * 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    }     ...}
zoom06.gif
zoom06.gif

7. Over Zoom 处理

在双指捏是允许 zoom < minZoom ,并在手指抬起时,通过动画还原到 minZoom 。

Code
/** * 实现功能 * 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()    }    ...}
zoom07.gif
zoom07.gif

8. 嵌套在滚动控件内时事件冲突处理

事件冲突总体处理起来很轻松,down 事件时设置parent.requestDisallowInterceptTouchEvent(true) 优先由 ZoomImageView 响应处理,当 ZoomImageView 滑动到边界时设置parent.requestDisallowInterceptTouchEvent(false),在交由父级控件处理。

另外双指捏和与父级滚动控件谁先响应的冲突,这里直接简单处理,触屏手指数 >1 都由 ZoomImageView 响应。

Code
/** * 实现功能 * 嵌套冲突处理 */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. 规范代码,细节调优

对一些变量命名、代码注释等进行规范,最终完整代码如下

Code
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 中原来的控件,看下最终效果,撒花。。。✿✿ヽ(°▽°)ノ✿

meetphoto-preview.gif
meetphoto-preview.gif

总结

我们像上面那样一步一步地实现了一个自己的 ZoomImageView 控件,包含原始控件的功能,并解决掉最开始提到的两个体验问题,但依然存在许多问题(如何解决就留给 ZoomImageView 下篇再来讲述吧)。我把这些问题总结如下

  • 性能方面,空间、时间复杂度没有考虑(例如第 6 步中重复计算的问题),对大图可能导致内存溢出也未做处理
  • 手势交互是否足够灵敏?可以调整触摸滑动的最小距离(通过 getTouchSlop() 获取)来改善用户体验。动画过程中拖动等并行操作未做处理。
  • 不支持 over scale (过度放大)、over scroll,因为整体思路和边界矫正并不合理,导致很难扩展 over scroll 功能
  • 不支持关闭,不支持 scaleType 等等(不需要的时候就先不要吧)
  • 双指捏合同时移动出现抖动的问题

对比下面四个图片预览的功能,第一个第二个都能明显感到抖动,iPhone 体验最好,OPPO 上体验还是不错的,Pixel4 真的没想到这么拉,这个功能体验如果排序的话:iPhone > OPPO > Pixel4 > Demo。

My Demo
My Demo
Pixel4 相册
Pixel4 相册
iPhone 照片
iPhone 照片
OPPO 相册
OPPO 相册

达到 iPhone 照片丝般顺滑的体验很有挑战,但我们至少可以实现媲美 OPPO 相册上的体验。然而,这篇博客介绍的方法已不再适用。

思考

分解

当我们遇到一个大问题的挑战时,可能当下并不具备解决它的能力,不妨试试分解它。我们把一个大问题拆分成若干个小问题,逐个击破,最终组合解决方案。就像通关游戏一样,我们需要先通过一个一个小的关卡,才能挑战最终的 boss。这种方式往往行之有效,并且在分解之后我们掌握了一个一个小的技能。

如果我们想了解发动机的工作原理,我们需要把发动机拆开,拆开需要那些工具?我们需要扳手、螺丝刀、需要起重设备和滑轮,然后我们把它拆开,不仅了解发动机的工作原理,在这个过程中,还会学会扳手、螺丝刀等工具的使用。

这篇博客也一样,为了解决 ZoomImageView 的问题,我们要弄清楚 Scroller 滑动特性、事件分发和 GestureDetector 、对 Matrix 运算也了解一二,学会了 geogebra 工具的使用。我们不只是得到了一个 ZoomImageView 控件,更有意义的是积木变成了粘土。现在这些粘土可以按照我们的意愿重新组合成新的积木了,我们可以拿来一个手势放大的视频控件,或是在鸿蒙、Flutter、QT 等上面实现一个 ”ZoomImageView”,在遇到头像裁剪、图片编辑等技术问题时,这些粘土一样可以派上用场。

这些便是对分解的另一种解释。

技术的时效性

多年之前使用 PhotoView 时就曾想对其进行重构,这个念头终于有一天奇怪地又带点遗憾地实现了,不免让人唏嘘。某项技术随着时间的推移变得不再引入注目,放到时代背景下,连同它所依附的行业都变得无足轻重,开发者所具备的经验和技能其实作为知识资产,和金融资产何其相似。也许我们需要像金融投资者那样管理自己的技能组合。除了年终时盘算自己一年下来可怜的收入,是否也可以盘算一下,哪些技能和经验,未来潜在的收益率更大。

彩蛋

2007年乔布斯在初代 iPhone 发布会上演示图片手势放大的片段

录屏2024-05-17 09.11.39.gif
录屏2024-05-17 09.11.39.gif

参考文档