https://rainmonth.github.io/posts/A180504.html
摘要
本文内容有点多,主要介绍View的基础知识、View的事件分发机制、从源码分析View的事件分发机制以及滑动冲出的处理这几方面的知识。
View的基础知识
位置参数
以下四个参数是相对父容器而言的:
- top,左上角纵坐标,通过
getTop()
获得 - left,左上角横坐标,通过
getLeft()
获得 - right,右下角横坐标,通过
getRight()
获得 - bottom,右下角纵坐标,通过
getBottom()
获得
Android 3.0之后,新增:
- x,左上角横坐标
- y,左上角纵坐标
- transitionX,左上角相对于父容器的横向偏移量,默认值为0
- transitionY,左上角相对于父容器的纵向偏移量,默认值为0
所以
- 宽width= right -left
- 高height=bottom-top
- x = left + transitionX
- y = top + transitionY
View平移的时候,top、left表示的原始的左上角的位置信息,不对改变,测试发生改变的时x、y、transitionX、transitionY这四个参数。
MotionEvent
记录手指、鼠标、触控笔等的移动事件的对象。典型的有一下几种:
- ACTION_DOWN,手指刚接触屏幕
- ACTION_MOVE,手指在屏幕上移动
- ACTION_UP,手指离开屏幕瞬间
正常事件流:
- 点击屏幕后松开,ACTION_DOWN->ACTION_UP
- 点击屏幕滑动一会松开,ACTION_DOWN->ACTION_MOVE->…->ACTION_UP
获取点击事件发生的位置:
- getX和getY,获取相对于当前View左上角的x,y值
- getRawX和getRawY,获取相对于屏幕左上角的x,y值
TouchSlop
TouchSlop是系统所能识别出的被认为是滑动的最小距离,即如果屏幕上的两次滑动距离小于这个值,系统就会判定你不是在进行滑动操作。
可以利用这个值对滑动的距离做过滤。
VelocityTracer
速度追踪,用于追踪手指在滑动过程中的速度,包括水平和竖直方向的速度。具体使用如下:
1 |
|
针对上面代码做以下说明:
得到VelocityTracker后,要回收
获取速度之前,先要计算速度,计算速度时要传入间隔时间,速度计算公式如下:
1
速度 = (终点位置 - 起点位置) / 时间间隔
速度存在正负,从左往右(从上往下)是得到的x方向速度(y方向速度)为正
GestureDetector
手势检测,用于辅助检测用户的单击、双击、长按、滑动等行为,具体使用方法如下:
Activity(或View)实现
GestureDetector.OnGestureListener
和GestureDetector.OnDoubleTapListener
这两个接口;得到GestureDetector
1
2
3
4mGestureDetector = new GestureDetector(this);
// 设置之后就不能响应长按事件了
mGestureDetector.setIsLongpressEnabled(false);
mGestureDetector.setOnDoubleTapListener(this);重写Activity(或View)的onTouchEvent,我这里直接交由GestureDetector的onTouchEvent来处理
1
2
3
4
5
public boolean onTouchEvent(MotionEvent event) {
// printVelocityTracker(event);
return mGestureDetector.onTouchEvent(event);
}
至于GestureDetector的onGestureListener和onDoubleTabListener中每个方法的具体含义,请参考源码的说明,不做详述。(实际上,GestureDetector的所有方法完全可以参考其onTouchEvent在目标的onTouchEvent方法上来实现)
Scroller
弹性滑动对象,用于实现View的弹性滑动。View的scrollTo/scrollBy方法滑动时,是瞬时完成的,无过渡效果,体验不好。采用Scroller可以让滑动过程在一定的时间间隔内完成。
注意,Scroller本身无法让View弹性滑动,需要和View的computeScroll方法配合才能完成这个功能。
一般使用过程如下:
1 | Scroller mScroller = new Scroller(mContext); |
View的滑动
View的滑动,是实现绚丽自定义控件的基础。View的滑动注意可以通过以下几种方式:
- View本身提供的scrollTo/scrollBy方法来实现滑动;
- 通过平移动画来实现View的滑动;
- 通过LayoutParams是的View重新布局来实现滑动;
使用scrollTo/scrollBy
先看看这两个方法的源码:
1 | /** |
针对上面代码作以下说明:
- scrollBy内部调用了scrollTo,二者区别:前者加上了mScrollX和mScrollY,是相对滑动(要知道mScrollX和mScrollY是可以为负值),后者是绝对滑动
- mScrollX和mScrollY分别代表View的内容与View的边界的距离,即:
- mScrollX = ViewEdgeLeft - ViewContentLeft,大于0表示内容在边界左边,从右向左滑动
- mScrollY = ViewEdgeTop - ViewContentTop,大于0表示内容在边界上边,从下向上滑动。
- scrollBy和scrollTo改变的时View内容的位置而不是View在布局中的位置
使用动画
既可以使用传统的View动画,也可以使用Android 3.0之后的属性动画(3.0之前如果要应用属性动画可以采用nineoldanroids这个开源库实现)。
使用View动画
View动画分四种:平移动画,缩放动画,透明度动画,旋转动画,注意使用动画改变的只是View内容,View正在的位置参数没有改变,这就会导致问题的发生。如我将一个Button向下移动100px,移动过后,假如我设置了点击事件,此时你会发现点击现在的位置无法响应点击事件,而点击原来的地可以,这就是View动画局限的地方。
使用属性动画
针对使用View动画的问题,Android 3.0后可以采用属性动画来解决。属性动画可以改变View的位置。
改变布局参数
使用这种方式完成滑动个人觉得不是很优雅,不建议使用。这种方式是通过实现设置一个占位的View,当需要发生滑动是,改变这个占位View的布局参数,从而达到改变目标View位置的目的。
当然,也可以动态设置View的margin值,得到滑动View的目的。
三种方式对比:
- scrollTo/scrollBy,原生方法,使用方便,能滑动内容但不能滑动View本身;
- 动画,使用简单,能实现复杂的动画,Android 3.0之后使用无明显缺点,但之前的使用的话存在和scrollTo/scrollBy一样的问题。
- 改变布局参数,使用简单,适用于有交互的场景。
View的弹性滑动
弹性滑动的实现方式有多种,使用Scroller、动画、通过延时策略。
Scroller实现
上面介绍了Scroller的基本使用方法,看看源码中关于Scroller的描述,发现它本身并不做任何移动View的动作,而是记录了一些滑动相关信息。基本使用方法中,调用startScroll后,调用invalidate方法,这回导致View的重绘,View的重绘又会调用draw方法,draw方法中又会调用computeScroll方法。
在重写的computeScroll方法中,调用了ScrollTo方法来实现View大的滑动,这之后又调用postInvalidate方法,又会发生重绘,如此循化。结束条件是mScroller.computeScrollOffset()
返回true,看看computeScrollOffset()
的实现:
1 | /** |
简单说明下,当动画进行的时间小于动画持续时间,就不断更新动画相关的变量,继续动画;当进行时间大于等于规定动画持续时间,就结束动画了,就是这么简单。
动画实现
动画本身就是弹性的(因为动画内部都会有插值器),这里就不详细说明了。
使用延时策略
使用延时策略就是通过发送一系列的延时消息从而达到一种渐进式的效果,具体实现就是利用Handler或View的postDelay方法,当然也可以调用线程的Sleep方法。
这里以Handler为例介绍延时策略实现弹性滑动。
1 | private static final int MSG_SCROLL_TO = 1; |
上面的llTestContainer是移动的目标View,执行之后你会发现llTestContainer里面的内容向左移动了100像素。(注意并不是llTestContainer本身移动了)。
View的事件分发机制
View的事件分发是View这块的核心和难点所在。这里的事件指的就是点击屏幕产生的MotionEvent对象了。事件分发就是对MotionEvent传递的过程。这个过程由三个十分重要的方法来共同完成:dispatchTouchEvent
、onInterceptTouchEvent
、onTouchEvent
。看这三个方法的具体介绍:
public boolean dispathcTouchEvent(MotionEvent event)
用来进行事件的分发。如果事件能够传递给View,那么这个方法一定会被调用。返回结果受当前View的onTouchEvent
和子View的dispatchTouchEvent
方法影响,表示是否消耗当前事件。
public boolean onInterceptTouchEvent(MotionEvent event)
判断是否进行事件拦截,(显然View没有这个方法,View关心的时处理或不处理这个事件)如果返回true,表示对当前事件进行拦截,事件交由当前View的onTouchEvent来处理,处理结果又onTouchEvent决定。如果返回false,则交由子View的dispatchTouchEvent处理。
public boolean onTouchEvent(MotionEvent event)
是否消费当前事件,true消费,false不消费,交由当前View的父View来处理。
上面三个函数的关系可以用以下伪代码表示:
1 | public boolean dispatchTouchEvent(MotionEvent event) { |
关于事件传递,给出如下结论:
- 同一个事件序列指的是从手指触摸屏幕的那一刻开始到手指离开屏幕的那一刻结束,即事件序列总是以
ACTION_DOWN
开始,ACTION_UP
结束。 - 正常情况下一个事件序列只能被一个View消耗,除非你在View的onTouchEvent中强行将事件传递给了其他View。
- View(ViewGroup)一旦决定拦截,那么被拦截的这个事件所在序列就都由这个View(ViewGroup)处理,并且针对这个事件序列的其他事件,onInterceptTouchEvent不会再被调用。
- View(ViewGroup)一旦开始处理事件,就要负责到底,如果它不消耗
ACTION_DOWN
事件(即onTouchEvent返回false),那么事件序列的其它的事件都不会交给他处理,而会交由View(ViewGroup)的父元素处理,即调用父元素的onTouchEvent。(通俗的将,我这么信任你,你第一步都没有处理好,我后续的还能信任你吗?) - 如果View(ViewGroup)消耗了事件序列的
ACTION_DOWN
事件(即在ACTION_DOWN
时返回true),但其他事件都没消耗(即返回false),那么这个事件就会消失了,不会交给View的父元素的onTouchEvent处理,并且当前View还可以接受其他事件,只是这些事件最终都会传递给Activity处理。 - ViewGroup默认不拦截任何事件,它的
onInterceptTouchEvent
方法是默认返回false。 - View没有
onInterceptTouchEvent
方法,一旦有事件传递给他,那么它的onTouchEvent事件就会被调用
。 - View的
onTouchEvent
默认都会消耗事件(即return true),(除非它是不可点击的(clickable和longClickable同时为false)。View的longClickable属性默认为false,clickable属性视具体控件而定,如Button的clickable默认为true,而TextView的则默认为false。 - 即使View的enable属性为false,它也可以消耗事件(前提是它是可点击的),也就是说enable属性不影响onTouchEvent的返回值。
- View的onClick会发生的前提是当前的View是可点击的,并且它接受到了
ACTION_DOWN
和ACTION_UP
事件。 - 事件传递是由外向内的,及Activity->Window->DecorView->ViewGroup…->最底层View,在这个过程中父级View可以通过
onInterceptTouchEvent
来拦截事件,而子View可以通过requestDisallowInterceptTouchEvent
来干预父元素(除`ACTION_DOWN外)的事件分发。
从源码角度理解事件分发
Activity对点击事件的分发
上面总结的第十一条描述了事件的传递方向,那么位于第一层的Activity如何分发事件的呢?看源码:
1 | public boolean dispatchTouchEvent(MotionEvent ev) { |
显然事件都交给了Window来处理,如果getWindow().superDispatchTouchEvent(ev)
返回true,则事件序列的处理就结束了,如果返回false,则意味着传递过程中所有View的onTouchEvent都返回false了,交由Activity的onTouchEvent来处理。
Window是个抽象类,其唯一实现类时PhoneWindow,所以我们看看PhoneWindow是如何处理事件的:
1 |
|
显然,交给了DecorView处理,看DecorView如何处理:
1 | public boolean superDispatchTouchEvent(MotionEvent event) { |
而DecorView是FrameLayout的子类,也就是说事件会交给FrameLayout(ViewGroup的子类)的dispatchTouchEvent
处理,也就是说,事件最终都交给了ViewGroup处理。
顶级View的事件分发过程
这里结合源码来对上面的结论做说明。首先解释结论11,即子View可以干预父View的事件拦截过程(ACTION_DOWN
除外),代码片段如下:
1 | // Handle an initial down. |
上面代码中,先看这个if判断:
1 | if (actionMasked == MotionEvent.ACTION_DOWN |
可见能进行事件拦截的先决条件是要么当前事件是ACTION_DOWN,要么mFirstTouchTarget不为null,这个mFirstTouchTarget是在ViewGroup不拦截事件且指定那个子View来处理事件时会被赋值的,也就是说,如果ViewGroup决定拦截事件,mFirstTouchTarget != null 就不成立,那么当其他事件(非ACTION_DOWN事件)来临时,onInterceptTouchEvent都不会再调用,这就印证了上面的结论3。
在开始判断是否拦截之前,有一个ACTION_DOWN的判断,该判断就是印证结论11的子View不能干预父View对ACTION_DOWN事件的处理,因为这个判断中会对FLAG_DISALLOWED_INTERCEPT标志位重置。
根据上面代码,可以有如下总结:
- onInterceptTouchEvent并不是每次都调用,如果我们想处理每个事件,我们必须写在dispatchTouchEvent中;
- FLAG_DISALLOWED_INTERCEPT标志位为解决滑动提供了一种可能途径。
当ViewGroup不拦截事件时,事件会分发到它的子View进行处理,代码如下:
1 | final View[] children = mChildren; |
上面的dispatchTransformedTouchEvent
方法,其实调用的就是子View的dispatchTouchEvent。如果子View的dispatchTouchEvent返回true,那么:
1 | if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) { |
在里面的addTouchTarget方法中,mFirstTouchTarget被赋值,然后跳出循环。赋值的具体实现
1 | private TouchTarget addTouchTarget(int pointerIdBits) { View child, |
如果上面的for循环结束事件都没有被合适的处理(可能有两种情况:ViewGroup没有子元素或ViewGroup的子元素处理了事件但它的dispatchTouchEvent方法返回的时false,表示没消费事件,实际上已经处理了)这样的话ViewGroup会自己处理事件。代码片段如下:
1 | if (mFirstTouchTarget == null) { |
这里的第三个参数为空,此时就会调用super.dispatchTouchEvent,即View的dispatchTouchEvent方法了,事件交由了View来处理。接下来看看View(不包括ViewGroup)对事件的处理。
View对事件的处理
View(非ViewGroup)对事件的处理就比较简单了,先看View的dispatchTouchEvent代码片段:
1 | if (onFilterTouchEventForSecurity(event)) { |
这里可以很好的解释为什么说OnTouchListener的优先级比onTouchEvent的高了。
再看onTouchEvent的代码片段:
1 | // 判断是否可点击,以下三种状态都是可点击状态 |
看见那个performClick方法了没?这应该就是我们点击事件发生的地方,看看这个方法都做了什么:
1 | public boolean performClick() { |
从上面的那个if判断就可以得出如下结论:
View.OnClickListener的执行是在View的onTouchEvent执行之后的,且在调用View的setOnClickListener方法的时候,改变了View的clickable属性。
到这里View的事件分发差不多分析完了,其实细看起来并不复杂哈。
View的滑动冲突
引起滑动冲突的原因
既然是滑动冲突,那么说明可滑动的View应该不止一个,引起冲突的原因大致可分为以下几种:
- 内部View和外部View都可滑动,滑动方向不一致;
- 内部View和外部View都可滑动,滑动方向一致;
- 上面两种情况的嵌套;
第一种情况,比较常见的ViewPager + Fragment组合,然后Fragment里面又是一个RecyclerView或ListView,本来这种情况应该是滑动冲突的,但我们平常这样使用的时候却没有出现问题。其实是因为ViewPager内部做了处理。
第二种情况,比较常见的就是ScrollView里面嵌套一个ListView或RecyclerView,都在竖直方向滑动,如果处理不好,可能只能有一个能滑动一个不能滑动,也有可能两个都能滑动,但会卡顿,通常这种情况我们是希望二者能同时滑动。
第三种情况,比较常见的就是SlideMenu + ViewPager+Fragment,然后Fragment里面又是一个RecyclerView或ListView,虽然看上去更复杂了,但其实它是1和2的组合,只要我们能将1和2逐个击破,也就能顺利解决问题了。
滑动冲突的一般处理规则
针对第一种场景,通常是左右互动时外部的ViewPager处理,上下滑动是,内部的ListView处理。具体即使先判断当前滑动是竖直滑动还是水平滑动,然后再将事件分发给合适的View处理。
针对第二种场景,我们就不能想第一种那样简单处理了,这个时候我们要结合具体的业务规则来判断事件的分发。
针对第三种场景,处理同2,得结合业务分析。
滑动冲突的解决方式
为什么会滑动冲突,因为系统不知道事件到底要以何种规则来交给不同的View来处理,所以解决滑动冲突的基础就是深入了解Android View的事件传递机制。其实核心方法就是onInterceptTouchEvent和requestDisallowInterceptTouchEvent。
外部拦截法
主要是重写外层View容器的onInterceptTouchEvent来决定事件的分发;
内部拦截法
主要是重写内部View的requestDisallowInterceptTouchEvent来干涉外部View容器的onInterceptTouchEvent对事件分发的处理。
总结
掌握View的基础知识,是自定义View的基础,掌握View的事件分发是定义出好控件的基础,同时也是解决好滑动冲突的基础。虽然View的事件分发看起来比较复杂,但是结合源码来分析,还是比较容易理解的,所以没事多看看源码,你会受益匪浅的。