AwesomeImageView支持滑动、双击放大缩小

147 阅读4分钟

在实际项目中遇到了一个需求:图片支持双击放大缩小,放大的情况下支持滑动,还支持双指放大收缩,这其实就类似手机相册浏览图片时的功能。这里想到的是自定义View,拿到图片资源后(不论是本地还是网上资源最终都能转为bitmap)在onDraw方法中使用canvas.drawBitmap去绘制图片,具体代码如下:

class AwesomeImageView @JvmOverloads constructor(context: Context, attributeSet: AttributeSet) :
    View(context, attributeSet), Runnable {

    private var bitmap: Bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.ic_fizz)
    private var paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)

    //bitmap绘制的时候的初试偏移值,因为我们需要把bitmap绘制到view的中心处,而不是左上角
    private var originalOffsetX = 0f
    private var originalOffsetY = 0f
    //用于canvas.translate
    private var offsetX = 0f
    private var offsetY = 0f
    //缩放比例
    private var hugeScale = 0f
    private var littleScale = 0f
    //双击、单指滑动、惯性滑动相关
    private var gestureListener = MyGestureDetectorListener()
    private var gestureDetector: GestureDetector = GestureDetector(context, gestureListener)

    //双指缩放相关
    private var scaleGestureListener = MyScaleGestureListener()
    private var scaleGestureDetector: ScaleGestureDetector = ScaleGestureDetector(context, scaleGestureListener)

    private var isHugeModel = false//当前是放大还是缩小
    private val OVER_SCALE_FACTOR = 1.5f
    private var objectAnimator: ObjectAnimator? = null

    private var initialScale = 0f
    private var canScroller = true
    private var currentScale = 0f
        set(value) {
            field = value
            invalidate()
        }

    private val overScroller = OverScroller(context)
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        originalOffsetX = (w - bitmap.width) / 2f
        originalOffsetY = (h - bitmap.height) / 2f
        //说明bitmap高更接近view边缘
        if (w.toFloat() / h >= bitmap.width.toFloat() / bitmap.height) {
            //计算出缩放比例,这里的OVER_SCALE_FACTOR是为了在手指滑动的时候上下左右都能滑动所以刻意把 hugeScale 的缩放比例增大
            hugeScale = (w.toFloat() / bitmap.width) * OVER_SCALE_FACTOR
            littleScale = h.toFloat() / bitmap.height
        } else {
            littleScale = w.toFloat() / bitmap.width
            hugeScale = (h.toFloat() / bitmap.height) * OVER_SCALE_FACTOR
        }
        currentScale = littleScale
    }

    override fun onDraw(canvas: Canvas?) {
        //不能一步到位直接移动,还是需要根据动画的完成度来移动,否则会闪
        val translateRatio = (currentScale - littleScale) / (hugeScale - littleScale)
        canvas?.translate(offsetX * translateRatio, offsetY * translateRatio)

        canvas?.scale(currentScale, currentScale, width / 2f, height / 2f)
        canvas?.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
    }

    inner class MyGestureDetectorListener : GestureDetector.SimpleOnGestureListener(){
        override fun onDown(e: MotionEvent?): Boolean {
            //返回true表示消费当前事件,否则当前事件序列的后续事件会收不到
            return true
        }

        /**
         * 处理单指滑动
         */
        override fun onScroll(
            down: MotionEvent?,//最初的down事件,被保留下来了
            currentEvent: MotionEvent?,
            distanceX: Float,//上一个event和当前event x的距离,旧的减新的
            distanceY: Float
        ): Boolean {
            if (isHugeModel && canScroller) {
                //这里之所以是-=而不是+=是因为canvas.translate移动的不是坐标点而是画布
                //比如这个场景bitmap向右下方移动实际上就是画布向右下移动,所以从canvas.translate的角度来看offsetX和offsetY都得是正数
                //而旧值减新值无论distanceX还是distanceY都是负,所以是-=
                offsetX -= distanceX
                offsetY -= distanceY

                //这里是为了防止无限滑动,把整个bitmap都滑出屏幕了,这肯定是我们不想要的
                //不信的话可以试试注释掉下面这段代码......
                offsetX = offsetX.coerceAtMost(bitmap.width / 2 * hugeScale - width / 2)
                offsetX = offsetX.coerceAtLeast(-(bitmap.width / 2 * hugeScale - width / 2))
                offsetY = offsetY.coerceAtMost(bitmap.height / 2 * hugeScale - height / 2)
                offsetY = offsetY.coerceAtLeast(-(bitmap.height / 2 * hugeScale - height / 2))

                invalidate()
            }
            return false
        }

        /**
         * 类似于recyclerView的惯性滑动
         */
        override fun onFling(
            down: MotionEvent?,
            currentEvent: MotionEvent?,
            velocityX: Float,//x方向的速度
            velocityY: Float
        ): Boolean {

            overScroller.fling(
                offsetX.toInt(),//起始点就是在onScroll后的offsetX和offsetY
                offsetY.toInt(),
                velocityX.toInt(),
                velocityY.toInt(),
                -(bitmap.width * hugeScale / 2 - width / 2).toInt(),//X最小的移动范围
                (bitmap.width * hugeScale / 2 - width / 2).toInt(),//X最大的移动范围
                -(bitmap.height * hugeScale / 2 - height / 2).toInt(),
                (bitmap.height * hugeScale / 2 - height / 2).toInt(), 80, 80
            )
            postOnAnimation(this@AwesomeImageView)
            return false
        }

        /**
         * 支持双击放大缩小
         */
        override fun onDoubleTap(e: MotionEvent?): Boolean {
            isHugeModel = !isHugeModel
            if (isHugeModel) {
                //之所以start?和final?需要这样算,是因为这终究是基于view的中心点的缩放
                //要是直接使用startX = e.x那算出来的距离就不对了,除非这是基于canvas的坐标原点放大
                //重点------>我们能实现在点击处放大是因为在缩放前把坐标点给移动了
                e?.let {
                    val startX = e.x - width / 2
                    val startY = e.y - height / 2
                    val finalX = startX * hugeScale / littleScale
                    val finalY = startY * hugeScale / littleScale

                    //这里之所以是start?-final?,比如放大后的位置x、y点都大于初始位置
                    //那在绘制bitmap之前肯定就得把canvas的坐标点往相反的方向移动,这样才能保证点击的位置放大后还在原地
                    offsetX = startX - finalX
                    offsetY = startY - finalY
                }
                getAnimator().start()
            } else {
                getAnimator().reverse()
            }
            return false
        }
    }

    /**
     * 支持双指缩放
     */
    private inner class MyScaleGestureListener :ScaleGestureDetector.OnScaleGestureListener{
        //如果返回 true 则表示当前缩放事件已经被处理,检测器会重新积累缩放因子,返回 false 则会继续积累缩放因子
        override fun onScale(detector: ScaleGestureDetector?): Boolean {
            //detector.scaleFactor是从1开始的   1.1 1.2 1.3等等
            //所以我们可以直接做乘法
            currentScale = initialScale * (detector?.scaleFactor ?: 1f)
            currentScale = currentScale.coerceAtLeast(littleScale * 0.4f)
            currentScale = currentScale.coerceAtMost(hugeScale * 2)
            invalidate()
            return false
        }

        //如果返回 false 则表示不使用当前这次缩放手势
        override fun onScaleBegin(detector: ScaleGestureDetector?): Boolean {
            initialScale = currentScale
            canScroller = false
            return true
        }

        //缩放结束
        override fun onScaleEnd(detector: ScaleGestureDetector?) {
            canScroller = true
        }
    }


    fun getAnimator(): ObjectAnimator {
        if (objectAnimator == null) {
            /**
             * 重点------》为了实现多点触控这里的value值不再使用0和1,而是直接使用具体的缩放比例,这样在动画过程中拿到的值就是当前bitmap真正的缩放比例
             * 这也是为什么onScale方法中的代码可以直接 currentScale = initialScale * (detector?.scaleFactor ?: 1f)
             */
            objectAnimator = ObjectAnimator.ofFloat(this, "currentScale", littleScale, hugeScale)
            objectAnimator!!.duration = 150
        }
        return objectAnimator!!
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        //这里目前的写法还有冲突,可以根据当前手势自行调用scaleGestureDetector或者gestureDetector的onTouchEvent,而不是一起调用
        scaleGestureDetector.onTouchEvent(event)
        return gestureDetector.onTouchEvent(event)
    }

    override fun run() {
        if (overScroller.computeScrollOffset()) {
            offsetX = overScroller.currX.toFloat()
            offsetY = overScroller.currY.toFloat()
            invalidate()
            postOnAnimation(this)
        }
    }
}

回过头来看其实onDraw中绘制相关的代码很少,核心还是依靠 GestureDetector对手势进行检测再做具体操作。本例主要在于思路,是一个大体的流程,还有很多细节没优化。