如何实现一个简单的支持手势缩放的ImageView

764 阅读9分钟

获取图片

 private fun getAvatar(width: Float): Bitmap {
        val options = BitmapFactory.Options()
        options.inJustDecodeBounds = true
        BitmapFactory.decodeResource(resources, R.drawable.avatar, options)
        options.inJustDecodeBounds = false
        options.inDensity = options.outWidth
        options.inTargetDensity = width.toInt()
        return BitmapFactory.decodeResource(resources, R.drawable.avatar, options)
    }

这里做了一个优化,options先读取一次图片, options.inJustDecodeBounds这options只读取原图的尺寸信息,并且获得需要的大小,然后根据尺寸 options.inTargetDensity = width.toInt(),比获取足够清晰且不浪费内存加载图片。

将图片绘制在屏幕中央

首先定义下面这几个变量

//绘制BitMap的Paint
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)

    //绘制的图片对象
    private val bitmap = getAvatar(IMAGE_SIZE)

    //初始的X的偏移
    private var originalOffsetX = 0F.dp

    //初始的Y的偏移
    private var originalOffsetY = 0F.dp

    //偏移量X
    private var offsetX = 0F

    //偏移量Y
    private var offsetY = 0F
    
        //缩放比
    private var currentScale = smallScale
        set(value) {
            field = value
            invalidate()
        }

在View的onSizeChange()阶段进行偏移量的计算

蓝色表示View,红色表示我们绘制的BitMap,所以,我们测到的初始需要的偏移量为

 originalOffsetX = (width - IMAGE_SIZE) / 2
 originalOffsetY = (height - IMAGE_SIZE) / 2

但此时还有个问题,就是我们需要图片的贴边,而不留白,下面以QQ为例 即使是小图的状态,左右两边也会贴边,而不会留下空白,将宽图宽度放大到屏幕(View)的宽度相同,或者长图的长度放大到View的高度相同即可。

	//如果图片的宽高比大于视图的宽高比(属于偏宽的图片)
   if (bitmap.width / bitmap.height.toFloat() > width / height) {
            smallScale = width.toFloat() / bitmap.width
            bigScale = height.toFloat() / bitmap.height * EXTRA_SCALE_FRACTION
     } else {
            //偏长的图片
            bigScale = width.toFloat() / bitmap.width * EXTRA_SCALE_FRACTION
            smallScale = height.toFloat() / bitmap.height
     }
     //当前缩放比为默认的小图模式
     currentScale = smallScale

于是onDraw()绘制如下

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.scale(currentScale, currentScale, width / 2F, height / 2F)
        canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
    }

双击放大

为了解耦,写一个inner 类GestureDelegate用于承载手势监听器,继承于SimpleOnGestureListener() ,这个类有以下方法可以被重写,先解释一下。

  • onShowPress()

      用户触摸了100ms之后触发
    
  • onSingleTapUp()

      点击一次触发,返回值不会影响此段代码的执行,只用于系统确认是否消费了这里,用来打日志。
      
    
  • onDown()

      用户按下屏幕,返回值作用同上
    
  • onFling()

      快速滑动监听,用于惯性滑动
    
  • onScroll()

      长时间滑动监听,返回值不影响效果
     
    
  • onLongPress()

      长按监听
      
    
  • onDoubleTap()

      双击监听,带误操作防护,双击间隔50ms-300ms才会触发
      
    
  • onDoubleTapEvent()

      双击第二下的点击监听
      
    
  • onSingleTapConfirmed()

      单击监听,不同于onSingleTapUp(),双击的时候onSingleTapUp()也会触发,但是这个不会触发,在双击的判断时区结束后才会触发此方法,如果有双击操作也有单击操作的时候推荐使用这个代替onSingleTapUp()
      
    

首先从双击开始

  1. 改变状态

  2. 定义一个放大的属性动画

    private val scaleAnimation = ObjectAnimator.ofFloat(this, "currentScale", smallScale, bigScale)
    将当前的缩放比从small变化到big
    

3.然后根据 状态执行操作

	 if (isBig) {
            offsetX = (ev.x - width / 2F) * (1 - bigScale / smallScale)
            offsetY = (ev.y - height / 2F) * (1 - bigScale / smallScale)
            fixOffset()
            scaleAnimation.start()
        } else {
            scaleAnimation.reverse()
        }

