View的事件体系
一 View的基础知识
什么是View
View是android所有控件的基类,View是一种界面层控件的一种抽象,代表了一个控件。所有控件都是View或者ViewGroup的子类,ViewGroup也继承了View,即所有控件都是View的子类。View的位置参数
View的位置主要由4个顶点来决定的,位置坐标都是相对坐标,相对于父容器。看下图:
上图我们可以得出View的宽高:width = right - left; height = bottom - top;
获取View的4个参数:
- Left = getLeft();
- Right = getRight();
- Top = getTop();
- Bottom = getBottom();
View新增了几个参数:x、y、translationX和translationY,其中x和y是View左上角的坐标,而translationX和translationY是View左上角相对于父控件容器的偏移量,默认值为0,参数关系如下所示:
x = left + translationX; y = right + translationY;
需要注意的地方:View在平移过程中,top和left表示的是原始左上角的位置信息,值不会发生改变,发生改变的是x、y、translationX和translationY。
MotionEvent和TouchSlop
MotionEvent
手指接触屏幕后产生的一些列事件,有如下几种:- ACTION_DOWN——手指刚接触屏幕
- ACTION_MOVE——手指在屏幕上移动
- ACTION_UP——手指从屏幕上松开的一瞬间
通过MotionEvent对象我们可以得到点击事件的x和y坐标:
getX/getY:返回当前View左上角的x和y坐标
getRawX/getRawY:返回的是相对于手机屏幕左上角的x和y坐标- TouchSlop
TouchSlop是系统所能识别的被认为是滑动的最小距离。我们可以通过ViewConfiguration.get(getContext()).getScaledTouchSlop()
获取这个常量,这个常量在不同设备上值可能是不同的。我们可以在源码中找到这个常量的定义,在frameworks/base/core/res/res/values/comfig.xml文件中。
VelocityTracker、GestureDetector和Scroller
VelocityTracker
速度追踪,用于追踪手指在滑动过程中的速度。包括水平和竖直方向的速度,使用过程很简单,步骤如下:VelocityTracker velocityTracker = VelocityTracker.obtain(); velocityTracker.addMovement(event);
我们想知道当前滑动速度时,可以采用如下方式获得当前速度:
velocityTracker.computeCurrentVelocity(1000);//单位是ms int xVelocity = (int) velocityTracker.getXVelocity(); int yVelocity = (int) velocityTracker.getYVelocity();
获取速度之前必须先计算速度,即
getXVelocity
和`getYVelocity
这两个方法前面必须先调用computeCurrentVelocity
方法;速度是指一段时间内手指滑过的像素数,速度可以为负数,当手指从右向左即为负值。速度计算公式如下:速度 = (终点位置 - 起点位置) / 时间段。
另外,
computeCurrentVelocity
方法的参数表示的是一个时间单元或者说是时间间隔,计算速度得到的就是时间间隔内手指在水平或者竖直方向上所滑动的像素数。
如果不需要使用的时候,我们需要调用clear方法重置并回收内存。velocityTracker.clear(); velocityTracker.recycle();
GestureDetector
手势检测,用户辅助检测用户的单击、滑动、长按、双击等行为。
手势检测一般需要创建一个GestureDetector对象并实现OnGestureListener
接口,还可以实现OnDoubleTapListener
从而能够监听双击行为:GestureDetector mGestureDetector = new GestureDetector(this); //解决长按屏幕后无法拖动的现象 mGestureDetector.setIsLongpressEnabled(false);
我们然后接管目标view的onTouchEvent方法,在待监听View的onTouchEvent方法中添加如下实现:
boolean consume = mGestureDetector.onTouchEvent(event); return consume;
我们可以选择实现
OnGestureListener
和OnDoubleTapListener
接口,方法如下所示:public class GestureListenerImpl implements GestureDetector.OnGestureListener,GestureDetector.OnDoubleTapListener { //手指轻触屏幕的一瞬间,有1个ACTION_DOWN触发 @Override public boolean onDown(MotionEvent e) { return false; } //手指轻触屏幕的一瞬间,尚未松开或拖动,有1个ACTION_DOWN触发 @Override public void onShowPress(MotionEvent e) { } //手指(轻轻触摸屏幕后)松开,伴随着一个MotionEvent ACTION_UP而触发,这是单击行为 @Override public boolean onSingleTapUp(MotionEvent e) { return false; } //手指按下屏幕并拖动,由一个ACTION_DOWN,多个ACTION_MOVE触发,这是拖动行为 @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { return false; } //用户长久按着屏幕不放,长按 @Override public void onLongPress(MotionEvent e) { } //用户按下触摸屏,快速滑动后松开,由1个ACTION_DOWN、多个ACTION_MOVE和1个ACTION_UP触发,这是快速滑动行为 @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { return false; } //严格的单击行为,触发了onSingleTapConfirmed,那么后面不可能再紧跟着另一个单击行为 @Override public boolean onSingleTapConfirmed(MotionEvent e) { return false; } //双击,由2次连续的单击组成,不能和onSingleTapConfirmed共存 @Override public boolean onDoubleTap(MotionEvent e) { return false; } //表示发生了双击行为,在双击的期间,ACTION_DOWN、ACTION_MOVE、ACTION_UP都会触发此回调 @Override public boolean onDoubleTapEvent(MotionEvent e) { return false; } }
方法很多,并不是都经常用到,我们如果只是监听滑动相关的,建议直接在
onTouchevent
中实现,如果监听双击这种行为,那么就使用GestureDetector
。- Scroller
弹性滑动对象,用于实现View的弹性滑动。Scroller本身无法让View弹性滑动,它需要和View的computeScroll方法配合使用才能共同完成这个功能。具体用法在View的滑动里面细讲。
二 View的滑动
使用scrollTo/scrollBy
/** * Set the scrolled position of your view. This will cause a call to * {@link #onScrollChanged(int, int, int, int)} and the view will be * invalidated. * @param x the x position to scroll to * @param y the y position to scroll to */ public void scrollTo(int x, int y) { if (mScrollX != x || mScrollY != y) { int oldX = mScrollX; int oldY = mScrollY; mScrollX = x; mScrollY = y; invalidateParentCaches(); onScrollChanged(mScrollX, mScrollY, oldX, oldY); if (!awakenScrollBars()) { postInvalidateOnAnimation(); } } } /** * Move the scrolled position of your view. This will cause a call to * {@link #onScrollChanged(int, int, int, int)} and the view will be * invalidated. * @param x the amount of pixels to scroll by horizontally * @param y the amount of pixels to scroll by vertically */ public void scrollBy(int x, int y) { scrollTo(mScrollX + x, mScrollY + y); }
源码可以看出,
scrollBy
实际上也是调用了scrollTo
方法,scrollBy()方法是让View相对于当前的位置滚动某段距离,而scrollTo()方法则是让View相对于初始的位置滚动某段距离,而初始位置是不变的,所以不改变参数的情况下,多次调用scrollTo()方法都将是滚动到同一个位置。在滑动过程中,scrollTo和scrollBy只能改变View内容的位置而不能改变View在布局中的位置,mScrollX 的值等于view左边缘和view内容左边缘在水平方向上的距离,mScrollY 同理,view边缘指view的位置,内容边缘指view在布局中的位置。mScrollX 和mScrollY 单位为像素,View左边缘在View内容边缘的右边时,mScrollX 为正值,反之为负值,即如果从左向右滑动,那么mScrollX 为负值,反之为正值,mScrollY 同理。使用动画
可以使用view动画和属性动画来实现平移,平移就是一种滑动,相对简单。改变布局参数
改变布局参数即改变LayoutParams,我们想把一个Button向右平移100px,我们只需要将这个Button的marginLeft参数的值增加100px即可;还可以在Button的左边放置一个空的view,view的默认宽度为0,当我们需要移动时,重新设置空view的宽度即可。- 各种滑动方式对比
- scrollTo/scrollBy滑动:操作简单,适合对View内容的滑动
- 动画:操作简单,主要适用于没有交互的view和实现复杂的动画效果
- 改变布局参数: 操作稍微复杂,适用于交互的view
三 弹性滑动
使用Scroller
Scroller的典型用法比较固定,如下所示:Scroller scroller = new Scroller(context); public void smoothScrollTo(int destX, int destY) { int scrollX = getScrollX(); int deltaX = destX - scrollX; //1000ms内滑向destX,效果就是慢慢滑动 scroller.startScroll(scrollX, 0, deltaX, 0, 1000); invalidate(); } @Override public void computeScroll() { if(scroller.computeScrollOffset()){ scrollTo(scroller.getCurrX(), scroller.getCurrY()); postInvalidate(); } super.computeScroll(); }
下面我们看一下为什么能够实现滑动,看一下startScroll的原型:
public void startScroll(int startX, int startY, int dx, int dy, int duration) { mMode = SCROLL_MODE; mFinished = false; mDuration = duration; mStartTime = AnimationUtils.currentAnimationTimeMillis(); mStartX = startX; mStartY = startY; mFinalX = startX + dx; mFinalY = startY + dy; mDeltaX = dx; mDeltaY = dy; mDurationReciprocal = 1.0f / (float) mDuration; }
其实Scroller内部什么也没有做,只是保存了我们传递的几个参数。方法的参数含义如下:
- startX和startY表示的是滑动的起点
- dx和dy表示的是滑动的距离。
- duration表示的是滑动过程完成所需要的时间
Scroller实现滑动的方法是startScroll方法下面的invalidate方法,invalidate方法会导致view重绘,在View的draw方法中又会调用computeScroll方法,computeScroll方法在view是一个空实现,因此需要我们自己去实现,上面示例代码实现了computeScroll方法,view才能实现弹性滑动。当View重绘后会在draw方法中调用computeScroll,而computeScroll又会去向Scroller获取当前的scrollX和scrollY,然后通过scrollTo方法实现滑动;接着调用postInvalidate方法进行第二次重绘,如此反复,直到滑动过程结束。
我们再看一下computeScrollOffset方法的实现,如下所示
public boolean computeScrollOffset() { ... int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - 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(x * mDeltaY); break; ... } } return true; }
我们不去关心这个具体过程,这个方法主要根据时间的流逝来计算出当前的scrollX和scrollY,通过时间流逝的百分比计算出当前的值。返回true表示滑动还未结束,false则表示滑动已经结束。
通过动画
动画本身就是一种渐进的过程,通过它本身实现的滑动本身就具有弹性 效果,但是这里我们使用动画的特性来实现,采用如下方式实现:final int startX = 0; final int deltaX = 100; ValueAnimator animator = ValueAnimator.ofInt(0, 1).setDuration(1000); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float fraction = animator.getAnimatedFraction(); mButton.scrollTo(startX + (int)(deltaX * fraction), 0); } }); animator.start();
上面我们的动画本质没有作用于任何对象上,只是在1000ms内完成了整个动画过程,这里滑动的是View的内容而非本身,我们完全可以在
onAnimationUpdate
方法中加入我们想要的其它操作。使用延时策略
通过发送一系列延时消息从而达到一种渐进式的效果,可以用Handler或View的postDelayed方法,也可以使用线程的sleep方法,对于postDelayed方法来说,通过延时发送消息,然后在消息中进行View滑动,接连不断的发送这种延时消息,就可以实现弹性滑动的效果。弹性滑动还有其它方式实现,更多的是实现思想,实际中我们可以对其灵活扩展从而实现更多复杂的效果。
四 View的事件分发机制
点击事件的传递规则
点击事件的分发实际上就是对MotionEvent
事件的分发过程,系统需要把这个事件传递给一个具体的View,而这个传递的过程就是分发过程,点击事件的分发过程有三个重要的方法:public boolean dispatchTouchEvent(MotionEvent ev)
用来进行事件的分发。如果事件能够传递给当前View,那么此方法一定会被调用,返回结果受当前的View的onTouchevent和下级的dispatchTouchEvent方法的影响,表示是否消耗当前事件。public boolean onInterceptTouchEvent(MotionEvent ev)
用来判断是否拦截某个事件,如果当前View拦截了某个事件,那么在同一个事件的序列中,此方法不会被再次调用,返回值表示是否拦截当前事件。public boolean onTouchEvent(MotionEvent event)
在dispatchTouchEvent方法中调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前View无法再次接受到事件。
上述方法之间的关系可以用下面的伪代码来描述:
public boolean dispatchTouchEvent(MotionEvent ev) { boolean consume = false; if(onInterceptTouchEvent(ev)){ consume = onTouchEvent(ev); } else { consume = child.dispatchTouchEvent(ev); } return consume; }
通过上面的伪代码,我们可以大致的了解点击事件的传递规则:
对于一个根ViewGroup来说,点击事件产生后,首先会传递给它,首先调用dispatchTouchEvent
,如果它的onInterceptTouchEvent
返回true就是拦截当前事件,事件交给ViewGroup处理;如果返回false,表示不拦截当前事件,继续传递给它的子元素,接着子元素的dispatchTouchEvent
就会被调用,如此反复直到事件结束。当一个View需要处理事件时,如果设置了
OnTouchListener
,那么OnTouchListener
中的onTouch
方法会被回调。事件如何处理还要看onTouch
的返回值,如果返回false,则当前View的onTouchEvent
会被调用;如果返回true,那么onTouchEvent
将不会被调用。点击事件产生后,它的传递过程遵循如下顺序:
Activity -> Window-> View
事件总是先传递给Activity,Activity在传递给Window,最后Window再传递给顶级View,顶级View接收事件后,就按照事件分发机制去分发事件。如果一个View的onTouchevent返回false,那么它的父容器onTouchevent会被调用,一次类推,如果所有元素都不处理这个事件,最终将会传递到Activity去处理,即Activity的onTouchevent会被调用。
关于事件传递机制的常见一些结论:
- 正常情况下,一个事件序列只能被一个View拦截并且消耗。一个事件序列中的事件不能分别由两个View同时处理,但是一个View可以将本该自己处理的是将通过onTouchEvent强行传递给其它View处理。
- 一个View一旦决定拦截,那么一个事件序列都只能由它来处理,并且它的onInterceptTouchEvent不会再次调用。
- 某个View一旦开始处理事件,如果它不消耗ACTION_DOWN事件(onTouchevent返回了false),那么同一事件序列中的其它事件都不会再交给它来处理,并且事件将交由它的父元素去处理。
- 如果View不消耗除ACTION_DOWN意外的其他事件,那么这个点击事件会消失,此时父元素的onTouchevent并不会被调用,并且当前View可以持续收到后续的事件,最终这些消失的点击事件会传递给Activity处理。
- ViewGroup默认不拦截任何事件,View没有onInterceptTouchEvent方法。
- View的onTouchevent默认都会消耗事件,除非它是不可点击的(clickable和longClickable同时为false)。View的longClickable都为false,而clickable则分情况,一般Button默认为true,TextView默认为false。
- View的enable属性不影响onTouchEvent的默认返回值。
- onClick()会发生的前提是View是可以点击的,并且收到了down和up的事件。
- 事件传递过程由外向内的,即事件总是先传递给父元素,然后再由父元素分发给子View,通过
requestDisallowInterceptTouchEvent
方法可以再子元素中干预父元素的事件分发过程,但是ACTION_DOWN除外。
以上结论我们都可以在源码分析部分得到解释。
- 事件分发的源码分析
源码解析我会另写一篇,结合书中还有一些博客分析源码,到时候整理发出来。
五 View的滑动冲突
常见的滑动冲突场景
- 外部滑动方向和内部滑动方向不一致,例如Viewpager中的页面包含listview
- 外部滑动方向和内部滑动方向一致,例如Viewpager的某一个页面含有banner图。
- 上面两种情况的嵌套,例如外部有一个SlideMenu,内部有一个ViewPager,Viewpager的每一个页面又是一个listView。
滑动冲突的处理规则
滑动冲突的一般处理规则根据滑动路径和水平方向所形成的夹角、距离差、速度差等来判断,还有就是结合具体的业务需求来得出相应的处理规则。滑动冲突的解决方式
外部拦截法
点击事件都要经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要此事件就不拦截,需要重写父容器的onInterceptTouchEvent
方法,伪代码如下:public boolean onInterceptTouchEvent(MotionEvent ev) { boolean intercepted = false; int x = (int) ev.getX(); int y = (int) ev.getY(); switch (ev.getAction()){ case MotionEvent.ACTION_DOWN: intercepted = false; break; case MotionEvent.ACTION_MOVE: if(父容器需要当前点击事件){ intercepted = true; }else{ intercepted = false; } break; case MotionEvent.ACTION_UP: intercepted = false; break; } mLastXIntercept = x; mLastYIntercept = y; return intercepted; }
内部拦截法
内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要子事件就直接消耗掉,否则直接交由父容器进行处理。需要配合requestDisallowInterceptTouchEvent
方法。public boolean dispatchTouchEvent(MotionEvent event) { int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: getParent().requestDisallowInterceptTouchEvent(true); break; case MotionEvent.ACTION_MOVE: int deltaX = x - mLastX; int deltaY = y - mLastY; if (当前view需要拦截当前点击事件的条件) { getParent().requestDisallowInterceptTouchEvent(false); } break; case MotionEvent.ACTION_UP: break; } mLastX = x; mLastY = y; return super.dispatchTouchEvent(event); }
OK,第三章除了源码分析没写,基本上结束。