第三章 View 的事件体系.md
第三章 View 的事件体系
3.1 View 基础知识
3.1.1 什么是 View
View 是 Android 中界面层的控件的一种抽象,它代表了一个控件。它派生出的各种 子类 如 TextView 等,共同组成一个界面。同时,View 有一个很特殊的抽象派生类 ViewGroup,它可以存放多个 子 View。最终 整个 View 体系会形成一棵树,这和前端的 DOM 树类似。而 ViewGroup 又派生出了各种布局,如 LinearLayout 等。
3.1.2 View 的位置参数
在 Android 的 View 中,使用的是相对定位,即每个 View 都会储存自己在 父 ViewGroup 中的位置,而最终它要显示在屏幕什么位置,则需要从父节点不断向下绘制,最终完成整体的绘制。
View 是矩形模型,由四个顶点的坐标决定,而他们坐标分量分别对应 View 的四个属性:top, left, right, bottom. 具体对应关系可以看下图:
可以得出 View 的宽高公式:
width = right-left
height = bottom - top
此外,从 Android3.0 开始,View 额外增加了四个参数:x, y, translationX, translationY. 其中 x, y 为 View 左上角的坐标,而 translationX 与 translationY 表示在原有 四个属性的基础上整体的便宜,因此可以得到以下公式:
x = left + translationX
y = top + translationY
可以参考下图:
3.1.3 MotionEvent 和 TouchSlop
-
MotionEvent
MotionEvent 是一个实体类,表示一个触摸事件,它有一些属性:
- Action:它表明触摸事件的类型,取值与常量对应,其中 ACTION_DOWN 表示手指刚按下,ACTION_MOVE 表示手指在屏幕移动,ACTION_UP 表示手指在屏幕上松开。除此之外还有第二根手指按下等 ACTION,这里不做详解。
- x,y:通过调用 getX()/getY() 方法获取,获取点击的位置相对于点击的位置最顶层的 View 的坐标
- rawX, rawY:通过 getRawX()/getRawY() 方法获取,获取点击位置相对于手机屏幕的坐标
当我们手指按下后抬起时,会产生两个事件,分别是 ACTION_DOWN 与 ACTION_UP
当我们手指按下滑动一段距离后抬起时,会产生若干个事件,首先是 ACTION_DOWN,然后再滑动时不断产生 ACTION_MOVE ,当然坐标会不断更新,最后抬起时产生一个 ACTION_UP。
-
TouchSlop
这个是系统能识别的最小滑动像素。当手指再屏幕上滑动时,如果滑动距离少于这个值,则系统会认为它不在滑动。这个和硬件有关,在不同设备上是不同的,可以通过以下方式获取:
- ViewConfiguration.get(context).getScledTouchSlop()
这个常量还是比较常用的,例如当你需要判断用户是再横向滑动、纵向滑动还是同时滑动时,可以通过两个 MOVE 事件的坐标差与该值比较(因为人手滑动的时候一般会有轻微抖动,直接判断坐标是否相等会破坏用户体验)。
3.1.4 VelocityTracker、GestureDetector 和 Scroller
-
VelocityTracker
速度追踪器,用于追踪手指在滑动过程中的速度,包括水平方向的速度和竖直方向。使用也很简单,先获取一个对象:
VelocityTracker vt = VelocityTracker.obtain();
然后我们在 View 的 OnTouchEvent 方法中将事件传递给追踪器:
vt.addMovement(event);
当我们需要获取速度时,先调用 computeCurrentVelocity(Long) 方法计算速度,然后就可以获取速度了
vt.computeCurrentVelocity(1000); float vX = vt.getXVelocity(); float vY = vt.getYVelocity();
其中,computeCurrentVelocity 方法的参数为时间段,实际上,我们获取的速度计算公式如下:
velocity = ( 终点坐标 - 起点坐标 )/ 时间段
其中时间段就是我们传入的数值,以毫秒为单位。这里坐标的方向是向右为 X 轴方向,向下为 Y 轴方向,因此如果在该时间段内手指往左或往上滑动,则速度为负值。
最后,在不需要使用的时候,记得释放内存,避免内存泄漏:
vt.clear(); vt.recycle();
-
GestureDetector
手势检测器,用于帮助检测用户单击、滑动、长按、双击等行为,要使用它也很简单,先实例化一个:
GestureDetector gd = new GestureDetector(Context, OnGestureListener)
当然它还有许多构造方法,这里就不一一展开了。
此外,如果有长按的需求,需要先调用一下允许长按:
gd.setIsLongpressEnabled(true);
然后再 View 的 onTouchEvent 方法中将事件传给检测器并返回消费结果:
boolean con = gd.onTouchEvent(event); return con;
至此,手势检测器就可以工作了,不过我们需要设置一下监听器:
还记得刚刚实例化传入的 OnGestureListener,这就是手势的回调,它有以下方法:
方法名 描述 onDown 按下一瞬间,由一个 ACTION_DOWN 触发 onShowPress 手指轻触屏幕,未松开或拖动,ACTION_DOWN 之后一段时间(通常很短)无其他事件时触发。 onSingleTapUp 手指按下后松开,单击行为,由 ACTION_DOWN 与 一个 ACTION_UP 触发 onScroll 按下后滑动,由一个 ACTION_DOWN 与 多个 ACTION_MOVE 触发 onLongPress 长按 onFling 快速滑动,按下,快速滑动后松开,注意要快速滑动 再对应的事件发生时候监听器的对应回调会启动,除此之外,还有一个方法可以指定双击监听器:
gd.setOnDoubleTapListener(OnDoubleTapListener)
这里 OnDoubleTapListener 与之前的 OnGestureListener 是可以共同存在的,而 OnDoubleTapListener 中有如下方法:
方法名 描述 onDoubleTap 双击,两次连续单击组成,调用之前 onSimgleTapUp 会调用两次 onSingleTapConfirmed 严格单击,当单击之后一段时间没有再次单击(双击)后才会触发 onDoubleTapEvent 表示发生了双击行为,双击期间,ACTION_DOWN 、ACTION_MOVE 和 ACTION_UP 都会触发 -
Scroller
弹性滑动对象,将在 3.3 做介绍,这里就不详解了。
3.2 View 的滑动
再 Android 设备上,滑动时标配,基本上大部分场景都有滑动的身影,因此,掌握滑动的方法时实现绚丽的自定义 View 的基础。有三种方式可以实现 View 的滑动,现在来一一介绍:
3.2.1 使用 scrollTo/scrollBy
View 中有两个属性 scrollX 与 scrollY,这两个属性不会改变 View 的当前位置,但是却会改变 View 中内容的位置。也就是说,在 View 定位之后,其位置参数已经确定,此时 View 的区域已经确定。然后就会开始绘制内容,而 scrollX 与 scrollY 就是在绘制内容时候产生的偏移。这里的偏移方向与坐标轴方向相反,当scrollX 为 100 时,代表此时 View 的左边缘在 View 的内容左边缘右边 100 个像素。
这两个方法都可以改变 View 的 scrollX 与 scrollY 的值,只不过 scrollTo(int x, int y) 传入的 x 和 y 会直接赋值给 scrollX 与 scrollY,而 scrollBy(int x, int y) 传入的值会在原先 scrollX 的基础上加上 x(y 也一样)。
例如以下场景:
- 当我们调用 scrollTo(0, 0) 的时候,表示将内容拉回起始点。
- 当我们调用 scrollBy(100, 100) 的时候,表示将内容往左上滑动,两个方向各滑动 100 个像素点。
注意:scrollTo 和 scrollBy 不会改变 View 的位置,当内容滑动到 View 边缘的时候,超出部分将不会绘制,直接消失。
3.2.2 使用动画
使用动画也可以使 View 滑动,这里介绍两种动画,View 动画(补间动画)和属性动画:
关于动画会在第七章进行详解,这里简单提一下。
- View 动画是 Android 很早就提供的比较原始的动画,它支持 平移 缩放 旋转 和 渐变 四种基本效果及其组合。不过需要注意的是,这种动画只是针对 View 的虚影,也就是 并不会真正改变 View 的位置,当我们将一个按钮使用 View 的动画平移后,新的位置无法响应点击事件,而原来的位置依然可以相应。
- 属性动画严格来说并不能算动画,它的主要思路是在一段时间内,将 View 的某个属性以某种顺序不断地改变并刷新。因此,如果想要用属性动画实现平移效果,我们可以改变 View 的 translationX 和 translationY 属性来达到目的(之前的位置参数中有讲到)。这种动画的好处是其是确确实实地改变了 View 的属性,也就是 View 的位置确实会发生改变,在新的位置也可以工作。同时,属性动画也抽象出了插值器估值器等功能,可以实现各种各样的效果。不过属性动画是在 安卓3.0 版本之后才加入的,如果需要支持更低版本,需要用到兼容库。(实际上, translationX 和 translationY 属性 也是 安卓3.0 加入的)
3.2.3 改变布局参数
严格来说之前的属性动画也是属于这个这分支,但那个是通过 属性动画来改变 View 自己的属性,而这里是改变 View 在其父布局中的参数。也就是直接改变 LayoutParams 的相关参数来达到滑动的目的,比如左外边距等。当然这里也可以使用 属性动画 工具来实现平滑移动。
3.3 弹性滑动
这一小节将介绍如何实现平滑滑动,其实本质上都是在一次大滑动直接加入一点小滑动,打个时间差,让视觉上看起来像是慢慢滑过去。
这里书上讲的比较乱,这里我做了一下整理:
3.3.1 使用 Scroller 和 scrollTo
首先我们先来看看第一种,以下就是常规用法了:
// DiyView.java
public class SmoothScrollView extends View{
// 省略构造方法
private Scroller mScroller = new Scroller(mContext);
public void smoothScrollTo(int x, int y){
int dx = x-getScrollX();
int dy = y-getScrollY();
// 最后一个参数为滑动需要的时间,以毫秒为单位
mScroller.startScroll(getScrollX(), getScrollY(), dx, dy, 1000);
invalidate();
}
@Override
public void computeScroll(){
if(mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
}
可以看到还是很简单的,这里主要讲解一下 computeScroll 方法,这个方法会在 View 重绘时在 onDraw 中调用,而我们在这里判断一下 mScroller 是否标记需要滑动,然后调用以下 scrollTo 方法,在重回一次。因此当我们调用 mScroller.startScroll 时,mScroller 就会标记为需要滑动的状态,并且之后我们会重绘一次,然后 View 就会不断重绘,不断调用 ScrollTo 方法,直到滑动完成,而滑动标记,滑动的值的计算则通过 Scroller 完成。
3.3.2 使用 ObjectAnimator 改变 translationX 或 translationY
来看看第二种,第二种就非常简单了,ObjectAnimation 就是属性动画,其内部实现了一个 ValueAnimator,并且在值更新的时候自动设置 View 的相关属性,因此我们可以直接时候它来实现弹性滑动:
ObjectAnimator.ofFloat(targetView, "translationX", 0, 100).setDuration(1000).start();
当调用 start 之后,ObjectAnimator 会在 1000 毫秒内将 targetView 的 translationX 属性从 0 逐步变为 100,如果需要多个 ObjectAnimator 同时,则可以使用 AnimatorSet,这里不做详解,在之后将会提到。
3.3.3 使用 ValueAnimator 和 scrollTo
来看看最后一种,使用 ValueAnimator,我们刚刚讲到 ObjectAnimation 内部实现了一个 ValueAnimator,其实 ValueAnimator 是一个在特定时间内更新一些值的工具,这么说有点抽象,来看代码:
ValueAnimator animator = ValueAnimator.ofFloat(0F, 100F).setDuration(1000);
animator.addUpdateListener(new AnimatorUpdateListener(){
@Override
public void onAnimationUpdate(ValueAnimator animator){
float value = (float) animation.getAnimatedValue();
targetView.scrollTo(value, targetView.getScrollY());
}
});
animator.start();
调用 start 之后, ValueAnimator 会在 1000 ms 内生成一组 从 0 开始 到 100 的数字,并在更新数字的时候调用 onAnimationUpdate 。而我们将该数字 传入 scrollTo 即可实现弹性滑动。这里可以看出 ValueAnimator 和 ObjectAnimation 的关系,因为 ObjectAnimation 就是内部实现了 ValueAnimator,并在数据更新时调用我们指定的 View 的设置指定属性的方法而已。
3.3.4 手动实现
其实抛开一切动画的库,我们可以自己实现弹性滑动,例如我们可以新建一个 Handler 然后不断调用 PostDelay 来实现,直到滑动到想要的位置,这里比较灵活也比较好理解,就不做详解。
3.4 View 的事件分发机制
这部分是重点,也是难点,许多开发者都会觉得困难,因此我们更要好好理解,掌握。这里也会花较大篇幅去讲解:
3.4.1 点击事件传递规则
这里书中讲解得比较繁琐,这里重新梳理了一下:
首先,事件是有序列的,当手指按下屏幕,此时会产生一个 ACTION_DOWN 的事件,而手指在若干操作后松开,则会产生一个 ACTION_UP 事件。这个过程中 ACTION_DOWN 为序列的第一个事件,ACTION_UP 为序列的最后一个事件。
当一个 ACTION_DOWN 事件产生,系统会在 各种 View 中传递该事件,当某个 View 决定处理该事件时,则同个序列之后的所有事件都会直接交给该 View 处理(指的是 View 的默认实现,如果重写了相关方法可以改变该规则)。
首先要先明白点击事件产生后的入口,首先 Android 中的布局是有以下层次构成的:
Activity -> Windows -> RootLayout(ViewGroup)
然后 RootLayout 就是最初的 View 架构,最终形成一课树。
点击事件的传递也是按照这个顺序,首先是 Activity,然后是 Windows,然后 WIndows 在传递给 RootLayout,然后就进入 View 的层级了。
其中 事件 的传递与三个方法息息相关:
-
public boolean dispatchTouchEvent(MotionEvent ev)
这是 点击事件 在 View 中的入口,只要一个事件传递到该 View ,则该方法会被首先调用,同时其他方法也由该方法 间接调用,返回值为是否消费了该事件。
-
public boolean onInterceptTouchEvent(MotionEvent ev)
在 ViewGroup 中才有,用来判断是否拦截该事件,返回值为是否拦截。该方法由 dispatchTouchEvent 调用,返回值为是否拦截。
-
public boolean onTouchEvent(MotionEvent ev)
在 dispatchTouchEvent 中调用,用来处理该事件,返回值为是否消费了该事件。
以下是,对于 ACTION_DOWN 事件的,ViewGroup 中 dispatchTouchEvent 的 默认实现 的伪代码:
public boolean dispatchTouchEvent(MotionEvent ev){
if(onInterceptTouchEvent(ev) || !child.dispatchTouchEvent(ev)){
if(mOnTouchListener != null && mOnTouchListener.onTouch(ev)){
return true;
}
if(onTouchEvent(ev)){
return true;
}
return false;
}
return true;
}
可以看到,会先调用 onInterceptTouchEvent(ev) 判断是否拦截,如果拦截则直接由自己处理(if 分支),如果没拦截则短路或会将事件传递给 child,这里省去了根据点击位置判断下一级 View 的过程。如果 child 消费了该事件,则直接返回 true,如果 child 没消费,则还是交给自己处理。
而事件的处理有三种方式,优先级由高到低分别为 onTouchListener,onTouchEvent 和 onClickListener。三种方式都没处理则返回 false。
注意,这里只分析 ACTION_DOWN 的事件传递,并且是该方法的默认实现。如果是其他事件则会按照刚刚的规则直接传递到目标 View 中,在下一节源码分析中会详细讲到。
规则其实大概讲完了,这里简单整理一下(有一些额外的规则整理):
- 一个事件序列以 ACTION_DOWN 开始,以 ACTION_UP 结束,中间由若干个 ACTION_MOVE
- 正常情况下(View 的默认实现),一个序列的事件只能由同一个 View 拦截且消耗
- 如果某个 ViewGroup 的 onInterceptTouchEvent(ev) 返回 true,但是它自身无法消费该事件,那么该事件还是会交给它 父布局处理(从伪代码中可以看出,如果拦截了但是没消费依然返回 false)
- 如果某个 View 消费了 ACTION_DOWN ,那么之后的事件都会交给它处理,在之后的事件中,如果这个 View 没有消费该事件,则不会影响该序列之后的事件,同时没消费的事件会传递给 Activity 的 onTouchEvent 处理。
- ViewGroup 的 onInterceptTouchEvent(ev) 方法默认 返回 false,即不拦截
- 只要 View 的 clickable 、longClickable、contextClickable 三者其中一个为 true,则 该 View 的 onTouchEvent 会返回 true (默认实现)
- View 的 enable 属性不会影响 onTouchEvent 的结果(默认实现)
- onClick 会发生的前提是 可点击标记为 true,同时收到了 DOWN 和 UP 事件。
- 除了 ACTION_DOWN 事件之外的事件在传递到目标 View 时也会经过 父布局,此时父布局的 onInterceptTouchEvent 依然可以强制拦截,子 View 可以调用 requestDisallowInterceptTouchEvent 来禁止父布局拦截本应由自己消费的 View(此时父布局的 onInterceptTouchEvent 将不会调用)。但 ACTION_DOWN 除外,因为 ACTION_DOWN 事件传递时还没有决定由谁消费,因此 requestDisallowInterceptTouchEvent 无法禁止父布局拦截。
3.4.2 事件分发源码分析
以上规则都是默认实现下的规则,如果你重写了相关方法,那么规则可能会被改变。因此为了更好的理解点击事件的分发,我们需要进行源码追踪。开始把:我分析的源码 sdk 版本为 30,而书中的是 20 ,虽然大体类似但细节可能会有冲突。
首先,一个 MotionEvent 最初会来到 Activity 并调用它的 dispatchTouchEvent 方法,先来看看:
// Activity#dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction(); // 默认为空实现,可以重写
}
if (getWindow().superDispatchTouchEvent(ev)) {
// 将事件传递给 window,并且如果被消费了则直接返回
return true;
}
// 如果没被消费则调用 onTouchEvent 方法
return onTouchEvent(ev);
}
可以看到事件会被传递到 window,window 是个抽象类:
**
* Abstract base class for a top-level window look and behavior policy. An
* instance of this class should be used as the top-level view added to the
* window manager. It provides standard UI policies such as a background, title
* area, default key processing, etc.
*
* <p>The only existing implementation of this abstract class is
* android.view.PhoneWindow, which you should instantiate when needing a
* Window.
*/
public abstract class Window {
同个注释可以看出它可以控制顶层 View 的外观和行为。同时它是一个抽象类,它的一个唯一实现类在 android.view.PhoneWindow
中 (不过最终我在 com.android.internal.policy
找到这个类,与注解的包名不同)
// PhoneWindow#superDispatchTouchEvent
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}
可以看到最终是调用了 mDecor 的方法
// PhoenWindow.mDecor
// This is the top-level view of the window, containing the window decor.
private DecorView mDecor;
可以看出至此事件就来到 View 架构中了。这个 DecorView 是用于 承接 我们在 Activity 中调用 setContentView 设置的 View 的。它是 FrameLayout 的子类。
// DecorView#superDispatcTouchEvent
public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
}
可以看到我们最终调用了熟悉的 dispatchTouchEvent。至此事件就传递到 View 层了。而因为事件传递时从根出发往下传递的,因此一般都是先传递到 ViewGroup 在 到 View,因此我们来到 ViewGroup ,ViewGroup 的 disaptchTouchEvent 代码比较长,这里来分一下段:
// ViewGroup#disaptchTouchEvent tip1
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onTouchEvent(ev, 1);
}// 目标决定器
if (ev.isTargetAccessibilityFocus() && isAccessibilityFocusedViewOrHost()) {
ev.setTargetAccessibilityFocus(false);
}// 焦点的相关操作
boolean handled = false;
if (onFilterTouchEventForSecurity(ev)){
// tip 2
}
if (!handled && mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
}
return handled;
}
这里开头和结尾的一些操作暂时不在我们考虑范围,先暂时隐藏,因此逻辑简化为:
注意,在之后的源码中,有关于 mInputEventConsistencyVerifier 的操作都会省去,因为它是一个 debug 时候的事件管理器,不在我们考虑的范围。
// ViewGroup#disaptchTouchEvent simple tip1
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean handled = false;
if (onFilterTouchEventForSecurity(ev)){
// tip 2
}
return handled;
}
先来看看 onFilterTouchEventForSecurity:
// View#onFilterTouchEventForSecurity
/**
* Filter the touch event to apply security policies.
*
* @param event The motion event to be filtered.
* @return True if the event should be dispatched, false if the event should be dropped.
*
* @see #getFilterTouchesWhenObscured
*/
public boolean onFilterTouchEventForSecurity(MotionEvent event) {
//noinspection RedundantIfStatement
if ((mViewFlags & FILTER_TOUCHES_WHEN_OBSCURED) != 0
&& (event.getFlags() & MotionEvent.FLAG_WINDOW_IS_OBSCURED) != 0) {
// Window is obscured, drop this touch.
return false;
}
return true;
}
这是用于过滤事件的方法,某些事件因为不安全会被过滤掉,这里和事件传递无关也暂时不讲解。因此实际逻辑就放到了 tip2 部分,这部分也比较长,这里分段讲解:
final int action = ev.getAction();
final int actionMasked = action & MotionEvent.ACTION_MASK;
if (actionMasked == MotionEvent.ACTION_DOWN) { // 如果是按下
cancelAndClearTouchTargets(ev);// 清空当前处理的 View
resetTouchState();//重置点击状态
}
这里可以看到针对 ACTION_DOWN 的事件做了一些初始化,表面新的事件序列开始。cancelAndClearTouchTargets 方法最终会调用 clearTouchTargets 方法,会将一个成员变量 mFirstTouchTarget 设置为 null,这个变量是 TouchTarget 类型,这个变量中含有当前正在处理当前事件队列的 View 的相关信息。因此这里就是将正在处理的 View 置空,表面新的序列开始。
// Check for interception.
final boolean intercepted; // 标记是否拦截
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
// 当事件为 ACTION_DOWN 或者其他 View 正在处理的事件
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
// 这个标记可由子 View 调用 requestDisallowInterceptTouchEvent 设置
if (!disallowIntercept) { // 如果子 View 没有禁止调用
intercepted = onInterceptTouchEvent(ev);
// 调用 onInterceptTouchEvent 看是否拦截
ev.setAction(action); // 重置一下 action (防止 在 onInterceptTouchEvent 中修改 )
} else { // 子 View 禁止拦截
intercepted = false;
}
} else {
// 会执行到这里当且仅当事件不是 MOTION_DOWN 且 mFirstTouchTarget 为空,表示这是由这个 ViewGroup 本身处理的事件
intercepted = true;
}
这里主要是是否拦截的判断,不过要注意:
如果当前事件是 ACTION_DOWN,则 FLAG_DISALLOW_INTERCEPT 的标记会在 上面 resetTouchState() 中重置,因此 子 View 无法通过 requestDisallowInterceptTouchEvent 来阻止父布局拦截 ACTION_DOWN 的事件
在 onInterceptTouchEvent 中改变的 MotionEvent 的 ACTION,不会产生效果,因为在之后会复原。
if (intercepted || mFirstTouchTarget != null) {
ev.setTargetAccessibilityFocus(false);
}// 焦点相关设置
// 判断事件是否被关闭
final boolean canceled = resetCancelNextUpFlag(this)
|| actionMasked == MotionEvent.ACTION_CANCEL;
// 一些标记判断
// Update list of touch targets for pointer down, if needed.
final boolean isMouseEvent = ev.getSource() == InputDevice.SOURCE_MOUSE;
final boolean split = (mGroupFlags & FLAG_SPLIT_MOTION_EVENTS) != 0
&& !isMouseEvent;
这段代主要是用于设置焦点和判断事件是否被关闭,当我们的 View 决定要处理该事件后,如果事件被父布局强行拦截并处理了,则子 View 会收到一个 ACTION_CANCEL 事件。同时,在一个 事件队列结束后,负责处理该事件的 View 也会收到一个 CANCEL 事件。
继续看:
TouchTarget newTouchTarget = null; // 新的处理事件的 View
boolean alreadyDispatchedToNewTouchTarget = false; // 是否已经分发给新的 View 处理
if (!canceled && !intercepted) {
// If the event is targeting accessibility focus we give it to the
// view that has accessibility focus and if it does not handle it
// we clear the flag and dispatch the event to all children as usual.
// We are looking up the accessibility focused host to avoid keeping
// state since these events are very rare.
View childWithAccessibilityFocus = ev.isTargetAccessibilityFocus()
? findChildWithAccessibilityFocus() : null; // 这个变量后面不会再用到,这里先暂时不看
// tip 3
}
可以看到,如果不拦截,不是 CANCEL 事件,则要进行分发了,分发的具体逻辑在上述代码中 tip 3 部分。
接下来看 tip 3 部分:
// tip 3
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) { // 新事件序列
final int actionIndex = ev.getActionIndex();
final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
: TouchTarget.ALL_POINTER_IDS; // 多指支持
removePointersFromTouchTargets(idBitsToAssign);
final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
final float x =
isMouseEvent ? ev.getXCursorPosition() : ev.getX(actionIndex);
final float y =
isMouseEvent ? ev.getYCursorPosition() : ev.getY(actionIndex);
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
final boolean customOrder = preorderedList == null
&& isChildrenDrawingOrderEnabled(); //
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
if (!child.canReceivePointerEvents()
|| !isTransformedTouchPointInView(x, y, child, null)) {
continue;
}
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// Child is already receiving touch within its bounds.
// Give it the new pointer in addition to the ones it is handling.
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
resetCancelNextUpFlag(child);
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// Child wants to receive touch within its bounds.
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
// childIndex points into presorted list, find original index
for (int j = 0; j < childrenCount; j++) {
if (children[childIndex] == mChildren[j]) {
mLastTouchDownIndex = j;
break;
}
}
} else {
mLastTouchDownIndex = childIndex;
}
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
// The accessibility focus didn't handle the event, so clear
// the flag and do a normal dispatch to all children.
ev.setTargetAccessibilityFocus(false);
}
if (preorderedList != null) preorderedList.clear();
}
if (newTouchTarget == null && mFirstTouchTarget != null) {
// Did not find a child to receive the event.
// Assign the pointer to the least recently added target.
newTouchTarget = mFirstTouchTarget;
while (newTouchTarget.next != null) {
newTouchTarget = newTouchTarget.next;
}
newTouchTarget.pointerIdBits |= idBitsToAssign;
}
}
逻辑还是比较清晰的,遍历一下 chirldren,然后重点看这里:
if (!child.canReceivePointerEvents() // 是否能接收事件
|| !isTransformedTouchPointInView(x, y, child, null)) { // 点击位置是否在该孩子处
continue;
}
newTouchTarget = getTouchTarget(child); // 先看看该 View 是否已经作为处理事件的 View
if (newTouchTarget != null) {
// Child is already receiving touch within its bounds.
// Give it the new pointer in addition to the ones it is handling.
newTouchTarget.pointerIdBits |= idBitsToAssign; // 该 View 已经在处理事件,这里加入一个指头标记(第二根手指按下)
break;
}
然后是:
resetCancelNextUpFlag(child);
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// Child wants to receive touch within its bounds.
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
// childIndex points into presorted list, find original index
for (int j = 0; j < childrenCount; j++) {
if (children[childIndex] == mChildren[j]) {
mLastTouchDownIndex = j;
break;
}
}
} else {
mLastTouchDownIndex = childIndex;
}
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
其中 dispatchTransformedTouchEvent 方法会调用子 View 的 DispatchTouchEvent 方法,并且如果该方法返回 true,即孩子消费了这个 DOWN 事件,则讲其记录为这一个事件序列的处理 View,并跳出循环。否则会继续循环。
至此,tip3 部分就完了。让我们继续看
这里的部分已经跳出了 tip3,因此所有的事件都会走到这里,之前做了判断 只有事件序列的第一个事件才能走:
// Dispatch to touch targets.
if (mFirstTouchTarget == null) {
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}
可以自己分析下,这里先判断是否有 目标 View,如果没有则调用 dispatchTransformedTouchEvent(ev, canceled, null,TouchTarget.ALL_POINTER_IDS)
该方法逻辑较为复杂,具体为根据各种情况将事件分发。
如果有,还是调用 dispatchTransformedTouchEvent(ev, cancelChild, target.child, target.pointerIdBits)
,只不过第三个参数为目标 View,该方法会将事件分发给 child (正常情况下)。
以下是 dispatchTransformedTouchEvent 重点代码 (节选):
// ViewGroup#dispatchTransformedTouchEvent
// ……
if (child == null) {
handled = super.dispatchTouchEvent(transformedEvent);
} else {
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
transformedEvent.offsetLocation(offsetX, offsetY);
if (! child.hasIdentityMatrix()) {
transformedEvent.transform(child.getInverseMatrix());
}
handled = child.dispatchTouchEvent(transformedEvent);
}
// ……
可以看到最终调用了 dispatchTouchEvent 方法,根据是否有目标而调用特定的方法。如果没有目标,则 ViewGroup会自己处理该事件,调用 super.dispatchTouchEvent
,因为 ViewGroup 是继承于 View,因此我们来到的 View 的 dispatchTouchEvent 方法。
至此,我们分析了 dispatchTouchEvent 的部分源码,相信对事件的分发有了大概的了解。接下来我们来到 View 的 dispatchTouchEvent 方法:
// View#dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.isTargetAccessibilityFocus()) {
if (!isAccessibilityFocusedViewOrHost()) {
return false;
}
event.setTargetAccessibilityFocus(false);
}
boolean result = false;
if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onTouchEvent(event, 0);
}
final int actionMasked = event.getActionMasked();
if (actionMasked == MotionEvent.ACTION_DOWN) {
stopNestedScroll();
}
if (onFilterTouchEventForSecurity(event)) {
// tip
}
if (!result && mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(event, 0);
}
if (actionMasked == MotionEvent.ACTION_UP ||
actionMasked == MotionEvent.ACTION_CANCEL ||
(actionMasked == MotionEvent.ACTION_DOWN && !result)) {
stopNestedScroll();
}
return result;
}
可以看到比较简单,因为 View 不存在子 View,它只能自己处理事件,因此该方法也比较简单,这里前后都不做讲解,重点看 tip 部分:
if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
result = true;
}
//noinspection SimplifiableIfStatement
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
result = true;
}
if (!result && onTouchEvent(event)) {
result = true;
}
可以看到,它会先调用 mOnTouchListener.onTouch 方法,而这个变量可以通过 setTouchListener 来设置。如果在这里就将时间消费,则不会走到 onTouchEvent,否则则尝试走 onTouchEvent。
关于 View 的 onTouchEvent 的默认实现,因为内容较多这里不详细给出,不过要记得几点:
- 只要 View 的 Clickable 和 LongClickable 的 flags 中有一个为 true,则 onTouchEvent 会返回 true
- 当 ACTION_UP 发生时,会触发 performClick 方法,如果有设置 OnClickListener ,则会调用
3.5 View 的滑动冲突
3.5.1 常见滑动冲突情景
简单来说有三种情景:
- 场景 1 —— 外部滑动方向与内部滑动方向不一致
- 场景 2 —— 外部滑动方向与内部滑动方向一致
- 场景 3 —— 上面两种情况的嵌套
对于场景一,一种典型场景是 ViewPager 中 嵌套 RecyclerView,此时外部滑动方向是左右,而内部是上下。不过对于 ViewPager,它内部帮我们解决了滑动冲突。如果我们使用的是横向的 HorizontalScrollView,则需要处理。
对于场景二,一种典型场景是 RecyclerView 的嵌套,而外层内层滑动方向一致,此时会产生滑动冲突
对于场景三就是套娃,比如 ViewPager 套 两层 RecyclerView 等。需要我们分别处理各两层之间的冲突,最终解决整体冲突
3.5.2 滑动冲突处理规则
滑动冲突的规则其实还是比较好理解的,这里根据上面三个场景一个一个来看:
- 对于场景一,我们需要判断用户是往什么方向滑动,如果是水平滑动,则将事件交给左右滑动的 View 处理,如果是竖直滑动反之。这里问题在于如何判断是左右滑动还是上下滑动,这里有很多方法,我们可以取一段时间然后根据滑动的位移差来判断,也可以用滑动速度来判断,也可以终点起点连线与水平方向夹角来判断。
- 对于场景二,我们没办法从技术的角度判断了。我们只能从业务角度来判断,比如对于上下滑动嵌套,我们可以根据内部的 View 是否滑动到顶部来判断,当内部的 View 滑动到顶部,则该滑动事件需要交给父布局。
- 对于场景三,则更复杂了,我们需要综合判断各种业务。下一节将会通过例子来演示解决方案。
3.5.3 滑动冲突解决方式
解决滑动冲突的核心在于,将事件分发给正确的 View 来处理,处理方法很多,只要能达到目的即可,但一般说来,有两种方式,一是外部拦截法和二是内部拦截法。第一种为事件先交给父布局,父布局判断该事件是否是自己想要的,如果是则自己处理如果不是则传递给孩子。第二种是直接交给子布局,子布局判断该事件是否是自己想要的,如果不是则返回自己未能处理。
外部拦截法
主要为重写父容器的 onInterceptTouchEvent
方法,如果该事件为需要的话则直接拦截,主要代码如下,其中 need 方法为返回是否需要该事件:
public boolean onInterceptTouchEvent(MotionEvent envet){
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch(event.getjAction()){
case MotionEvent.ACTION_DOWN:
intercepted = false;
break;
case MotionEvent.ACTION_MOVE:
// 如果父容器需要该事件
if(need(mLastX, mLastY, x, y)){
intercepted = true;
}else{
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
}
mLastX = x;
mLastY = y;
return intercepted;
}
内部拦截法
这种方法与安卓原本的分发机制不一致,需要配合 requestDisallowInterceptTouchEvent
方法才能工作,主要为重写子控件的 dispatchTouchEvent
方法,具体代码如下:
public boolean dispatchTouchEvent(MotionEvent event){
int x = (int) event.getX();
int y = (int) event.getY();
switch(event.getAction()){
case MotionEvent.ACTION_DOWN:
parent.requestDisllowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
// 如果父容器需要该事件
if(need(mLastX, mLastY, x, y)){
parent.requestDisllowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
同时注意,ACTION_DOWN 事件不受 FLAG_DISALLOW_INTERCEPT 标记的控制,因此父容器不能拦截 ACTION_DOWN 事件,否则所有事件都无法传到孩子,则 父布局的 onInterceptTouchEvent 方法中对于 ACTION_DOWN 事件必须返回 false 。