4. 此时有个问题,无论从何处点击放大的中心都会在图片中央,而不是我们点击的地方,这个其实也不难解决,设图片中心为(x1,y1),点击的地方为(x2, y2),在进行放大后,点击的地方为(x3,y3),因为是按比例放大了,所以X3,Y3的坐标也是按比例放大的,这个比例为bigScale / smallScale),图片中心的位置很好计算,就是View的长宽的一半的地方,(width/2,height/2),所有计算出过量的偏移量再补偿回去就行了。 所以得出偏移

   offsetX = (ev.x - width / 2F) * (1 - bigScale / smallScale)
   offsetY = (ev.y - height / 2F) * (1 - bigScale / smallScale)

但是我们点击的是屏幕的边缘怎么办??? 如果还是缩放中心点还是点击处,就不会贴边了,会露出View的底色,所有写个函数判断一下位置是否合理, 比较偏移量的是否大于/小于越界的位置。但什么时候是越界的位置。 这张图是我们在放大状态,且中心放大的状态,View左右偏移量多大?只需红色BitMap的宽度-View的宽度再除以2即可获得,于是代码如下

 offsetX = min(offsetX, (bitmap.width * bigScale - width) / 2)
 offsetX = max(offsetX, -(bitmap.width * bigScale - width) / 2)
 offsetY = min(offsetY, (bitmap.height * bigScale - height) / 2)
 offsetY = max(offsetY, -(bitmap.height * bigScale - height) / 2)

滑动

自定义View的入门教程很多都有讲,获取滑动的偏移加载额外偏移变量上即可,并且通知View更新视图,记得修正一下越界问题。

      //长时间滑动监听,返回值不影响效果
        override fun onScroll(
            downEvent: MotionEvent?,//上次事件
            currentEvent: MotionEvent?,//当前事件
            distenceX: Float,//两次事件的距离
            distenceY: Float//两次事件的距离
        ): Boolean {
            if (isBig) {
                offsetX -= distenceX
                offsetY -= distenceY
                fixOffset()
            }
            invalidate()
            return true
        }

此步完成后,会发现可以滑动,但很僵硬,手指离开屏幕图片就顶住了,在外面一些常见的图片软件都能滑动后再随着“惯性”移动一段距离。

这里需要用一个安卓给我们工具类来计算惯性偏移并且给予绘制才能实现。 定义 private val scroller = OverScroller(context),不推荐使用Scroller,Scroller的动画效果比较生硬,OverScroller更加自然一些。

OnFling()中填入参数

 //快速滑动监听,用于惯性滑动
        override fun onFling(
            downEvent: MotionEvent?,//上次事件
            currentEvent: MotionEvent?,//当前事件
            vX: Float,//两次事件的距离
            vY: Float//两次事件的数据
        ): Boolean {
            //只在大图环境下可用的惯性滑动
            if (isBig) {
                scroller.fling(
                    offsetX.toInt(),//起始位置x
                    offsetY.toInt(),//起始位置y
                    vX.toInt(),//移动距离
                    vY.toInt(),//激动距离
                    (-(bitmap.width * bigScale - width) / 2).toInt(),//x的最小值
                    ((bitmap.width * bigScale - width) / 2).toInt(),//x移动后的最大值
                    (-(bitmap.height * bigScale - height) / 2).toInt(),y移动后的最小值
                    ((bitmap.height * bigScale - height) / 2).toInt()//y移动后的最大值
                )
                //一帧只会进行一次
                ViewCompat.postOnAnimation(this@ScalableImageView) { refresh() }
            }
            return false
        }

定义一个refresh()将计算出的惯性移动给绘制出来,

  private fun refresh() {
            //计算过程结束后停止位移
       if (scroller.computeScrollOffset()) {
                offsetX = scroller.currX.toFloat()
                offsetY = scroller.currY.toFloat()
                invalidate()
                postOnAnimation { refresh() }
         }
    }

scroller.computeScrollOffset()会有一个返回值,如果为false表示滑动结束,不然就继续根据返回的计算偏移值进行动画更新。 推荐使用ViewCompat.postOnAnimation(this@ScalableImageView) { refresh() } 而不是View自带post,post会在一帧中执行多次,但我们一帧只需要绘制一次其实,多余的计算浪费了性能。

onDraw()应用当前缩放

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val scaleFraction = (currentScale - smallScale) / (bigScale - smallScale)
        canvas.translate(offsetX * scaleFraction, offsetY * scaleFraction)
        canvas.scale(currentScale, currentScale, width / 2F, height / 2F)
        canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
    }

将双击事件绑定

声明一个GestureDetectorCompat

  private val gestureDetector = GestureDetectorCompat(context, GestureDelegate())

如果SDK比较老可使用GestureDetector,GestureDetectorCompat为它的兼容版本,兼容性更好。

然后再onTouchEvent()将其绑定

gestureDetector.onTouchEvent(event)

手指捏撑控制缩放

