Android多点触控简单实践

1,421 阅读5分钟

单点触控实现

实现一个触摸移动图片的View

  • onTouchEvent监听move和down事件
  • 移动时重新绘制
class MultiTouchView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
  private val bitmap = getAvatar(resources,200.dp.toInt())
  private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
  private var originalX = 0f
  private var originalY = 0f
  private var offsetX = 0f
  private var offsetY = 0f
  private var downX = 0f
  private var downY = 0f

  override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    canvas.drawBitmap(bitmap,offsetX,offsetY,paint)
  }

  override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.actionMasked) {
      ACTION_DOWN ->{
        downX = event.x
        downY = event.y
        originalX = offsetX
        originalY = offsetY
      }
      ACTION_MOVE -> {
        offsetX = event.x - downX + originalX
        offsetY = event.y - downY + originalY
        invalidate()
      }
    }
    return true
  }

}

现在我们先用一个手指移动图片不松开,然后另外一个手指点击图片,松开第一个手指,发现图片发生了位移,图片会移动到我们第二个手指的触摸位置,这是因为我们还没有处理多点触控事件,下面我们要处理多点触控事件,先来看下概念

触摸事件的结构

  • 触摸事件是按序列来分组的,每一组事件必然以 ACTION_DOWN 开头,以 ACTION_UP 或 ACTION_CANCEL 结束。
  • ACTION_POINTER_DOWN 和 ACTION_POINTER_UP 和 ACTION_MOVE 一样, 只是事件序列中的组成部分,并不会单独分出新的事件序列
  • 触摸事件序列是针对 View 的,而不是针对 pointer 的。「某个 pointer 的事 件」这种说法是不正确的。
  • 在一个触摸事件里,每个 Pointer 除了 x 和 y 之外,还有 index 和 id。
  • 「移动的那个手指」这个概念是伪概念,「寻找移动的那个手指」这个需求是个 伪需求。

多点触控事件序列

ACTION_DOWN p(x, y, index, id)
ACTION MOVE p(x, y, index, id)
ACTION_MOVE p(x, y, index, id)
ACTION_MOVE p(x, y, index, id)
ACTION_POINTER_DOWN p(x, y, index, id) p(x, y, index, id)
ACTION_MOVE p(x, y, index, id)p(x, y, index, id)
ACTION_MOVE p(x, y, index, id)p(x, y, index, id)
ACTION POINTER_DOWN
ACTION MOVE
ACTION POINTER_UP
ACTION MOVE
ACTION POINTER_UP
ACTION MOVE
ACTION_UP

MotionEvent.getActionMasked()

  • ACTION_DOWN 第一个手指按下(之前没有任何手指触摸到 View)
  • ACTION_UP 最后一个手指抬起(抬起之后没有任何手指触摸到 View,这个手指未必是 ACTION_DOWN 的那个手指)
  • ACTION_MOVE 有手指发生移动
  • ACTION_POINTER_DOWN 额外手指按下(按下之前已经有别的手指触摸到View)
  • ACTION_POINTER_UP 有手指抬起,但不是最后一个(抬起之后,仍然还有别的手指在触摸着 View)

MotionEvent.getActionIndex(index)

获取「非第一根按下手指」或「非最后一根抬起手指」的 index

多点触控的三种类型

  • 接力型
    同一时刻只有一个 pointer 起作用,即最新的 pointer。 典型: ListView、RecyclerView。 实现方式:在 ACTION_POINTER_DOWN 和 ACTION_POINTER_UP 时记录下最新的 pointer,在之后的 ACTION_MOVE 事 件中使用这个 pointer 来判断位置。
  • 配合型 / 协作型
    所有触摸到 View 的 pointer 共同起作用。 典型:ScaleGestureDetector,以及 GestureDetector 的 onScroll() 方法判断。 实现方式:在每个 DOWN、POINTER_DOWN、POINTER_UP、UP 事件中 使用所有 pointer 的坐标来共同更新焦点坐标,并在 MOVE 事件中使用所有 pointer 的坐标来判断位置。
  • 各自为战型
    各个 pointer 做不同的事,互不影响。 典型:支持多画笔的画板应 用。 实现方式:在每个 DOWN、POINTER_DOWN 事件中记录下每个 pointer 的 id,在 MOVE 事件中使用 id 对它们进行跟踪。

接力型

  • 比如图片多点触控,同一时间只有一个手指的触摸事件生效
  • 除了ACTION_DOWN和ACTION_MOVE,还要处理ACTION_POINTER_DOWN和ACTION_POINTER_UP事件
  • 完整代码如下:
class MultiTouchView2(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
  private val bitmap = getAvatar(resources,200.dp.toInt())
  private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
  private var originalX = 0f
  private var originalY = 0f
  private var offsetX = 0f
  private var offsetY = 0f
  private var downX = 0f
  private var downY = 0f
  private var trackingPointerId = 0//唯一手指id

  override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    canvas.drawBitmap(bitmap,offsetX,offsetY,paint)
  }

  override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.actionMasked) {
      ACTION_DOWN -> {
        trackingPointerId = event.getPointerId(0)
        downX = event.x
        downY = event.y
        originalX = offsetX
        originalY = offsetY
      }
      ACTION_POINTER_DOWN -> {//多指触控 按下
        val actionIndex = event.actionIndex
        trackingPointerId = event.getPointerId(actionIndex)
        downX = event.getX(actionIndex)
        downY = event.getY(actionIndex)
        originalX = offsetX
        originalY = offsetY
      }
      ACTION_POINTER_UP -> {//多指触控 抬起
        val actionIndex = event.actionIndex
        val pointerId = event.getPointerId(actionIndex)
        if (pointerId == trackingPointerId){
          var newIndex = -1 ;
          if (actionIndex == event.pointerCount - 1 ){//表示这是最后一个触控的手指
            newIndex = event.pointerCount - 2
          }else{
            newIndex = event.pointerCount - 1
          }
          trackingPointerId = event.getPointerId(newIndex)
          downX = event.getX(newIndex)
          downY = event.getY(newIndex)
          originalX = offsetX
          originalY = offsetY
        }
      }
      ACTION_MOVE -> {
        val index = event.findPointerIndex(trackingPointerId)
        offsetX = event.getX(index) - downX + originalX
        offsetY = event.getY(index) - downY + originalY
        invalidate()
      }
    }
    return true
  }

}

