Android交互五剑客(二):多点触控--图片缩放

347 阅读9分钟

一、概述

本文对MotionEvent做了详细的介绍,并且对触点的获取方法和意义做了具体的讲解,最后再通过一个缩放图片的案例来详细介绍了多点触控的应用场景。温馨提示:如果不知道本文下面的这些代码要写在哪里,一头雾水的,就回去看一下上一篇文章吧。

二、MotionEvent详解

MotionEvent 是 Android 系统中处理用户输入事件的核心类。就像下面这个 onTouchEvent 方法,他会包含一个MotionEvent 的参数,那这个参数包含了什么?能有什么用呢?

override fun onTouchEvent(event: MotionEvent?): Boolean

① 获取核心事件类型

基础事件:

  1. ACTION_DOWN:手指首次接触屏幕时触发,标记事件序列开始。
  2. ACTION_MOVE:触摸点在屏幕上移动时触发。
  3. ACTION_UP:最后一个触点离开屏幕时触发,标记事件序列结束。
  4. ACTION_CANCEL:事件被上层拦截时触发。

多点触控事件:

  1. ACTION_POINTER_DOWN非首个触点按下时触发。
  2. ACTION_POINTER_UP非最后一个触点松开时触发。

其他事件:

  1. ACTION_SCROLL:鼠标滚轮或轨迹球触发。
  2. ACTION_OUTSIDE:触摸超出控件边界时触发。

有这么多的类型,怎么获取呢?这里有两个方法。

val action = event?.actionMasked
val action = event?.action

这两个方法有时候用起来差不多,但是细节上还是有很大的区别的:

  1. action表示事件的原始动作值,包含动作类型(低8位)和触摸点索引(高8位)的组合。