其实这部逻辑也差不多了,定义一个 inner 类 ScaleGestureDelegate继承于 ScaleGestureDetector.SimpleOnScaleGestureListener() 我们仅仅需要重写两个方法即可

  • onScaleBegin()
 offsetX = (detector.focusX - width / 2F) * (1 - bigScale / smallScale)
 offsetY = (detector.focusY - height / 2F) * (1 - bigScale / smallScale)
 return super.onScaleBegin(detector)

与双击放大相同,这里计算出偏移的大小。缩放中心为双指的中间的位置。

  • onScale()
    override fun onScale(detector: ScaleGestureDetector): Boolean {
          val tempCurrentScale = currentScale * detector.scaleFactor
          return if (tempCurrentScale < smallScale || tempCurrentScale > 						bigScale) {
              //不消费,防止边界捏撑被刷新
              false
     } else {
              currentScale = tempCurrentScale
              true
          }
      }

这里获取缩放的比例,当缩放的比例过大或者过小不应用。

应用缩放的监听器也如出一辙

    //缩放监听器
  private val scaleGestureDelegate = ScaleGestureDelegate()

  //缩放检查器
  private val scaleGestureDetector = ScaleGestureDetector(context, scaleGestureDelegate)

这里请使用ScaleGestureDetector,而不是ScaleGestureDetectorCompat,ScaleGestureDetectorCompat并不是它的兼容版本仅仅是一个工具类用于兼容适配,是无法直接使用的。 然后在onTouchEvent()中使用即可。

解决事件冲突

此处用户操作的时候可能会有歧义,如双指触摸的时候不小心触发了双击,但用户其实是想捏撑放大,所以需要解决一下事件抢夺问题。

    override fun onTouchEvent(event: MotionEvent?): Boolean {

       scaleGestureDetector.onTouchEvent(event)
       //防止事件抢夺
       if (!scaleGestureDetector.isInProgress) {
           gestureDetector.onTouchEvent(event)
       }
       return true
   }

完整代码

 //图像的大小
private val IMAGE_SIZE = 300F.dp
//额外缩放比
private const val EXTRA_SCALE_FRACTION = 1.5F

class ScalableImageView(context: Context, attr: AttributeSet) : View(context, attr) {

