触摸反馈②双向滑动的ScalableImageView

807 阅读6分钟

步骤

  1. onDraw方法通过drawBitmap画出图片

  2. 通过x,y轴偏移量动态计算将图片布局到屏幕中间

  3. 计算出图片的最小和最大放缩比

    • 51
  4. 缩放测试,测试图片放缩效果

    • 52
  5. 手势监听事件挂载,覆盖原生onTouchEvent,返回GestureDetectorCompat的onTouchEvent监听,实现GestureDetector.

    • private gestureDetector = GestureDetectorCompat(context,gestureListener)
    • onDown:重写,要返回true
    • onShowPress:按下和预按下结合的一个回调方法
    • onSingleTapUp:单机抬起,实现逻辑与onClick有细微差别,在本例中可以作为onClick的一个替代,重写,返回true或false不影响流程(我们的事件是否被消费以onDown为准)
    • onScroll:滑动,可以监测滑动距离等,重写,返回true或false不影响(由于我们本例是缩放ImageView)
    • onFling:快速滑动会惯性滑动,如微信列表快速滑动,当我们抬起之后,列表继续滑动,这时候会触发onFling, onScroll是手指拖着滑动,一个是手指松开之后的滑动
    • onLongPress:与onLongClick类似
     
    override fun onDown(e: MotionEvent): Boolean {
        // 每次 ACTION_DOWN 事件出现的时候都会被调用,在这里返回 true可以保证必然消费掉事件
        return true 
    }
    
    override fun onShowPress(e: MotionEvent) {
    // 用户按下 100ms 不松手后会被调用,用于标记「可以显示按下状态了」
    }
    
    override fun onSingleTapUp(e: MotionEvent): Boolean { 
        // 用户单击时被调用(支持⻓按时⻓按后松手不会调用、双击的第二下时不会被调用) 
        return false
    }
    
    override fun onScroll(downEvent: MotionEvent, currentEvent: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
        // 用户滑动时被调用
        // 第一个事件是用户按下时的 ACTION_DOWN 事件,第二个事件是当前 事件
        // 偏移是按下时的位置 - 当前事件的位置
        return false 
    }
    
    override fun onLongPress(e: MotionEvent) {
        // 用户⻓按(按下 500ms 不松手)后会被调用
        // 这个 500ms 在 GestureDetectorCompat 中变成了 600ms(???) 
    }
    
    override fun onFling(downEvent: MotionEvent, currentEvent: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
        // 用于滑动时迅速抬起时被调用,用于用户希望控件进行惯性滑动的场景
        return false 
    }
    
    
  6. 添加双击监听器GestureDetector.OnDoubleTapListener

    • gestureDetector.setOnDoubleTapListener(doubleTapListener);
    • 那为什么还要实现OnGestureListener呢:需要使用其中的onDown回调
    • onDoubleTap:两次点击大于40ms并且小于300ms被认定是双击,否则false
      • 代码 if (deltaTime > 300 || deltaTime < 40) { return false; }
    • onDoubleTapEvent:用户双击第二次按下时、第二次按下后移动时、第二次按下后抬起时都会被调用
    • onSingleTapConfirmed:单击确认回调,支持双击的时候,onSingleTapConfirmed要比onSingleTapUp更准确,因为onSingleTapConfirmed总要等待300ms
     
    override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
        // 用户单击时被调用
        // 和 onSingltTapUp() 的区别在于,用户的一次点击不会立即调用
        //这个方法,而是在一定时间后(300ms),确认用户没有进行双击,这个方法才会被调用
        return false 
    }
    
    override fun onDoubleTap(e: MotionEvent): Boolean { // 用户双击时被调用
        // 注意:第二次触摸到屏幕时就调用,而不是抬起时
        return false
    }
    
    override fun onDoubleTapEvent(e: MotionEvent): Boolean { 
        // 用户双击第二次按下时、第二次按下后移动时、第二次按下后抬起时都会被调用
        // 常用于「双击拖拽」的场景 
        return false
    }
    
  7. 在双击事件回调中标记状态重新绘制图片

      override fun onDoubleTap(e: MotionEvent?): Boolean {
        //7. 在双击事件回调中标记状态重新绘制图片
        big = !big
        invalidate()
        return true
      }
    
  8. 加入属性动画放缩效果ObjectAnimator.ofFloat(this,"scaleFraction",0f,1f)

  9. 加入图片滑动支持,监听滑动事件并处理

    • 首先在onScroll中监听滑动距离
    • 然后在绘制的时候通过canvas.translate(x偏移,y偏移)移动图片
  10. 现在双击图片并触摸移动图片,发现图片会有白边,下面需要通过overScroller计算滑动偏移,设置图片惯性滑动距离,计算偏移量,并在下一帧刷新
    53

  11. 动画结束的回调函数中将偏移量进行重置

  12. 现在双击图片,不管在哪里双击都只会沿着中心点进行放大,而我们要做的是点哪里从哪里进行放大,所以要在双击事件中进行双击触点偏移修正

  13. 边缘修正

