一、概述
本文对MotionEvent做了详细的介绍,并且对触点的获取方法和意义做了具体的讲解,最后再通过一个缩放图片的案例来详细介绍了多点触控的应用场景。温馨提示:如果不知道本文下面的这些代码要写在哪里,一头雾水的,就回去看一下上一篇文章吧。
二、MotionEvent详解
MotionEvent 是 Android 系统中处理用户输入事件的核心类。就像下面这个 onTouchEvent 方法,他会包含一个MotionEvent 的参数,那这个参数包含了什么?能有什么用呢?
override fun onTouchEvent(event: MotionEvent?): Boolean
① 获取核心事件类型
基础事件:
ACTION_DOWN:手指首次接触屏幕时触发,标记事件序列开始。ACTION_MOVE:触摸点在屏幕上移动时触发。ACTION_UP:最后一个触点离开屏幕时触发,标记事件序列结束。ACTION_CANCEL:事件被上层拦截时触发。
多点触控事件:
ACTION_POINTER_DOWN:非首个触点按下时触发。ACTION_POINTER_UP:非最后一个触点松开时触发。
其他事件:
ACTION_SCROLL:鼠标滚轮或轨迹球触发。ACTION_OUTSIDE:触摸超出控件边界时触发。
有这么多的类型,怎么获取呢?这里有两个方法。
val action = event?.actionMasked
val action = event?.action
这两个方法有时候用起来差不多,但是细节上还是有很大的区别的:
action表示事件的原始动作值,包含动作类型(低8位)和触摸点索引(高8位)的组合。
局限性:无法直接识别多指操作。例如,ACTION_POINTER_DOWN等事件会返回包含索引的复合值,需手动解析:通过 action & 0xFF提取低8位动作类型。例如,0x0105 & 0xFF = 0x05(即 ACTION_POINTER_DOWN)
actionMasked通过掩码(ACTION_MASK)过滤掉索引信息,仅保留纯动作类型。你不需要关心是哪个特定的触摸点发生了事件,只关心事件的“动作类型”。
一般用于识别 ACTION_POINTER_DOWN、ACTION_POINTER_UP等多指事件。
常见案例代码:
val action = event?.actionMasked
when (action) {
MotionEvent.ACTION_POINTER_DOWN -> {
//...
}
MotionEvent.ACTION_MOVE -> {
//...
}
MotionEvent.ACTION_UP -> {
//...
}
}
② 获取坐标系统
- 相对坐标:
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的参数,这个参数是有什么意义,怎么使用会在下面讲到。
- 其他属性
getPressure():触控压力值(0~1,部分设备可能固定为1)。
getEdgeFlags():标记触点起始边界(如 EDGE_LEFT)。
③ 获取滑动距离
在图片缩放场景中,我们需要根据滑动距离干事情,对于获得滑动距离
三、PointerId 与 Index
PointerId 与 PointerIndex 是跟踪 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_DOWN 或 ACTION_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 方向的缩放: scaleX 与 scaleY。我们可以通过调节这个属性来实现缩放的效果。
想要实现缩放的功能,我们很自然的就会想到根据两指之间的距离来调整缩放比例。获取一下 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())
}
但是缩放总是向中间的,为了实现往两个手指之间缩放,设置一个聚焦点,focusX和focusY。
实现缩放的同时往聚焦点方向移动整个视图。
由图可见:
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
放大后单手移动图片变慢的原因,我估计,translationX 和 translationY 的变化量是图片的每个像素相对于屏幕的移动距离,像素点放大之后,会导致图片在屏幕上移动的距离变小。
6.代码分享
欢迎光临我的CoreCodeLibrary
ScalableImageView缩放图片代码
喜欢的话记得给 star 哦!
五、技术指导
Android 技术指导 :小王学长