    //绘制BitMap的Paint
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)

    //绘制的图片对象
    private val bitmap = getAvatar(IMAGE_SIZE)

    //初始的X的偏移
    private var originalOffsetX = 0F.dp

    //初始的Y的偏移
    private var originalOffsetY = 0F.dp

    //偏移量X
    private var offsetX = 0F

    //偏移量Y
    private var offsetY = 0F

    //小的缩放比
    private var smallScale = 0F

    //大图缩放比例
    private var bigScale = 0F

    //当前状态
    private var isBig = false

    //提供的工具类用于实现惯性
    private val scroller = OverScroller(context)

    //缩放监听器
    private val scaleGestureDelegate = ScaleGestureDelegate()

    //缩放检查器
    private val scaleGestureDetector = ScaleGestureDetector(context, scaleGestureDelegate)

    //缩放比
    private var currentScale = smallScale
        set(value) {
            field = value
            invalidate()
        }


    private val scaleAnimation = ObjectAnimator.ofFloat(this, "currentScale", smallScale, bigScale)


    private val gestureDetector = GestureDetectorCompat(context, GestureDelegate())


    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val scaleFraction = (currentScale - smallScale) / (bigScale - smallScale)
        canvas.translate(offsetX * scaleFraction, offsetY * scaleFraction)
        canvas.scale(currentScale, currentScale, width / 2F, height / 2F)
        canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        originalOffsetX = (width - IMAGE_SIZE) / 2
        originalOffsetY = (height - IMAGE_SIZE) / 2
        //如果图片的宽高比大于视图的宽高比(属于偏宽的图片)
        if (bitmap.width / bitmap.height.toFloat() > width / height) {
            smallScale = width.toFloat() / bitmap.width
            bigScale = height.toFloat() / bitmap.height * EXTRA_SCALE_FRACTION
        } else {
            //偏长的图片
            bigScale = width.toFloat() / bitmap.width * EXTRA_SCALE_FRACTION
            smallScale = height.toFloat() / bitmap.height
        }
        currentScale = smallScale
        scaleAnimation.setFloatValues(smallScale, bigScale)
    }

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent?): Boolean {

        scaleGestureDetector.onTouchEvent(event)
        //防止事件抢夺
        if (!scaleGestureDetector.isInProgress) {
            gestureDetector.onTouchEvent(event)
        }
        return true
    }

    private fun getAvatar(width: Float): Bitmap {
        val options = BitmapFactory.Options()
        options.inJustDecodeBounds = true
        BitmapFactory.decodeResource(resources, R.drawable.avatar, options)
        options.inJustDecodeBounds = false
        options.inDensity = options.outWidth
        options.inTargetDensity = width.toInt()
        return BitmapFactory.decodeResource(resources, R.drawable.avatar, options)
    }


    //单击双击滑动行为监听器
    inner class GestureDelegate : GestureDetector.SimpleOnGestureListener() {
        //用户触摸了一百毫秒之后触发
        override fun onShowPress(ev: MotionEvent) = Unit


        //点击一次,返回值:是否消费了该事件,但返回值其实并不会影响实现结果
        override fun onSingleTapUp(ev: MotionEvent): Boolean = false

        //允许返回事件流
        override fun onDown(ev: MotionEvent): Boolean = true

        //快速滑动监听,用于惯性滑动
        override fun onFling(
            downEvent: MotionEvent?,//上次事件
            currentEvent: MotionEvent?,//当前事件
            vX: Float,//两次事件的距离
            vY: Float//两次事件的数据
        ): Boolean {
            //只在大图环境下可用的惯性滑动
            if (isBig) {
                scroller.fling(
                    offsetX.toInt(),
                    offsetY.toInt(),
                    vX.toInt(),
                    vY.toInt(),
                    (-(bitmap.width * bigScale - width) / 2).toInt(),
                    ((bitmap.width * bigScale - width) / 2).toInt(),
                    (-(bitmap.height * bigScale - height) / 2).toInt(),
                    ((bitmap.height * bigScale - height) / 2).toInt()
                )
                //一帧只会进行一次
                ViewCompat.postOnAnimation(this@ScalableImageView) { refresh() }
            }
            return false
        }

        //取值
        private fun refresh() {
            //计算过程结束后停止位移
            if (scroller.computeScrollOffset()) {
                offsetX = scroller.currX.toFloat()
                offsetY = scroller.currY.toFloat()
                invalidate()
                postOnAnimation { refresh() }
            }
        }


        //长时间滑动监听,返回值不影响效果
        override fun onScroll(
            downEvent: MotionEvent?,//上次事件
            currentEvent: MotionEvent?,//当前事件
            distenceX: Float,//两次事件的距离
            distenceY: Float//两次事件的数据
        ): Boolean {
            if (isBig) {
                offsetX -= distenceX
                offsetY -= distenceY
                fixOffset()
            }
            invalidate()
            return true
        }

        //长按
        override fun onLongPress(p0: MotionEvent) = Unit

        //双击触发(带手抖防护,50ms-300ms)
        override fun onDoubleTap(ev: MotionEvent): Boolean {
            isBig = !isBig
            if (isBig) {
                offsetX = (ev.x - width / 2F) * (1 - bigScale / smallScale)
                offsetY = (ev.y - height / 2F) * (1 - bigScale / smallScale)
                fixOffset()
                scaleAnimation.start()
            } else {
                scaleAnimation.reverse()
            }
            return true
        }

        //边缘修正,防止大图显示过界
        private fun fixOffset() {
            offsetX = min(offsetX, (bitmap.width * bigScale - width) / 2)
            offsetX = max(offsetX, -(bitmap.width * bigScale - width) / 2)
            offsetY = min(offsetY, (bitmap.height * bigScale - height) / 2)
            offsetY = max(offsetY, -(bitmap.height * bigScale - height) / 2)
        }

        //双击后的事件
        override fun onDoubleTapEvent(ev: MotionEvent?): Boolean = false

        //支持双击的时候,使用这个来判断单击
        override fun onSingleTapConfirmed(p0: MotionEvent?): Boolean = false
    }

    //缩放行为监听
    inner class ScaleGestureDelegate : ScaleGestureDetector.SimpleOnScaleGestureListener() {

        //缩放开始的时候设置偏移中心点
        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
            //根据当前的缩放比计算出缩放位移
            offsetX = (detector.focusX - width / 2F) * (1 - bigScale / smallScale)
            offsetY = (detector.focusY - height / 2F) * (1 - bigScale / smallScale)
            return super.onScaleBegin(detector)
        }

        //返回值是否消费
        override fun onScale(detector: ScaleGestureDetector): Boolean {
            val tempCurrentScale = currentScale * detector.scaleFactor
            return if (tempCurrentScale < smallScale || tempCurrentScale > bigScale) {
                //不消费,防止边界捏撑被刷新
                false
            } else {
                currentScale = tempCurrentScale
                true
            }
        }
    }
}

结尾推荐一波凯哥的HencoderPlus课程