# 3 View的事件体系
3.1 view的基础知识
3.1.1什么是view
View是Android中所有控件的基类,View的本身可以是单个空间,也可以是多个控件组成的一组控件,即ViewGroup,ViewGroup继承自View,其内部可以有子View,这样就形成了View树的结构。
3.1.2 View的位置参数
View的位置主要由它的四个顶点来决定,即它的四个属性:top、left、right、bottom,分别表示View左上角的坐标点(top,left)以及右下角的坐标点(right,bottom)。同时,我们可以得到View的大小:
width = right - left
height = bottom - top
而这四个参数可以由以下方式获取:
- Left = getLeft();
- Right = getRight();
- Top = getTop();
- Bottom = getBottom();
Android3.0后,View增加了x、y、translationX和translationY这几个参数。其中x和y是View左上角的坐标,而translationX和translationY是View左上角相对于容器的偏移量。
他们之间的换算关系如下:
x = left + translationX;
y = top + translationY;
注意:View在平移的过程中,top和left不会改变,改变的是x、y、translationX和translaY。
3.1.3 MotionEvent和TouchSlop
1 MotionEvent
在手指接触到屏幕后会产生乙烯类的点击事件,如
- 点击屏幕后离开松开,事件序列为DOWN->UP
点击屏幕滑动一会再松开,事件序列为DOWN->MOVE->...->MOVE->UP 通过MotionEven对象我们可以得到事件发生的x和y坐标,我们可以通过getX/getY和getRawX/getRawY得到,它们的区别是:getX/getY返回的是相对于当前View左上角的x和y坐标,getRawX/getRawY返回的是相对于手机屏幕左上角的x和y坐标。
2 TouchSloup
TouchSloup是系统所能识别出的被认为是滑动的最小距离,这是一个常量,与设备有关,可通过以下方法获得:
ViewConfiguration.get(getContext()).getScaledTouchSloup().
3.1.4 VelocityTracker、GestureDetector和Scroller
1 VelocityTracker
速度追踪,用于追踪手指在滑动过程中的速度,包括水平放向速度和竖直方向速度。 使用方法:
- 在View的onTouchEvent方法中追踪当前单击事件的速度
VelocityRracker velocityTracker = VelocityTracker.obtain(); velocityTracker.addMovement(event);
计算速度,获得水平速度和竖直速度
velocityTracker.computeCurrentVelocity(1000); int xVelocity = (int)velocityTracker.getXVelocity(); int yVelocity = (int)velocityTracker.getYVelocity();
注意,获取速度之前必须先计算速度,即调用computeCurrentVelocity方法,这里指的速度是指一段时间内手指滑过的像素数,1000指的是1000毫秒,得到的是1000毫秒内滑过的像素数。速度可正可负:
速度 = (终点位置 - 起点位置) / 时间段
- 在View的onTouchEvent方法中追踪当前单击事件的速度
- 最后,当不需要使用的时候,需要调用clear()方法重置并回收内存:
velocityTracker.clear(); velocityTracker.recycle();
2 GestureDetector
手势检测,用于辅助检测用户的单击、滑动、长按、双击等行为。 使用方法: - 创建一个GestureDetector对象并实现OnGestureListener接口,根据需要,也可实现OnDoubleTapListener接口从而监听双击行为:
GestureDetector mGestureDetector = new GestureDetector(this); //解决长按屏幕后无法拖动的现象 mGestureDetector.setIsLongpressEnabled(false);
- 在目标View的OnTouchEvent方法中添加以下实现:
boolean consume = mGestureDetector.onTouchEvent(event); return consume;
- 实现OnGestureListener和OnDoubleTapListener接口中的方法,其中常用的方法有:onSingleTapUp(单击)、onFling(快速滑动)、onScroll(拖动)、onLongPress(长按)和onDoubleTap(双击)。
建议:如果只是监听滑动相关的,可以自己在onTouchEvent中实现,如果要监听双击这种行为,那么就使用GestureDetector。
3 Scroller
弹性滑动对象,用于实现View的弹性滑动。其本身无法让View他行滑动,需要和View的computeScroll方法配合使用才能完成这个功能。 使用方法:
原理下节讲。Scroller scroller = new Scroller(mContext); //缓慢移动到指定位置 private void smoothScrollTo(int destX,int destY){ int scrollX = getScrollX(); int delta = destX - scrollX; //1000ms内滑向destX,效果就是慢慢滑动 mScroller.startScroll(scrollX,0,delta,0,1000); invalidata(); } @Override public void computeScroll(){ if(mScroller.computeScrollOffset()){ scrollTo(mScroller.getCurrX,mScroller.getCurrY()); postInvalidate(); } }
3.2 View的滑动
3.2.1 使用scrollTo/scrollBy
- scrollBy实际调用了scrollTo,它实现了基于当前位置的相对滑动,而scrollTo则实现了绝对滑动。
- scrollTo和scrollBy只能改变View的内容位置而不能改变View在布局中的位置。
- 滑动偏移量mScrollX和mScrollY的正负与实际滑动方向相反,即从左向右滑动,mScrollX为负值,从上往下滑动mScrollY为负值。
3.2.2 使用动画
使用动画移动View,主要是操作View的translationX和translationY属性,既可以采用传统的View动画,也可以采用属性动画,如果使用属性动画,为了能够兼容3.0以下的版本,需要采用开源动画库nineolddandroids。 如使用属性动画:(View在100ms内向右移动100像素)ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
3.2.3 改变布局属性
通过改变布局属性来移动View,即改变LayoutParams。3.2.4 各种滑动方式的对比
- scrollTo/scrollBy:操作简单,适合对View内容的滑动;
- 动画:操作简单,主要适用于没有交互的View和实现复杂的动画效果;
改变布局参数:操作稍微复杂,适用于有交互的View。
3.3 弹性滑动
3.3.1 使用Scroller
使用Scroller实现弹性滑动的典型使用方法如下:
Scroller scroller = new Scroller(mContext); //缓慢移动到指定位置 private void smoothScrollTo(int destX,int dextY){ int scrollX = getScrollX(); int deltaX = destX - scrollX; //1000ms内滑向destX,效果就是缓慢滑动 mScroller.startSscroll(scrollX,0,deltaX,0,1000); invalidate(); } @override public void computeScroll(){ if(mScroller.computeScrollOffset()){ scrollTo(mScroller.getCurrX(),mScroller.getCurrY()); postInvalidate(); } }
从上面代码可以知道,我们首先会构造一个Scroller对象,并调用他的startScroll方法,该方法并没有让view实现滑动,只是把参数保存下来,我们来看看startScroll方法的实现就知道了:
public void startScroll(int startX,int startY,int dx,int dy,int duration){ mMode = SCROLL_MODE; mFinished = false; mDuration = duration; mStartTime = AnimationUtils.currentAminationTimeMills(); mStartX = startX; mStartY = startY; mFinalX = startX + dx; mFinalY = startY + dy; mDeltaX = dx; mDeltaY = dy; mDurationReciprocal = 1.0f / (float)mDuration; }
可以知道,startScroll方法的几个参数的含义,startX和startY表示滑动的起点,dx和dy表示的是滑动的距离,而duration表示的是滑动时间,注意,这里的滑动指的是View内容的滑动,在startScroll方法被调用后,马上调用invalidate方法,这是滑动的开始,invalidate方法会导致View的重绘,在View的draw方法中调用computeScroll方法,computeScroll又会去向Scroller获取当前的scrollX和scrollY;然后通过scrollTo方法实现滑动,接着又调用postInvalidate方法进行第二次重绘,一直循环,知道computeScrollOffset()方法返回值为false才结束整个滑动过程。 我们可以看看computeScrollOffset方法是如何获得当前的scrollX和scrollY的:
public boolean computeScrollOffset(){ ... int timePassed = (int)(AnimationUtils.currentAnimationTimeMills() - mStartTime); if(timePassed < mDuration){ switch(mMode){ case SCROLL_MODE: final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal); mCurrX = mStartX + Math.round(x * mDeltaX); mCurrY = mStartY + Math.round(y * mDeltaY); break; ... } } return true; }
到这里我们就基本明白了,computeScroll向Scroller获取当前的scrollX和scrollY其实是通过计算时间流逝的百分比来获得的,每一次重绘距滑动起始时间会有一个时间间距,通过这个时间间距Scroller就可以得到View当前的滑动位置,然后就可以通过scrollTo方法来完成View的滑动了。
3.3.2 通过动画
动画本身就是一种渐近的过程,因此通过动画来实现的滑动本身就具有弹性。实现也很简单:
ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
当然,我们也可以利用动画来模仿Scroller实现View弹性滑动的过程:
final int startX = 0; final int deltaX = 100; ValueAnimator animator = ValueAnimator.ofInt(0,1).setDuration(1000); animator.addUpdateListener(new AnimatorUpdateListener(){ @override public void onAnimationUpdate(ValueAnimator animator){ float fraction = animator.getAnimatedFraction(); mButton1.scrollTo(startX + (int) (deltaX * fraction) , 0); } }); animator.start();
上面的动画本质上是没有作用于任何对象上的,他只是在1000ms内完成了整个动画过程,利用这个特性,我们就可以在动画的每一帧到来时获取动画完成的比例,根据比例计算出View所滑动的距离。
3.3.3 使用延时策略
延时策略的核心思想是通过发送一系列延时信息从而达到一种渐近式的效果,具体可以通过Hander和View的postDelayed方法,也可以使用线程的sleep方法。 下面以Handler为例:
private static final int MESSAGE_SCROLL_TO = 1; private static final int FRAME_COUNT = 30; private static final int DELATED_TIME = 33; private int mCount = 0; @suppressLint("HandlerLeak") private Handler handler = new handler(){ public void handleMessage(Message msg){ switch(msg.what){ case MESSAGE_SCROLL_TO: mCount ++ ; if (mCount <= FRAME_COUNT){ float fraction = mCount / (float) FRAME_COUNT; int scrollX = (int) (fraction * 100); mButton1.scrollTo(scrollX,0); mHandelr.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO , DELAYED_TIME); } break; default : break; } } }
3.4 View的事件分发机制
3.4.1 点击事件的传递规则
首先我们先看看下面一段伪代码,通过它我们可以理解到点击事件的传递规则:
public boolean dispatchTouchEvent (MotionEvent ev){ boolean consume = false; if (onInterceptTouchEvnet(ev){ consume = onTouchEvent(ev); } else { consume = child.dispatchTouchEnvet(ev); } return consume; }
上面代码主要涉及到以下三个方法:
public boolean dispatchTouchEvent(MotionEvent ev); 这个方法用来进行事件的分发 public boolean onInterceptTouchEvent(MotionEvent ev); 这个方法用来判断是否拦截事件 public boolean onTouchEvent(MotionEvent ev); 这个方法用来处理点击事件。
下面理一理点击事件的传递规则:对于一个根ViewGroup,点击事件产生后,首先会传递给他,这时候就会调用他的onDispatchTouchEvent方法,如果Viewgroup的onInterceptTouchEvent方法返回true表示他要拦截事件,接下来事件就会交给ViewGroup处理,调用ViewGroup的onTouchEvent方法;如果ViewGroup的onInteceptTouchEvent方法返回值为false,表示ViewGroup不拦截该事件,这时事件就传递给他的子View,接下来子View的dispatchTouchEvent方法,如此反复直到事件被最终处理。
当一个View需要处理事件时,如果它设置了OnTouchListener,那么onTouch方法会被调用,如果onTouch返回false,则当前View的onTouchEvent方法会被调用,返回true则不会被调用,同时,在onTouchEvent方法中如果设置了OnClickListener,那么他的onClick方法会被调用。由此可见处理事件时的优先级关系: onTouchListener > onTouchEvent > onClickListener
关于事件传递的机制,这里给出一些结论:
- 一个事件系列以down事件开始,中间包含数量不定的move事件,最终以up事件结束。
- 正常情况下,一个事件序列只能由一个View拦截并消耗。
- 某个View拦截了事件后,该事件序列只能由它去处理,并且它的onInterceptTouchEvent不会再被调用。
- 某个View一旦开始处理事件,如果它不消耗ACTION_DOWN事件(onTouchEvnet返回false),那么同一事件序列中的其他事件都不会交给他处理,并且事件将重新交由他的父元素去处理,即父元素的onTouchEvent被调用。
- 如果View不消耗ACTION_DOWN以外的其他事件,那么这个事件将会消失,此时父元素的onTouchEvent并不会被调用,并且当前View可以持续收到后续的事件,最终消失的点击事件会传递给Activity去处理。
- ViewGroup默认不拦截任何事件。
- View没有onInterceptTouchEvent方法,一旦事件传递给它,它的onTouchEvent方法会被调用。
- View的onTouchEvent默认消耗事件,除非他是不可点击的(clickable和longClickable同时为false)。
- View的enable不影响onTouchEvent的默认返回值。
- onClick会发生的前提是当前View是可点击的,并且收到了down和up事件。
- 事件传递过程总是由外向内的,即事件总是先传递给父元素,然后由父元素分发给子View,通过requestDisallowInterceptTouchEvent方法可以在子元素中干预父元素的分发过程,但是ACTION_DOWN事件除外。
3.5 滑动冲突
在界面中,只要内外两层同时可以滑动,这个时候就会产生滑动冲突。3.5.1 常见的滑动冲突场景
- 外部滑动和内部滑动方向不一致;
- 外部滑动方向和内部滑动方向一致;
- 上面两种情况的嵌套。
3.5.2 滑动冲突的处理规则
- 对于场景一,处理的规则是:当用户左右(上下)滑动时,需要让外部的View拦截点击事件,当用户上下(左右)滑动的时候,需要让内部的View拦截点击事件。根据滑动的方向判断谁来拦截事件。
- 对于场景二,由于滑动方向一致,这时候只能在业务上找到突破点,根据业务需求,规定什么时候让外部View拦截事件,什么时候由内部View拦截事件。
- 场景三的情况相对比较复杂,同样根据需求在业务上找到突破点。
3.5.3 滑动冲突的解决方式
- 外部拦截法:所谓的外部拦截法是指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,否则就不拦截。下面是伪代码:
public boolean onInterceptTouchEvent (MotionEvent event){ boolean intercepted = false; int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: intercepted = false; break; case MotionEvent.ACTION_MOVE: if (父容器需要当前事件) { intercepted = true; } else { intercepted = flase; } break; } case MotionEvent.ACTION_UP: intercepted = false; break; default : break; } mLastXIntercept = x; mLastYIntercept = y; return intercepted;
内部拦截法:内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗,否则就交由父容器进行处理。这种方法与Android事件分发机制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作。下面是伪代码:
public boolean dispatchTouchEvent ( MotionEvent event ) { int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction) { case MotionEvent.ACTION_DOWN: parent.requestDisallowInterceptTouchEvent(true); break; case MotionEvent.ACTION_MOVE: int deltaX = x - mLastX; int deltaY = y - mLastY; if (父容器需要此类点击事件) { parent.requestDisallowInterceptTouchEvent(false); } break; case MotionEvent.ACTION_UP: break; default : break; } mLastX = x; mLastY = y; return super.dispatchTouchEvent(event); }
除了子元素需要做处理外,父元素也要默认拦截除了ACTION_DOWN以外的其他事件,这样当子元素调用parent.requestDisallowInterceptTouchEvent(false)方法时,父元素才能继续拦截所需的事件。因此,父元素要做以下修改:
public boolean onInterceptTouchEvent (MotionEvent event) { int action = event.getAction(); if(action == MotionEvent.ACTION_DOWN) { return false; } else { return true; } }
至于具体的实现可以根据实际需要去修改拦截成立的条件,开发艺术艺术中也给出了实例,具体可参考书中P161-P173。