协作型

  • 比如图片两个多点触摸
    • 一个手指移动,另外一个不移动,半速移动
    • 两个手指同一方向移动,全速移动
    • 两个手指反向移动,移动快的方向差值半速移动
  • 使用所有 pointer 的坐标来共同更新焦点坐标
  • 完整代码如下:
/** 多点触控移动图片 协作型
 * @author dongshuhuan
 * date 2020/12/22
 * version
 */
class MultiTouchView3(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
  private val bitmap = getAvatar(resources,200.dp.toInt())
  private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
  private var originalX = 0f
  private var originalY = 0f
  private var offsetX = 0f
  private var offsetY = 0f
  private var downX = 0f
  private var downY = 0f

  override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    canvas.drawBitmap(bitmap,offsetX,offsetY,paint)
  }

  override fun onTouchEvent(event: MotionEvent): Boolean {
    val focusX:Float
    val focusY:Float
    var pointerCount = event.pointerCount
    var sumX = 0f
    var sumY = 0f
    val isPointerUp = event.actionMasked == ACTION_POINTER_UP
    for (i in 0 until pointerCount){
      if (!(isPointerUp && i == event.actionIndex)){
        sumX += event.getX(i)
        sumY += event.getY(i)
      }
    }
    if (isPointerUp){
      pointerCount--
    }
    focusX = sumX/pointerCount
    focusY = sumY/pointerCount

    when (event.actionMasked) {
      ACTION_DOWN,ACTION_POINTER_DOWN, ACTION_POINTER_UP -> {
        downX = focusX
        downY = focusY
        originalX = offsetX
        originalY = offsetY
      }
      ACTION_MOVE -> {
        offsetX = focusX - downX + originalX
        offsetY = focusY - downY + originalY
        invalidate()
      }
    }
    return true
  }

}

各自为战型

  • 比如涂鸦画板

单点涂鸦绘制

  • 监听ACTION_DOWN和ACTION_MOVE
  • 通过path.moveTo和path.lineTo记录path变化
  • 然后invalidate()生效
class MultiTouchView4(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
  private val paint= Paint(Paint.ANTI_ALIAS_FLAG)
  private var path = Path()

  init {
    paint.style = Paint.Style.STROKE
    paint.strokeWidth = 4.dp
    paint.strokeCap = Paint.Cap.ROUND
    paint.strokeJoin = Paint.Join.ROUND
  }

  override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    canvas.drawPath(path,paint)
  }

  override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.actionMasked) {
      MotionEvent.ACTION_DOWN -> {
        path.moveTo(event.x,event.y)
        invalidate()
      }
      MotionEvent.ACTION_MOVE -> {
        path.lineTo(event.x,event.y)
        invalidate()
      }
       MotionEvent.ACTION_UP -> {
        path.reset()
        invalidate()
      }
    }
    return true
  }

}

多点涂鸦绘制

  • 首先用SparseArray记录每个手指的轨迹
  • 然后在onTouch中记录按下移动抬起等事件
  • 最后在onDraw中重绘
  • 效果如图
  • 完整代码如下
/** 多点触控 互不干扰型
 * 例:涂鸦画板
 * @author dongshuhuan
 * date 2020/12/22
 * version
 */
class MultiTouchView4(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
  private val paint= Paint(Paint.ANTI_ALIAS_FLAG)
  private var paths = SparseArray<Path>()

  init {
    paint.style = Paint.Style.STROKE
    paint.strokeWidth = 4.dp
    paint.strokeCap = Paint.Cap.ROUND
    paint.strokeJoin = Paint.Join.ROUND
  }

  override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    for (i in 0 until paths.size()){
      val path = paths.valueAt(i)
      canvas.drawPath(path,paint)
    }

  }

  override fun onTouchEvent(event: MotionEvent): Boolean {
  //需要捕获异常,否则会报出java.lang.IllegalArgumentException: pointerIndex out of range异常,这是一个谷歌官方bug,至今未修复
    try {
      when (event.actionMasked) {
        MotionEvent.ACTION_DOWN,MotionEvent.ACTION_POINTER_DOWN -> {
          val actionIndex = event.actionIndex
          val path = Path()
          path.moveTo(event.getX(actionIndex),event.getY(actionIndex))
          paths.append(event.getPointerId(actionIndex),path)
          invalidate()
        }
        MotionEvent.ACTION_MOVE -> {
          for (i in 0 until paths.size()){
            val pointerId = event.getPointerId(i)
            val path = paths.get(pointerId)
            path.lineTo(event.getX(i),event.getY(i))
          }
          invalidate()
        }
        MotionEvent.ACTION_UP,MotionEvent.ACTION_POINTER_UP -> {
          val actionIndex = event.actionIndex
          val pointerId = event.getPointerId(actionIndex)
          paths.remove(pointerId)
          invalidate()
        }
      }
    }catch (e:IllegalArgumentException){
      e.printStackTrace()
    }
    
    return true
  }
  
}