Leo's Studio.

触摸事件实践之路

字数统计: 3.5k阅读时长: 13 min
2018/04/03

前言

最近帮公司面试 Android 岗位,面试的同学大部分具有两年以上的工作经验,但发现很多同学对触摸事件的分发都不是很熟悉,有的可能还能照本宣科地说个七七八八,有的却完全不熟悉,这让我感觉到诧异,因为我认为这 Android 中非常基础的知识,不仅要熟悉其中的分发流程,还要能实现简单的涉及手势处理的自定义控件。所以,这也是这篇文章的目的,希望能通过一些触摸事件处理的实际例子,来加深对触摸事件分发的理解,同时能实现相关需求。

触摸事件简单回顾

我们都知道触摸事件的分发严格意义上讲,是从 Activity 开始进行分发,但一般我们谈论的,只从 ViewGroup 开始分发。

TouchEvent

上面的图只是粗略地画出触摸事件的分发流程,从 ViewGroup 的 dispatchTouchEvent 开始分发,先判断 ViewGroup 的 onInterceptTouchEvent 是否拦截,同时这里也可以调用 ViewGroup 的 requestDisallowInterceptTouchEvent 让 ViewGroup 不调用 onInterceptTouchEvent,如果事件被拦截,则调用 ViewGroup 的超类即 View 的 dispatchTouchEvent,反之,则调用子视图的 dispatchTouchEvent,注意:这里我们做了简单处理,假设当前 ViewGroup 的子视图为 View,如果子视图为 ViewGroup,那么还是先执行 ViewGroup 的 dispatchTouchEvent

最终都会调用到 View 的 onTouchEvent 中,这个方法是真正处理触摸事件的,一般如果我们需要自己处理触摸事件,也是在这个方法中处理。

手势判定

上面我们简单回顾了触摸事件分发,具体细节可以看下源码。其实上面所讲的分发流程,很多同学都能说出个大概,但就是在动手写的时候,不知道如何入手。

当我们在日常开发中,如果遇到不知道怎么实现的需求时,最直接的方法就是阅读他人的源码,不管是个人的库也好,Google 提供的库也好,设计良好的源码抵过看一大堆的博客。在实现需要处理触摸事件的需求也是一个道理,Android SDK 已经提供了 ListView,RecyclerView,ScrollView 等等涉及拖动处理的系统控件,所以我们可以从阅读这类控件的源码入手,看看系统控件是如何处理触摸事件的。这里我们选择 ScrollView 来简单分析下:

ScrollView 继承于 FrameLayout,属于 ViewGroup 控件,所以有 onInterceptTouchEvent

因为篇幅有限,所以我们会省略去无关紧要的代码,各位同学可以对照着源码进行分析。

源码部分基于 Android 26

onInterceptTouchEvent

1
2
3
if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) {
return true;
}

mIsBeingDragged 变量来保存当前是否已经开始进行拖动手势,这个后面会讲到,同时当前分发事件类型为 ACTION_MOVE,那么直接返回 true,即拦截事件向子视图进行分发。这个是一个快捷的判断,省去了后面再进行一系列的判断,这个是个小技巧,我们在实现自定义控件的时候也可以用上。接下来的分析,为了更清晰,我们分为不同类型的 Touch Action 进行阅读。

TOUCH_DOWN

1
2
3
4
5
if (!inChild((int) ev.getX(), (int) y)) {
mIsBeingDragged = false;
recycleVelocityTracker();
break;
}

这段代码很简单,如果触摸事件没有作用于子视图范围内,那么就不处理,同时释放速度跟踪器,这个后面会讲到,一般用于 fling 手势的判定。

1
2
3
4
5
6
7
8
9
mLastMotionY = y;
mActivePointerId = ev.getPointerId(0);
initOrResetVelocityTracker();
mVelocityTracker.addMovement(ev);

mScroller.computeScrollOffset();
mIsBeingDragged = !mScroller.isFinished();

startNestedScroll(SCROLL_AXIS_VERTICAL);

