View的事件体系简述
View的基础知识
什么是View
View是Android中所有控件的基类,不管是简单的Button以及TextView还是复杂的RelativeLayout与ListView,它们的共同基类都是View。所以说View是一种界面层的控件的一种抽象,它代表了一个控件。除了View,还有ViewGroup,顾名思义,它内部包含了很多控件,ViewGroup也是继承自View,这意味着View本身就可以是单个控件也可以是多个控件组成的一组控件,通过这种关系就形成了View树的结构,这个和Web中的DOM树的概念很相似。View就相当于Dom中的节点,它可以是单个的View,也可以是包含View的ViewGroup。所以说Android界面构成实质上就是View树的绘制。
View的位置参数
View的位置主要由它的四个顶点来决定,分别对应于View的四个属性:top、left、right、buttom,其中(left,top)代表左上角的坐标,(right,bottom)代表右下角的坐标,需要注意的是,这些坐标都是相对于父容器来说的,它是一种相对坐标,View的坐标和父容器的关系如下图:
在Android中,x轴和Y轴的正方向分别为向右与向下,这点不难理解,不仅仅是Android,大部分显示系统都是按照这个标准来定义坐标的。由图可知:1
2width = right - left;
height = bottom - top;
View的四个参数对应的获取方法分别为:1
2
3
4left = getLeft();
right = getRight();
top = getTop();
bottom = getBottom();
在Android3.0新增了几个参数,x、y、translationX以及translationY,其中x与y是View左上角的坐标,而translationX与translationY是View左上角相对于父容器的偏移量,这几个参数也是相对于父容器,而translationX与translationY的默认值为0,和View的四个基本参数一样,View也为其提供了get/set方法,这个参数的换算关系如下:1
2x = left + translationX;
y = top + translationY;
需要注意的是,在平移的过程中,top与left分别代表的是原始左上角的位置信息,其值并不会改变,此时变化的是x、y、translationX以及translationY这四个参数。
MotionEvent与TouchSlop
MotionEvent
在手指触摸屏幕后所产生的一系列事件中,典型的事件类型有以下几种:
- ACTION_DOWN——手指刚接触屏幕
- ACTION_MOVE——手指在屏幕滑动
- ACTION_UP——手指从屏幕上松开的瞬间
正常情况下,一次手指触摸屏幕的行为会触发一系列点击事件,考虑如下几种情况:
- 点击屏幕后松开,事件顺序为DOWM——UP;
- 点击屏幕后滑动一会松开,事件顺序为DOWN——MOVE——MOVE…——UP;
上述三种情况是典型的事件序列,通过可以通过MotionEvent得到点击事件发生的x以及Y坐标。为此,系统提供了两组方法:getX/getY以及getRawX/getRawY。它们的区别很简单,前者返回的是相对于当前View左上角的x和y的坐标,而后者返回的是相对于手机屏幕左上角的x和y的坐标
TouchSlop
TouchSlop是系统所能识别出的被认为是滑动的最小距离,换句话说,当手指在屏幕上滑动时,如果两次的滑动之间的距离小于这个常量,那么系统就不认为你是在进行滑动操作。原因很简单,滑动距离太小,系统不认为这是一个滑动操作。这是一个常量,和设备有关,在不同的设备上这个值可能是不同的,通过如下方式可以获取这个常量:1
ViewConfigution.get(getContext()).getScaleTouchSlop()
这个常量有什么意义呢?当我们在处理滑动时,可以利用这个常量来做一些过滤,比如当两次滑动事件的滑动距离小于这个值,我们就可以认为未达到滑动距离的临界值,因此就可以认为它们不是滑动,这样做可以有更好的用户体验。
VelocityTracker、GestureDetector和Scroller
VelocityTracker
速度追踪,用于追踪手指在滑动过程中的速度,包括水平与垂直方向的速度。它的使用过程很简单,首先,在View的onTouchEvent方法中追踪当前点击事件的速度:1
2
3
4
5
6
7
8
9
10//表示单位时间内像素数
VelocityTracker velocityTracker = VelocityTracker.obtain();
velocityTracker.addMovement(event);
velocityTracker.computeCurrentVelocity(1000);
int xVelocity = (int)velocityTracker.getXVelocity();
int yVelocity = (int)velocityTracker.getYVelocity();
// 不用的话就将其释放
velocityTracker.clear();
velocityTracker.recycle();
gestureDetector
手势检测,用于辅助检测用户的单机、滑动、长按、双击等行为。使用GestureDetector很简单,只需要创建一个GestureDetector对象并且实现OnGestureListener接口,接着接管onTouchEvent方法,具体实现如下:1
2
3
4
5
6GestureDetector mGestureDetector = new GesTureDector(this);
//解决长按屏幕无法拖动的现象
mGestureDetector.setIsLongpressEnabled(false);
//在待监听的View的onTouchEvent方法中实现如下:
boolean consume = mGestureDetector.onTouchEvent(event);
return consume;
当然,OnGestureListener还有很多回调方法,这里就不一一介绍了。
Scroller
弹性滑动对象,用于实现View的弹性滑动。我们知道,当使用View的scrollBy或者scrollTo方法来进行滑动时,其过程时瞬间完成的,这个没有过渡效果的滑动用户体验不好。它的典型代码时固定的,具体实现如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17Scroller scroller = new Scroller(mContext);
//缓慢滑动到指定位置
private void smoothScroller(int destX, int destY){
int scrollX = getScrollX();
int delta = destX - scrollX;
//1000ms内滑动到destX,效果就是慢慢滑动
mScroller.startScroll(scrollX,0,delta,0,1000);
//startScroll只是做了更新数据,真正进行滑动是由invalidate()方法执行的
invalidate();
}
@Override
public void computeScroll(){
if(mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
postInvalidate();
}
补充说明以下,弹性滑动除了可以使用Scroller实现,还可以使用动画以及延时策略。使用动画只要为其设置一个执行动画的时间值即可,而延迟策略就是采用postDelay或者sleep等方法延迟执行滑动动作,从而可以实现弹性滑动的效果。
View的滑动
View的滑动,在Android设备上,滑动几乎是应用的标配,不管是下拉刷新还是SlidingMenu,它们的基础都是滑动。从另一方面说,Android手机屏幕比较小,为了给用户呈现更多的内容,就需要滑动来显示与隐藏一些内容。由此可见,滑动在Android开发中是多么的重要。
scrollTo与scrollBy
scrollTo与scrollBy是View提供的两个实现滑动的方法,scrollBy的内部实现也是scrollTo,只是入参不同而已,只不过scrollBy是基于当前位置的滑动,而scrollTo是基于所传参数的绝对滑动。
使用动画
动画本身就支持平移等操作,平移就是一种滑动,使用动画来移动View,主要就是操作View的translationX以及translationY属性,既可以使用传统的View动画,也可以使用属性动画(属性动画是在Android3.0引入的,如果对Android版本要求不高,优先使用属性动画。)
改变布局参数
改变布局参数,即改变LayoutParam。1
2
3
4
5MarginLayoutParam param = (MarginLayoutParam)button.getLayoutParam();
param.width += 10;
param.leftMargin += 100;
button.setLayoutParam(param);
//或者button.requestLayout();
各种滑动方式对比
先看scrollTo/scrollBy这种方式,它是View提供的原生的方法,其作用是专门用于View的滑动,它可以比较方便的实现滑动效果并且不影响内部元素的点击事件。但它的最大缺点也是很显然:它只能滑动View的内容,不能滑动View本身以及View在布局中的位置。
动画来实现滑动的话,要分情况,如果是Android3.0以上的话,用属性动画来实现,没有什么明显的缺点;如果是View的动画或者在Android3.0以下使用属性动画,均不能改变View的属性。在实际使用中,如果动画不影响交互的话,那么使用动画来做滑动是比较合适的,否则不合适。但是动画有个很明显的优点,那就是一些复杂的效果必须要用动画来实现。
使用改变布局的这种方式,除了使用起来比较麻烦,也没有明显的缺点,它的主要适用对象是一些具有交互性的View,因为这些View需要和用户交互,使用动画就会有问题。
所以总结以下就是:
- scrollTo/scrollBy:操作简单,适合对View内容的滑动
- 动画:操作简单,主要使用于没有交互的View以及复杂的动画效果
- 改变布局参数:操作稍微复杂,适用于有交互的View
事件分发机制
点击事件的传递规则
在介绍点击事件的传递规则前,首先我们要明白这里要分析的对象是MotionEvent,即点击事件。所谓点击事件的分发,其实就是对MotionEvent事件的分发过程,即当一个MotionEvent产生了以后,系统需要把这个事件传递给一个具体的View,而这个传递过程就是分发过程。点击事件的分发由三个很重要的方法来共同完成:dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent。
- dispatchTouchEvent:用来进行事件分发。如果事件能够到达当前View,那么此方法一定会被调用,返回结果受当前View以及下级的dispatchTouchEvent方法的影响,表示是否消耗当前事件。
- onInterceptTouchEvent:在dispatchTouchEvent方法中调用,用来判断是否拦截某个事件,如果当前View拦截了某个事件,那么在同一个事件序列中,此方法不会被再次调用,返回结果表示是否拦截此事件。
- onTouchEvent:在dispatchTouchEvent方法中调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在用一个事件序列,当前事件无法再次接收到事件。
三个方法之间的关系用伪代码表示如下:1
2
3
4
5
6
7
8
9public boolean dispatchTouchEvent(MotionEvent e){
boolean consume = false;
if(onInterceptTouchEvent(e)){
consume = onTouchEvent(e);
}else{
consume = child.dispatchTouchEvent(e);
}
return consume;
传递规则
对于根ViewGroup来说,点击事件产生后,首先会传递给它,这时它的dispatchTouchEvent就会被调用,如果这个ViewGroup的onInteceptTouchEvent返回true就表示它要拦截当前事件,接着这个事件就会这个ViewGroup来处理,即它的的onTouchEvent方法就会被调用;如果这个onInterceptTouchEvent方法返回false,就表示它不拦截当前事件,这时当前事件就会继续传递给它的子元素,接着子元素的dispacthTouchEvent方法就会被调用,如此反复直到事件被最终处理为止。
当一个View处理事件时,如果它设置了OnTouchListener,那么OnTouchListenr中的onTouch方法就会被调用。这时事件如何处理还要看onTouch的返回值,如果返回false,则当前View的onTouchEvent方法会被调用;如果返回true,那么onTouchEvent方法将不会被调用。由此可见,给View设置的onTouchListener,其优先级比onTouchEvent要高。在onTouchEvent方法中,如果当前方法有设置OnClickListener,那么它的onClick方法将被调用。可以看出,平时我们常用的OnClickListener,其优先级最低,即处于事件传递的尾端。
当一个事件产生后,它的传递过程遵循如下顺序:Activity->Window->View,即事件总是先传递给Activity,Activity在传给Window,最后Window再传递给View。顶级View接收到事件后,就会按照事件分发机制去分发事件。考虑一种情况,如果一个View的OnTouchEvent返回false,那么它的父容器的onTouchEvent将被调用,依次类推。如果所有的元素都不处理这个事件,那么这个事件最终将会交给Activity来处理,即Activity的onTouchEvent方法将会被调用。这个过程其实很好理解,我们换一种思路,加入点击事件是一个难题,这个难题被上级领导分配给了一个程序员去处理(这是事件分发过程),结果这个程序员搞不定(onTouchEvent方法返回了false),现在该怎么办呢?难题必须要解决,那只能交由水平更高的上级去解决(上级的onTouchEvent被调用),如果上级再搞不定,那只能交由上级的上级去解决,就这样将难题一层层的往上抛,这是公司内部一种很常见的处理问题的机制。从这个角度来看,View的事件传递还是还贴近现实的,毕竟程序员也生活在现实生活中。
关于事件传递的机制,这里给出一些结论,根据这些结论可以更好的理解整个传递机制:
- 同一个事件序列是指从手指接触屏幕的那一刻起,到手指离开屏幕的那一刻结束,在这个过程所产生的一系列的事件,这个事件序列以down开始,中间含有数量不定的move事件,最终以up事件结束。
- 正常情况下,一个事件序列只能被一个View拦截且消耗。这一条的原因可以参考(3),因为一旦一个元素拦截了此事件,那么同一个事件序列的所有事件都会直接交由它处理,因此同一个事件序列中的事件不能同时由两个View处理,但是通过特殊的手段可以做到,比如一个View将本该自己处理的事件通过onTouchEvent强行传递给其它的View处理。
- 某个View一旦决定开始拦截,那么这一个事件序列都只能由它来处理(如果事件序列能够传递给它的话),并且它的onInteceptTouchEvent不会再被调用。这条也很好理解,就是说当一个View决定拦截一个事件后,那么系统会把同一个事件序列内的方法都直接交由它来处理,因此就不用再调用这个View的onInterceptTouchEvent去询问它是否要拦截了。
- 某个View事件一旦开始处理事件,如果它不消耗ACTION_DOWN事件(onTouchEvent返回了false),那么同一事件序列中的其它事件都不会交给他处理,并且事件将重新交给他的父元素去处理,即父元素的onTouchEvent会被调用。意思就是一个事件一旦交给一个View处理,那么它就必须消耗掉,否则同一事件序列中剩下的事件就不在交给它处理了,这就好比上级交给程序员一件事,如果这件事没有处理好,短期内这个上级就不敢把事件交给这个程序员做了,二者是类似的道理。
- 如果View不消耗除了ACTION_DOWN以外的其它点击事件,那么这个点击事件就会消失,此时父元素的onTouchEvent并不会被调用,并且当前View可以持续收到后续的事件,最终这些消失的点击事件会传递给Activity处理。
- ViewGroup默认不拦截任何事件。Android源码中ViewGroup的onInterceptTouchEvent方法默认返回false。
- View没有onInterceptTouchEvent方法,一旦有点击事件传递给它,那么它的onTouchEvent方法就会被调用。
- View的onTouchEvent默认都会消耗事件(即返回true),除非它是不可点击的(clickable和longClickable为false)。View的longClickable默认为false,clickable要分情况,比如Button的clickable属性默认为true,而TextView的clickable属性默认为false。
- View的enable属性不影响onTouchEvent的默认返回值。哪怕一个View是disable状态的,只要它的clickable或者longClickable为true,那么它的onTouchEvent就返回true。
- onClick能发生的前提是View是可点击的,并且它受到了down与up的事件。
- 事件传递过程是由外向内的,即事件总是先传递给父元素,然后再有父元素分发给子View,通过requestDisallowInteceptTouchEvent方法可以在子元素中干预父元素的事件分发过程,但是ACTION_DOWN事件除外。
滑动冲突
相信开发Android的都会有这种体会:滑动冲突太坑人了,本来网上下载好的demo好好的,但是只要出现滑动冲突,demo就无法正常工作了。那么滑动冲突时如何产生的呢?其实在界面中,只要内外两层可以同时滑动,这个时候就会产生滑动冲突。如何解决滑动冲突呢?这既是一件困难的事又是一件简单的事,说困难是因为很多开发者面对滑动冲突都会显得束手无策,说简单是因为滑动冲突的解决有固定的套路,只要知道了这个套路问题就好解决了。
常见的滑动冲突场景
常见的滑动冲突可以分为以下三种:
- 场景1——外部滑动方向与内部滑动方向不一致
- 场景2——外部滑动方向与内部滑动方向一致
- 上面两种情况的嵌套
先说场景1,主要是将ViewPager与Fragment配合使用所组成的页面滑动效果,主流应用几乎都会使用这个效果。在这种效果中,可以通过左右滑动来切换页面,而每个页面内部又嵌套了一个ListView。本来这种情况是有滑动冲突的,但是ViewPager内部处理了这个滑动冲突,因此采用ViewPager时我们无须关注此问题。如果我们采用的不是ViewPager而是ScrollView等,那就必须手动处理滑动冲突了,否则造成的后果就是内外两层只有一层能进行滑动,这是因为两者之间的滑动事件有冲突。除了这种典型情况外,还存在其它的情况,比如外部上下滑动,内部左右滑动等,但是它们属于同一类滑动冲突。
再说场景2,这种情况稍微复杂些,当内外两层都在同一个方向可以进行滑动时,显然存在逻辑问题。因为当手指开始滑动时,系统无法知道用户到底是想让哪一层滑动,所以当手指滑动的时候就会出现问题,要么只有一层滑动,要么就是两层都滑动但是很卡顿。在实际开发中,这种场景主要是指内外两层同时上下滑动或者内外层同时左右滑动。
最后说下场景3,其实就是上面两种情况的嵌套,因此场景3的滑动冲突看起来就更复杂了。虽然说场景3滑动冲突看起来很复杂,但是它是几个单一滑动冲突的叠加,因此只需要处理内层和中层、中层与外层之间的滑动冲突即可,而具体的处理方法其实是和场景1、场景2相同的。
从本质上讲,这三种滑动冲突场景的复杂度其实是相同的,因为它们的区别仅仅是滑动策略的不同,至于解决滑动冲突的方法,它们几个是通用的,以下篇幅会做相应的介绍。
滑动冲突的处理规则
一般来说,不管滑动冲突多么复杂,它都有既定的规则,根据这些规则我们可以选择合适的方法去处理。
对于场景1,它的处理规则是:当用户左右滑动时,需要让外部的View拦截点击事件,当用户上下滑动时,需要让内部的View拦截点击事件。这个时候我们就可以根据它的特征来解决滑动冲突,具体来说是:根据滑动是水平滑动还是竖直滑动来判断到底是谁来拦截事件。根据滑动过程中两个点之间的坐标就可以得出到底是水平还是竖直滑动。简单来说,可以有很多的参考,依据滑动路径与水平方向上的夹角,也可以一句水平方向与竖直方向的距离差来判定,某些特殊时候还可以依据水平与竖直方向的速度差来判断。选用规则可以结合具体场景来选择。
对于场景2,比较特殊,无法根据角度、距离差以及速度差来判断,但是这个时候一般都能在业务上找到突破点,比如业务上规定:当处于某种状态时需要外部View响应用户的滑动,而处于另外一种状态时需要内部View来响应View的滑动,根据这种业务需求我们也能指定出相应的处理规则,有了处理规则同样可以进行下一步的处理。
对于场景3,它的滑动规则更复杂了,和场景2一样,它也无法根据角度、距离差以及速度差来判断,同样还是只能从业务上找突破点,具体方法和场景2一样,都是从业务的需求得出对应的处理规则。
滑动冲突的解决方式
首先我们分析滑动场景,这也是最简单、最典型的滑动冲突,因为它的滑动规则比较简单,不过多复杂的滑动冲突,它们之间的区别仅仅是滑动规则不同而已。抛开滑动规则不说,我们需要找到一种不依赖具体滑动的滑动规则的通用的解决方法,在这里我们需要根据场景1得出通用的解决方案,然后场景2、3只需要修改有关滑动规则的逻辑即可。
外部拦截法
所谓外部拦截法是指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就进行拦截,如果不需要此事件就不拦截,这样就可以解决滑动冲突问题,这种方法比较符合事件的分发机制。外部拦截法需要重写父容器的onteceptTouchEvent方法,在内部做相应的拦截即可,伪代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23public boolean onInterceptTouchEvent(MotionEvent event){
boolean intecepted = false;
int x = event.getX();
int y = event.getY();
switch(event.getAction()){
case MotionEvent.ACTION_DOWN:
inteceped = false;
break;
case MotionEvent.ACTION_MOVE:
if("父容器需要拦截此事件")
intecepted =true;
else
intecepted = false;
break;
case MotionEvent.ACTION_UP:
intecepted = false;
break;
default:
break;
}
return intecepted;
}
上述代码是外部拦截法的典型逻辑,针对不同的滑动冲突,只需要修改父容器需要当前点击事件这个条件即可,其它均不需做修改并且也不能修改。这里对上述代码在描述一下,在onInteceptTouchEvent方法中,首先是ACTION_DOWN事件,父容器必须返回false,即不拦截此事件,这是因为一旦父容器拦截了此事件,后续的事件都会直接交由父容器处理,这个时候事件就没办法再传递给子元素了;其次是ACTION_MOVE事件,这个事件可以根据需要来决定是否需要拦截,如果父容器需要拦截就返回true,否则返回false;最后是ACTIO_UP事件,这里必须返回false,因为ACTION_UP事件本身没有太多意义。
考虑一种情况,假设事件交由子元素处理,如果父容器再ACTION_UP时返回了true,就会导致子元素无法接收到ACTION_UP事件,这个时候子元素的onClick方法就无法触发,但是父容器比较特殊,一旦它开始拦截任何一个事件,那么后续的事件都会交由它处理,而ACTION_UP作为最后一个事件也必定可以传递给父容器,即便父容器的onInteceptTouchEvent方法方法在ACTION_UP中返回了false。
内部拦截法
内部拦截法是指父容器不拦截任何事件,所有的事件都交由子元素,如果子元素需要此事件就消耗掉,否则就交由父容器处理,这种 方法和Android中的事件分发不一致,需要配合requestDisallowInteceptTouchEvent方法才能正常工作,使用起来较外部拦截法稍显复杂。伪代码如下,我们需要重写dispatchTouchEvent方法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public boolean onInterceptTouchEvent(MotionEvent event){
int x = event.getX();
int y = event.getY();
switch(event.getAction()){
case MotionEvent.ACTION_DOWN:
parent.requestDisallowInteceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
if("父容器需要此类点击事件")
parent.requestDisallowInteceptTouchEvent(false);
break;
case MotionEvent.ACTION_UP:
break;
default:
break;
}
return super.dispatchTouchEvent(event);
}
上述代码是内部拦截法的典型代码,当面对不同的滑动策略时只需要修改里面的条件即可,其它不需要改动而且也不能改动。除了子元素需要做特殊处理外,父元素也要默认拦截除了ACTION_DOWN以外的其它事件,这样当子元素调用parent.requestDisallowInteceptTouchEvent(false)时,父元素才能继续拦截所需的事件。
为什么父容器不能拦截ACTION_DOWN事件呢?那是因为ACTION_DOWN事件并不受FLAG_DISALLOW_INTERCEPT这个标记位控制,所以一旦父容器拦截ACTION_DOWN事件,那么所有的事件都无法传到子元素中了,这样内部拦截就不起作用了。父元素所作修改如下:1
2
3
4
5
6
7
8public boolean onInterceptTouchEvent(MotionEvent event){
int action = event.getAction();
if(action == MotionEvent.ACTION_DOWN){
return false;
}else{
return true;
}
}
滑动冲突总结
所以对于滑动冲突,内部拦截法与外部拦截法都适用,当面对不同的滑动策略时只需要修改里面的条件即可,外部拦截法相对内部拦截法来说更简单更容易理解,可以优先考虑使用。
参考书籍: 《Android开发艺术探索》