现在,我们基本实现了双击放大缩小的功能,下面我们要实现双指捏撑缩小放大效果,将ScalableImageView代码保存,新建ScalableImageView2实现双指捏撑放大缩小效果

  1. 将接口抽离为内部类,有效减少无用的接口实现代码DSHGuestureListener:GestureDetector.SimpleOnGestureListener

  2. 添加放缩手势操作监听器

    • ① 内部类监听放缩手势事件
    • ② 用scaleGestureDetector替代gestureDetector,监听onTouch事件
    • ③ currentScale放缩系数值要改变,onDraw和onSizeChanged也需要修改
    • ④ onScale监听事件中动态计算currentScale的值
  3. 放缩触点偏移修正,在onScaleBegin中修正offsetX和offsetY

  4. 放缩比不能无限大或无限小,要限制范围在minScale和maxScale之间,否则将可以无限放大或缩小

  5. onScale中对事件消费返回值处理 只有放缩比在合理范围才消费,并且注掉15④和17步骤的代码

  6. 双击和放缩手势兼容处理,放缩具有较高的优先级,优先处理放缩触摸事件,并且注掉15②的代码

完整代码如下

/** 双击和捏撑可以放大缩小的ImageView
 * @author dongshuhuan
 * date 2020/12/18
 * version
 */
private val IMG_SIEZ = 300.dp.toInt()
private const val EXTRA_SACLE_FACTOR = 1.5F