这段代码主要是用于初始化判定之前的资源,比如 mLastMotionY 记录按下时的坐标信息,mActivePointerId 记录当前分发触摸事件的手指 id,这个一般用于多指的处理,initOrResetVelocityTracker 初始化速度跟踪器,同时使用 addMovement 记录当前触摸事件信息,mScroller 是一般用于 fling 手势处理,这里的作用是处理上一次的 fling,startNestedScroll 则是嵌套滚动机制的知识了,嵌套滚动机制也不难理解,但这里我们先不涉及,相信理解基础的触摸事件知识后,这个只要稍微阅读下源码,就能理解的,说句题外话,虽然嵌套滚动很容易理解,作用却非常大,基本解决了大部分的滑动冲突场景。

TOUCH_MOVE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
final int activePointerId = mActivePointerId;
if (activePointerId == INVALID_POINTER) {
break;
}
final int pointerIndex = ev.findPointerIndex(activePointerId);
if (pointerIndex == -1) {
break;
}
final int y = (int) ev.getY(pointerIndex);
final int yDiff = Math.abs(y - mLastMotionY);
if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) {
mIsBeingDragged = true;
mLastMotionY = y;
initVelocityTrackerIfNotExists();
mVelocityTracker.addMovement(ev);
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}

这段代码先使用我们在 TOUCH_DOWN 中记录的 mActivePointerId 进行是否为有效的判断,如果有效,则通过 findPointerIndex 获取作用手指 id 的下标,记录为 pointerIndex ,为什么要获取这个值,我们知道现在的手机屏幕都是支持多指触摸的,所以我们需要根据某个按下的手指的触摸信息来进行处理。yDiff 是滑动的距离,mTouchSlop 则是 SDK 定义的可作为判定是否开始进行拖动的距离常量,可以通过 ViewConfiguration 的 getScaledTouchSlop 获取,如果大于这个值,我们可以认为开始了拖动的手势。 getNestedScrollAxes 这个同样是用于嵌套滚动机制的,可以略过。如果开始了拖动手势,mIsBeingDragged 标记为 true,同样使用速度跟踪器记录信息,这里还会调用 ViewParent 的 requestDisallowInterceptTouchEvent,防止父视图拦截了事件,即 onInterceptTouchEvent

TOUCH_CANCEL && TOUCH_UP

1
2
3
4
mIsBeingDragged = false;
mActivePointerId = INVALID_POINTER;
recycleVelocityTracker();
stopNestedScroll();

一般我们都会在 TOUCH_CANCELTOUCH_UP 这两个类型的触摸事件分发中,进行一些释放资源的操作,比如 mIsBeingDragged 设置为 false,释放速度跟踪器等等

TOUCH_UP 是所有的手指(多指触摸)抬起时分发的事件,这个比较好理解,而 TOUCH_CANCEL 则是触摸取消事件类型,一般什么时候会分发这个事件呢?举个例子,如果某个子视图已经消费了 TOUCH_DOWN,即在这个事件分发时,向父视图传递了 true 的返回值,那么一般情况下,父视图不会再拦截接下来的事件,比如 ACTION_MOVE 等,但是如果父视图在这种情况下,还拦截了事件传递,即在 onInterceptTouch 中返回了 true,那么在 ViewGroup 的 dispatchTouchEvent 中会给已经确认消费事件的子视图分发一个 TOUCH_CANCEL 的事件,具体可以阅读源码。

ACTION_POINTER_UP

这个为多指触摸时,某个手指抬起时分发的事件。

1
2
3
4
5
6
7
8
9
10
11
final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >>
MotionEvent.ACTION_POINTER_INDEX_SHIFT;
final int pointerId = ev.getPointerId(pointerIndex);
if (pointerId == mActivePointerId) {
final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
mLastMotionY = (int) ev.getY(newPointerIndex);
mActivePointerId = ev.getPointerId(newPointerIndex);
if (mVelocityTracker != null) {
mVelocityTracker.clear();
}
}

这段代码处理的是,当某个手指抬起时,而这个手指刚好是我们当前使用的,则重新初始化资源

小结

我们可以简单总结下,onInterceptTouchEvent 所进行的处理,即在 TOUCH_DOWN 资源初始化,TOUCH_MOVE 判断是否开始拖动手势,TOUCH_CANCEL && TOUCH_UP 中进行资源释放。这里涉及了多指触摸的处理。

onTouchEvent