局限性:无法直接识别多指操作。例如,ACTION_POINTER_DOWN等事件会返回包含索引的复合值,需手动解析:通过 action & 0xFF提取低8位动作类型。例如,0x0105 & 0xFF = 0x05(即 ACTION_POINTER_DOWN

  1. actionMasked通过掩码(ACTION_MASK)过滤掉索引信息,仅保留纯动作类型。你不需要关心是哪个特定的触摸点发生了事件,只关心事件的“动作类型”。

一般用于识别 ACTION_POINTER_DOWNACTION_POINTER_UP等多指事件。

常见案例代码:

val action = event?.actionMasked

when (action) {
    MotionEvent.ACTION_POINTER_DOWN -> {
        //...
    }

    MotionEvent.ACTION_MOVE -> {
        //...
    }

    MotionEvent.ACTION_UP -> {
        //...
    }
}

② 获取坐标系统

  1. 相对坐标:

getX() / getY():相对于当前 View 左上角的坐标。

public final float getX(int pointerIndex) {
    return nativeGetAxisValue(mNativePtr, AXIS_X, pointerIndex, HISTORY_CURRENT);
}

2. 绝对坐标:

getRawX() / getRawY():相对于屏幕左上角的坐标。

public float getRawX(int pointerIndex) {
    return nativeGetRawAxisValue(mNativePtr, AXIS_X, pointerIndex, HISTORY_CURRENT);
}

通过观察这些方法,我们发现它需要传递一个叫pointerIndex的参数,这个参数是有什么意义,怎么使用会在下面讲到。

  1. 其他属性

getPressure():触控压力值(0~1,部分设备可能固定为1)。

getEdgeFlags():标记触点起始边界(如 EDGE_LEFT)。

③ 获取滑动距离

在图片缩放场景中,我们需要根据滑动距离干事情,对于获得滑动距离

三、PointerId 与 Index

PointerIdPointerIndex 是跟踪 MotionEvent 中的各个指针位置的关键所在。

官网推荐使用PointerId而非PointerIndex追踪触摸点,因为Index会因补位机制动态变化,而PointerId在手指按下至抬起期间固定不变。接下来我们就来稍微了解一下Index的变换规则。

1.PointerIndex 的变换规则

① 索引连续性原则

PointerIndex始终从 0 开始连续分配。当中间某个触摸点抬起后,后续触摸点的 Index 会**「前移补位」**,确保索引值的连续性。

例如:三指按下时,Index 依次为 0、1、2(对应 PointerId 0、1、2)若中间手指(Index=1)抬起,剩余两指的 Index 变为 0、1,但 PointerId 仍为 0 和 2。

② 新增触摸点的索引分配

新手指按下时,系统会优先填补空缺Index 值,否则按当前最大 Index+1 分配。

2.PointerId 的规则

① 唯一性与稳定性

每个手指按下时会被分配一个唯一且固定PointerId,在手指未抬起前,该值始终不变。

② 补位机制无关性

与动态变化的PointerIndex不同,PointerId不会因其他手指的增减发生补位调整。

3.获取指针位置

先看一下两个方法:

/**
 *返回与特定指针关联的指针标识符
 *此事件中的数据索引。标识符告诉实际的指针
 *与数据关联的数字,考虑单个指针
 *从当前手势开始上下移动。
 *@param pointerIndex要检索的指针的原始索引
 */
public final int getPointerId(int pointerIndex) {
    return nativeGetPointerId(mNativePtr, pointerIndex);
}
/**
 *给定一个指针ID,在事件中找到其数据的索引。
 *@param pointerId要查找的指针的ID。
 *@return返回指针的索引,如果没有可用数据,则为-1
 */
public final int findPointerIndex(int pointerId) {
    return nativeFindPointerIndex(mNativePtr, pointerId);
}

既然明白了上面的规则,我们就很容易的搞清楚,在手指刚刚落下的时候,index 肯定是从 0 开始然后递增的,乘着当前的index还没被打乱,可以使用 getPointerId() 方法获取指针的ID,这样 index再怎么变都不需要我们去操心啦,我们只需要根据保留下来的 ID ,使用 findPointerIndex() 方法,获取给定指针ID在相应动作事件中的指针索引。然后去使用它。

private var mActivePointerId: Int = 0

override fun onTouchEvent(event: MotionEvent): Boolean {
    
    mActivePointerId = event.getPointerId(0)
    val (x: Float, y: Float) = event.findPointerIndex(mActivePointerId).let { pointerIndex ->  
        event.getX(pointerIndex) to event.getY(pointerIndex)
    }
}

这样变来变去的主要原因就是:Index这一特性使得Index趋向于首次分配的数值,但受动态调整影响。

再看一个特别的方法:

public final int getActionIndex() {
    return (nativeGetAction(mNativePtr) & ACTION_POINTER_INDEX_MASK)
    >> ACTION_POINTER_INDEX_SHIFT;
}

这个方法的核心作用就是:通过位运算MotionEvent 的动作值中提取 指针索引(Index),用于标识多点触控事件中具体是哪一个触控点触发ACTION_POINTER_DOWNACTION_POINTER_UP 事件。

特别的是:在Move事件中,index的这种动态性使得 getActionIndex() 在移动事件中无法稳定标识触控点,所以 getActionIndex()始终返回0。

四、滑动历史触点记录

1.MotionEvent.getHistoricalX()

可以获得之前的触摸点位置,用于计算手指滑动的距离。

在触摸事件的生命周期中,ACTION_DOWN 事件不会记录历史坐标,因为它表示触摸的开始,并且是触摸序列中的第一个事件。

看一下方法:

public final float getHistoricalX(int pointerIndex, int pos) {
    return nativeGetAxisValue(mNativePtr, AXIS_X, pointerIndex, pos);
}

在调用 getHistoricalX() 前,一般要检查 getHistorySize() > 0,否则可能因索引越界导致崩溃。

根据 ID 和 pos 能获取历史触摸点的坐标点信息,规则如下:

索引 pos 从 0 开始递增,表示历史点的顺序是按时间追加的:

pos=0 对应最早的历史点(即上一次 ACTION_MOVE 事件中最早记录的触摸点)。

pos=HistorySize-1 对应最新的历史点(当前 MotionEvent 触发前最后一个记录的触摸点)。

2.自行记录

也就是自己开个变量记录一下上次滑动之后的点。不断的迭代数据,这里就不展示了。

五、图片缩放功能的实现

1.定向缩放

View 有两个缩放相关的属性,分别对应的 XY 方向的缩放: scaleXscaleY。我们可以通过调节这个属性来实现缩放的效果。

想要实现缩放的功能,我们很自然的就会想到根据两指之间的距离来调整缩放比例。获取一下 xy 的坐标差,加个勾股定理就能很容易获取两指的距离啦。

// 获取当前两个触摸点之间的距离
val currentDistance = getDistance(event)
// 计算两次距离的变化
val distanceChange = abs(currentDistance - prevDistance)

private fun getDistance(event: MotionEvent): Double {
    val dx = event.getX(0) - event.getX(1)
    val dy = event.getY(0) - event.getY(1)
    return sqrt((dx * dx + dy * dy).toDouble())
}

但是缩放总是向中间的,为了实现往两个手指之间缩放,设置一个聚焦点,focusXfocusY

实现缩放的同时往聚焦点方向移动整个视图。

由图可见:

val newTranslationX =
	translationX + (focusX - width / 2) * (1 - scale)
val newTranslationY =
	translationY + (focusY - height / 2) * (1 - scale)

2.小幅度缩放导致图片抽搐

设置状态,防止放大缩小之间来回切换过于频繁导致的图片抽搐。

// 获取当前两个触摸点之间的距离
val currentDistance = getDistance(event)
// 计算两次距离的变化
val distanceChange = abs(currentDistance - prevDistance)
// 计算缩放比例
val scale = currentDistance / prevDistance
if (scaleState && scale > 1) {
	scaleFactor *= scale  // 放大
} else if (scaleState && scale < 1 && distanceChange > 2) {
	scaleState = false
} else if (!scaleState && scale > 1 && distanceChange > 2) {
	scaleState = true
} else if (!scaleState && scale < 1) {
	scaleFactor *= scale  // 缩小
}

// 计算视图的偏移量
val newTranslationX = (translationX + (focusX - width / 2) * (1 - scale))
val newTranslationY =
	translationY + (focusY - height / 2) * (1 - scale)

// 应用缩放到 ImageView
scaleX = scaleFactor.toFloat()
scaleY = scaleFactor.toFloat()

if (scaleFactor > 1.0 && getImageHeight() >= height) {
	translationX = newTranslationX.toFloat()
	translationY = newTranslationY.toFloat()
}
prevDistance = currentDistance

3.边界移动与缩放

限制左右滑动的距离,防止图片边离开屏幕的边。

添加边界情况的判断:

private fun getImageHeight(): Float {
	val drawable = drawable ?: return 0.0f
	val imageHeight = (drawable.intrinsicHeight * width).toFloat() / drawable.intrinsicWidth
	return ((imageHeight * scaleFactor).toFloat())
}

private fun isHorizontalCorner(dx: Float): Boolean {
	return (abs(translationX + dx * scaleFactor) < width * (scaleFactor - 1) / 2)
}

private fun isVerticalCorner(dy: Float): Boolean {
	return getImageHeight() >= height && abs(translationY + dy * scaleFactor) < (getImageHeight() - height) / 2
}

private fun isRightCorner(): Boolean {
	val maxTranslationX = width * (scaleFactor - 1) / 2
	return -translationX > maxTranslationX
}

private fun isLeftCorner(): Boolean {
	val maxTranslationX = width * (scaleFactor - 1) / 2
	return translationX > maxTranslationX
}

private fun isTopCorner(): Boolean {
	val maxTranslationY = (getImageHeight() - height) / 2
	return  translationY > maxTranslationY
}

private fun isBottomCorner(): Boolean {
	val maxTranslationY = (getImageHeight() - height) / 2
	return  -translationY > maxTranslationY
}

4.当缩放过程中图片出现偏差时

手指抬起,图片边界通过动画效果和手机边缘贴合

MotionEvent.ACTION_UP -> {
	if (scaleFactor < 1.0) {
		val reboundAnimatorScaleX = ObjectAnimator.ofFloat(this, "scaleX", 1.0f)
		val reboundAnimatorScaleY = ObjectAnimator.ofFloat(this, "scaleY", 1.0f)
		val reboundAnimatorTransX = ObjectAnimator.ofFloat(this, "translationX", 0f)
		val reboundAnimatorTransY = ObjectAnimator.ofFloat(this, "translationY", 0f)
		val wholeAnim = AnimatorSet()
		wholeAnim.playTogether(
			reboundAnimatorScaleX,
			reboundAnimatorScaleY,
			reboundAnimatorTransX,
			reboundAnimatorTransY
		)
		wholeAnim.interpolator = mBounceBackInterpolator
		wholeAnim.start()
		scaleFactor = 1.0
	} else if (isRightCorner()) {
		val reboundAnimatorTransX = ObjectAnimator.ofFloat(
			this,
			"translationX",
			-(width * (scaleFactor - 1) / 2).toFloat()
		)

		reboundAnimatorTransX.interpolator = mBounceBackInterpolator
		reboundAnimatorTransX.start()
	}
	else if (isLeftCorner()){
		val reboundAnimatorTransX = ObjectAnimator.ofFloat(
			this,
			"translationX",
			(width * (scaleFactor - 1) / 2).toFloat()
		)

		reboundAnimatorTransX.interpolator = mBounceBackInterpolator
		reboundAnimatorTransX.start()
	}

	if (getImageHeight() - height >= 0 && isTopCorner()){
		val reboundAnimatorTransY = ObjectAnimator.ofFloat(this, "translationY", ((getImageHeight() - height)/ 2))
		reboundAnimatorTransY.interpolator = mBounceBackInterpolator
		reboundAnimatorTransY.start()
	}
	else if (getImageHeight() - height >= 0 && isBottomCorner()){
		val reboundAnimatorTransY = ObjectAnimator.ofFloat(this, "translationY", -((getImageHeight() - height) / 2))
		reboundAnimatorTransY.interpolator = mBounceBackInterpolator
		reboundAnimatorTransY.start()
	}
}

5.单指滑动同时也要支持

if (event.historySize > 0 && scaleFactor > 1.0) {
	val dx = event.x - event.getHistoricalX(0, event.historySize-1)
	val dy = event.y - event.getHistoricalY(0, event.historySize-1)

	// 限制图片的移动范围
	if (isHorizontalCorner(dx)) {
		translationX += dx * scaleFactor.toFloat()
	}
	if (isVerticalCorner(dy)) {
		translationY += dy * scaleFactor.toFloat()
	}
}

但是放大之后图片移动的速度会变得特别慢,所以要乘scaleFactor

放大后单手移动图片变慢的原因,我估计,translationXtranslationY 的变化量是图片的每个像素相对于屏幕的移动距离,像素点放大之后,会导致图片在屏幕上移动的距离变小。

6.代码分享

欢迎光临我的CoreCodeLibrary

ScalableImageView缩放图片代码

喜欢的话记得给 star 哦!

五、技术指导

Android 技术指导 :小王学长