class ScalableImageView2(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

  private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
  private val bitmap = getAvatar(resources,IMG_SIEZ)
  private var originalOffsetX = 0f //原始横向偏移量
  private var originalOffsetY = 0f //原始纵向偏移量
  private var offsetX = 0f //额外滑动横向偏移量
  private var offsetY = 0f //额外滑动纵向偏移量
  private var minScale = 0f //最小缩放比
  private var maxScale = 0f //最大缩放比
  private val dshScaleGestureListener = DSHScaleGestureListener()
  private val dshGuestureListener = DSHGuestureListener()
  private val dshFlingRunnner = DshFlingRunnner()
  private val scaleGestureDetector = ScaleGestureDetector(context,dshScaleGestureListener)
  var big = false//放大了么
  //5&6 手势监听
  private val gestureDetector = GestureDetectorCompat(context,dshGuestureListener)
  //15 ③ 放缩系数值要改变,onDraw和onSizeChanged也需要修改
  private var currentScale = 0f
    set(value) {
      field = value
      invalidate()
    }
  private val scaleAnimator:ObjectAnimator = ObjectAnimator.ofFloat(this,"currentScale",minScale,maxScale)

  private val scroller = OverScroller(context)


  override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    val scaleFraction = (currentScale-minScale)/(maxScale-minScale)
    //9.移动图片②绘制时横移
    canvas.translate(offsetX*scaleFraction,offsetY*scaleFraction)
    //4. 缩放
    canvas.scale(currentScale,currentScale,width/2f,height/2f)
    //1. 绘制图片
    canvas.drawBitmap(bitmap,originalOffsetX,originalOffsetY,paint)
  }


  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    //2. 图片位置x,y位置偏移量动态计算
    originalOffsetX = (width- IMG_SIEZ)/2f
    originalOffsetY = (height- IMG_SIEZ)/2f
    //3. 要算出较小缩放比值和较大缩放比值
    //宽高比大于屏幕宽高比的,胖图
    if (bitmap.width/bitmap.height.toFloat()>width/height.toFloat()){
      minScale = width/bitmap.width.toFloat()
      maxScale = height/bitmap.height.toFloat()*EXTRA_SACLE_FACTOR
    }else{
      //瘦图
      minScale = height/bitmap.height.toFloat()
      maxScale = width/bitmap.width.toFloat()*EXTRA_SACLE_FACTOR
    }
    currentScale = minScale
    scaleAnimator.setFloatValues(minScale,maxScale)
  }

  override fun onTouchEvent(event: MotionEvent?): Boolean {
//    //5. 覆盖原生onTouchEvent, 挂载手势监听事件
////    return gestureDetector.onTouchEvent(event)
//    //15 ②用scaleGestureDetector替代gestureDetector,监听onTouch事件
//    return scaleGestureDetector.onTouchEvent(event)

    //19. 双击和放缩手势兼容处理,放缩具有较高的优先级,优先处理放缩触摸事件
    scaleGestureDetector.onTouchEvent(event)
    if (!scaleGestureDetector.isInProgress){
      gestureDetector.onTouchEvent(event)
    }
    return true

  }

  private fun fixOffset() {
    //横向滑动边界限制
    offsetX = min(offsetX,(bitmap.width*maxScale-width)/2)
    offsetX = max(offsetX,-(bitmap.width*maxScale-width)/2)
    //纵向滑动边界限制
    offsetY = min(offsetY, (bitmap.height * maxScale - height) / 2)
    offsetY = max(offsetY, -(bitmap.height * maxScale - height) / 2)
  }


  //14 将多个接口抽离为一个类
  inner class DSHGuestureListener:GestureDetector.SimpleOnGestureListener(){

    override fun onDown(e: MotionEvent?): Boolean {
      return true
    }

    override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
      // 惯性滑动
      // 或快速滑动 如微信列表快速滑动,当我们抬起之后,列表继续滑动,这时候会触发onFling
      if (big){
        //10. ①防止白边处理,设置图片惯性滑动距离,并在下一帧刷新
        scroller.fling(offsetX.toInt(),offsetY.toInt(),velocityX.toInt(),velocityY.toInt(),
            -((bitmap.width*maxScale-width)/2).toInt(), ((bitmap.width*maxScale-width)/2).toInt(),
            -((bitmap.height*maxScale-height)/2).toInt(),((bitmap.height*maxScale-height)/2).toInt(),
            40.dp.toInt(),40.dp.toInt())//两个40.dp代表惯性回弹距离
        //10. ②下一帧刷新
        ViewCompat.postOnAnimation(this@ScalableImageView2,dshFlingRunnner)
      }
      return false
    }

    override fun onScroll(
      downEvent: MotionEvent?,
      currentEvent: MotionEvent?,
      distanceX: Float,
      distanceY: Float): Boolean {
      //9.移动图片①监听滑动距离
      if (big){
        offsetX -= distanceX
        offsetY -= distanceY
        fixOffset()
        invalidate()
      }
      return false;
    }

    override fun onDoubleTap(e: MotionEvent): Boolean {
      //两次点击大于40ms并且小于300ms被认定是双击,否则false
      //注意:第二次触摸到屏幕时就调用,而不是抬起时
      //7. 在双击事件回调中标记状态重新绘制图片
      big = !big
      //8. 放缩动画
      if (big){
        //12. 双击触点偏移修正(防止在任何地方双击都从中心点开始放大),偏移量处理
        offsetX = (e.x-width/2f)*(1-maxScale/minScale)
        offsetY = (e.y-height/2f)*(1-maxScale/minScale)
        //12 end
        //13 边缘修正
        fixOffset()
        scaleAnimator.start()
      }else{
        scaleAnimator.reverse()
      }
      return true
    }
  }

  inner class DshFlingRunnner:Runnable{
    override fun run() {
      if (scroller.computeScrollOffset()) {
        // 把此时的位置应用于界面
        offsetX = scroller.currX.toFloat()
        offsetY = scroller.currY.toFloat()
        invalidate()
        // 下一帧刷新
        ViewCompat.postOnAnimation(this@ScalableImageView2,this)
      }
    }

  }

  //15 ①内部类监听放缩手势事件
  inner class DSHScaleGestureListener:ScaleGestureDetector.OnScaleGestureListener{
    override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
      //16 放缩触点偏移量修正
      offsetX = (detector.focusX-width/2f)*(1-maxScale/minScale)
      offsetY = (detector.focusY-height/2f)*(1-maxScale/minScale)
      return true
    }

    override fun onScaleEnd(detector: ScaleGestureDetector?) {

    }

    override fun onScale(detector: ScaleGestureDetector): Boolean {
      //18 事件消费返回值处理 只有放缩比在合理范围才消费
      val tempCurrentScale = currentScale * detector.scaleFactor
      if (tempCurrentScale<minScale||tempCurrentScale>maxScale){
        return false
      }else{
        currentScale *= detector.scaleFactor//0 1; 0 无穷
        return true
      }
//      //15 ④onScale监听事件中动态计算currentScale的值
//      currentScale *= detector.scaleFactor//0 1; 0 无穷
//      //17 放缩比不能无限大或无限小,要限制范围
//      //coerceAtLeast等效Math.min, coerceAtMost等效Math.max
//      currentScale = currentScale.coerceAtLeast(minScale).coerceAtMost(maxScale)
//      return true
    }

  }

}