onTouchEvent 要比 onInterceptTouch 的逻辑更复杂,因为这个方法是用于真正的消费触摸事件。同样的,我们只关心核心代码,略去无关紧要的代码片段。

TOUCH_DOWN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (getChildCount() == 0) {
return false;
}
if ((mIsBeingDragged = !mScroller.isFinished())) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}

if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}

mLastMotionY = (int) ev.getY();
mActivePointerId = ev.getPointerId(0);

同样的,onTouchEventTOUCH_DOWN 事件分发中,主要是进行资源初始化,同时也处理上一次的 fling 任务,比如调用 Scroller 的 abortAnimation,如果 Scroller 还没结束 fling 计算,则中止处理。

TOUCH_MOVE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
if (activePointerIndex == -1) {
break;
}
final int y = (int) ev.getY(activePointerIndex);
int deltaY = mLastMotionY - y;
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
// 嵌套滚动处理
deltaY -= mScrollConsumed[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
}
if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
mIsBeingDragged = true;
if (deltaY > 0) {
deltaY -= mTouchSlop;
} else {
deltaY += mTouchSlop;
}
}

if (mIsBeingDragged) {
/// 业务逻辑
}

这段代码同样会进行多指处理,获取指定手指的触摸事件信息。mIsBeingDraggedfalse,同时会再进行一次拖动手势的判定,判定逻辑和 onInterceptTouchEvent 中类似,如果 mIsBeingDraggedtrue,则开始进行真正的逻辑处理。

EdgeEffect 是用于拖动时,边缘的阴影效果,具体使用可以参考源码

TOUCH_UP

1
2
3
4
5
6
7
8
9
10
11
12
if (mIsBeingDragged) {
final VelocityTracker velocityTracker = mVelocityTracker;
velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId);

if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
flingWithNestedDispatch(-initialVelocity);
}

mActivePointerId = INVALID_POINTER;
endDrag();
}

当手指全部抬起时,可以使用速度跟踪器进行 fling 手势的判定,同时释放资源。通过 getYVelocity 获取速度,在判断是否可以作为 fling 手势处理,mMaximumVelocity 是处理的最大速度,mMinimumVelocity 是处理的最小速度,这两个值同样可以通过 ViewConfiguration 的 getScaledMaximumFlingVelocitygetScaledMinimumFlingVelocity 获取。一般情况对 fling 的处理是通过 Scroller 进行处理的,因为这里涉及复杂的数学知识,而 Scroller 可以帮我们简化这里的操作,使用如下:

1
2
3
4
5
6
7
int height = getHeight() - mPaddingBottom - mPaddingTop;
int bottom = getChildAt(0).getHeight();

mScroller.fling(mScrollX, mScrollY, 0, velocityY, 0, 0, 0,
Math.max(0, bottom - height), 0, height/2);

postInvalidateOnAnimation();

通过传递当前拖动手势速度值来调用 fling 进行处理,然后在 computeScrollOffset 方法中,进行真正的滚动处理:

1
2
3
4
5
6
7
8
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
// 逻辑处理
int x = mScroller.getCurrX();
int y = mScroller.getCurrY();
postInvalidateOnAnimation();
}
}

首先我们要知道 Scroller 并不会为我们进行滚动处理,它只是提供了计算的模型,通过调用 computeScrollOffset 进行计算,如果返回 true,表示计算还没结束,然后通过 getCurrXgetCurrY 获取计算后的值,最后进行真正的滚动处理,比如调用 scrollTo 等等,这里需要注意的是,需要调用 invalidate 来确保进行下一次的 computeScroll 调用,这里使用的 postInvalidateOnAnimation 其作用是类似的。

TOUCH_CANCEL

1
2
3
4
5
6
7
if (mIsBeingDragged && getChildCount() > 0) {
if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0, getScrollRange())) {
postInvalidateOnAnimation();
}
mActivePointerId = INVALID_POINTER;
endDrag();
}

同样的,一般我们都会在 TOUCH_CANCEL 中释放资源。

ACTION_POINTER_DOWN

当有新的手指按下时分发的事件

1
2
3
final int index = ev.getActionIndex();
mLastMotionY = (int) ev.getY(index);
mActivePointerId = ev.getPointerId(index);

以新按下的手指的信息重新计算

ACTION_POINTER_UP

这里的处理和 onInterceptTouch 一致

小结

onTouchEventonInterceptTouchEvent 处理有些相似,主要是在 TOUCH_MOVE 中在判定为拖动手势后进行真正的业务逻辑处理,同时在 TOUCH_UP 中根据速度跟踪器的获取的速度,判定是否符合 fling 手势,如果符合,则使用 Scroller 进行计算。

总结

在分析完 ScrollView 的触摸事件处理,我们应该对事件处理有个基本理解,可以按照这个思路去分析其他的类似的系统控件,比如 NestedScrollViewRecyclerView 等等,我们可以发现处理的思路基本一致,那我们是不是可以将这些判定逻辑封装下呢?是的,并且系统已经提供 GestureDetector 来进行手势的判定,我们只需要在相应的手势回调方法中进行我们的业务逻辑即可。还有更强大的 ViewDragHelper ,但不管怎样,只要能理解好触摸事件分发,对工具类的熟练使用就不在话下。

实战

理论说的再多,也是纸上谈兵,只有真正去实践,才能熟练掌握。因为业务需求或者兴趣爱好,我写了以下两个自定义控件,正好一个是纵向滑动和一个是横向滑动,效果如下:

WheelView

TapeView

详细的代码分析我们就不进行了,因为阅读源码是最快的方式,第一个是滚轮控件,第二个是之前参加活动的仿薄荷健康的卷尺控件。在这里我们只分析部分代码:

滚轮控件

在前面手势判定中的分析中,我们提到在 onTouchEvent 判定拖动手势成功后,进行真正的业务逻辑处理,在这个控件中也是一样的:

1
2
3
4
if (mIsBeingDragged) {
mDistanceY += mLastY - moveY;
postInvalidateOnAnimation();
}

在每次 TOUCH_MOVE 事件分发时,计算与 TOUCH_DOWN 时记录的位置信息的差值,保存为 mDistanceY,并且在 onDraw 中使用这个值对 Canvas 进行位移,绘制新位置的 UI。

卷尺控件

拖动距离的计算与滚轮控件一样,只是记录为 mOffsetLeft 而已,同时两个控件都有在 onTouchEventACTION_UP 事件分发中,处理 fling 手势。不过卷尺控件有使用 EdgeEffect 处理边缘效果,有兴趣的同学可以看下。

结语

文章的主要目的并不在于教会怎么去处理触摸事件的分发,只是希望通过这个例子,大家能养成阅读源码的习惯,不管是系统 SDK 也好,第三方库也好,只要把核心知识掌握,就能熟练使用各种现成的工具库,并且达到举一反三的效果。

但是理论知识再多也是纸上谈兵,最重要的是实践,具体实践可以这样做:先理解好触摸事件分发流程,然后选择一个控件,可以是系统控件,也可以是其他控件,只要涉及触摸事件处理就行,进行阅读,然后手动实现一个相反方向滚动的控件,比如,你阅读的是纵向滑动的控件,那么就实现一个横向滑动的控件。这个自定义控件需要实现以下效果:

  • 最基本的拖动手势处理
  • fling 效果实现
  • 如果可以,再实现边缘效果

感谢大家的阅读。

CATALOG
  1. 1. 前言
  2. 2. 触摸事件简单回顾
  3. 3. 手势判定
    1. 3.1. onInterceptTouchEvent
      1. 3.1.1. TOUCH_DOWN
      2. 3.1.2. TOUCH_MOVE
      3. 3.1.3. TOUCH_CANCEL && TOUCH_UP
      4. 3.1.4. ACTION_POINTER_UP
      5. 3.1.5. 小结
    2. 3.2. onTouchEvent
      1. 3.2.1. TOUCH_DOWN
      2. 3.2.2. TOUCH_MOVE
      3. 3.2.3. TOUCH_UP
      4. 3.2.4. TOUCH_CANCEL
      5. 3.2.5. ACTION_POINTER_DOWN
      6. 3.2.6. ACTION_POINTER_UP
      7. 3.2.7. 小结
    3. 3.3. 总结
  4. 4. 实战
    1. 4.1. 滚轮控件
    2. 4.2. 卷尺控件
  5. 5. 结语