View的事件体系
1.1 View简述
- View是Android中所有控件的基类。View是一种界面层的控件的一种抽象,代表一个控件。
- ViewGroup:控件组。ViewGroup内部包含多个控件(View)。ViewGroup也继承View,意味着View本身就可以是单个控件也可以是多个控件组成的一组控件。
1.1.1 View的位置参数
View的位置由四个顶点确定,对应View的四个属性:top(左上纵坐标)、left(左上横坐标)、right(右下横坐标)、bottom(右下纵坐标)。
View的宽高和坐标关系:
width = right - left
height = bottom - top
位置参数的获取方法:
Left=getLeft()
Right=getRight()
Top=getTop()
Bottom=getBottom()
View的其他参数:
x:View的左上角横坐标
y:View的左上角纵坐标
translationX:View的左上角横坐标相对于父容器的偏移量。(默认0)
translationY:View的左上角纵坐标相对于父容器的偏移量。(默认0)
x=left+translationX
y=top+translationY
注意:top和left表示的是原始左上角的位置信息,不会改变;x、y、translationX、translationY这四个参数会发生改变。
1.1.2 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()
方法获取。
1.1.3 VelocityTracker、GestureDetector、Scroller
VelocityTracker:
速度追踪。
/** * 在View的onTouchEvent方法中追踪当前点击事件的速度 */ VelocityTracker velocityTracker = VelocityTracker.obtain(); velocityTracker.addMovement(event); /** * 获取当前速度 * 要在获取速度前调用computeCurrentVelocity()计算速度。单位ms。 * 速度是指一段时间内手指所划过的像素数。带方向,可以为负值。 * 速度 = (终点位置 - 起点位置)/时间段 */ velocityTracker.computeCurrentVelocity(1000); int xVelocity=(int)velocityTracker.getXvelocity(); int yVelocity=(int)velocityTracker.getYvelocity(); /** * 不需要时,需要重置并回收内存 */ velocityTracker.clear() velocityTracker.recycle()
GestureDetector:
手势检测。
/** * 创建GestureDetector对象,并实现OnGestureListener接口 */ GestureDetector mGestureDetector = new GestureDetector(this); //解决长按屏幕后无法无法拖动现象 mGestureDetector.setIsLongpressEnabled(false); /** * 在View的onTouchEvent方法中实现 */ boolean consume = mGestureDetector.onTouchEvent(event); return consume;
完成上述步骤后,可选择性地实现OnGestureListener和OnDoubleTapListener中的方法:
方法名 描述 所属接口 onDown 手指轻轻触摸屏幕的一瞬间,由1个ACTION_DOWN触发 OnGestureListener onShowPress 手指轻轻触摸屏幕,尚未松开或拖动,由1个ACTION_DOWN触发。和onDown()的区别:它强调的是没有松开或者拖动的状态。 OnGestureListener onSingleTapUp 手指(轻触摸屏幕后)松开,伴随着1个 ACTION_UP触发,是单击行为。 OnGestureListener onScroll 手指按下屏幕并拖动,由1个ACTION_DOWN,多个ACTION_MOVE触发,是拖动行为。 OnGestureListener onLongPress 用户长久地按着屏幕不放,即长按。 OnGestureListener onFling 用户按下触摸屏、快速滑动后松开,由1个ACTION_DOWN、多个ACTION_MOVE和1个ACTION_UP触发,是快速滑动行为。 OnGestureListener onDoubleTap 双击,由2次连续的单击组成。不可能和onSingleTapConirned共存。 OnDoubleTapListener onSingleTapConfirmed 严格的单击行为。和onSingleTapUp的区别:如果触发了onSingleTapConfirmed,那么后面不可能再紧跟着另一个单击行为,即这只可能是单击,而不可能是双击中的一次单击。 OnDoubleTapListener onDoubleTapEvent 双击行为,在双击的期间,ACTION DOWN、ACTION_ MOVE和ACTION_ UP都会触发此回调。 OnDoubleTapListener 常用:onSingleTapUp、onFling、onScroll、onLongPress、onDoubleTap。
注意:实际开发中,可以不使用GestureDetector,可以完全在View的ontouchEvent方法中实现所需监听。
Scroller:
弹性滑动。
View的scollTo/scrollBy方法进行滑动时,过程是瞬间完成的,没有过渡效果。Scroller可以实现有过渡效果的滑动,Scroller本身无法让View弹性滑动,需要和View的computeScroll方法配合实现。
典型代码:
Scroller mScroller = new Scroller(mContext); //缓慢滚动到指定位置 private void smoothScrollTo(int destX,int destY){ int scrollX = getScrollX(); int delta = destX - scrollX; // 1000ms 内滑向 destX,效果就是慢慢滑动 mScroller.startScroll(scrollX,0,delta,1000); invalidate(); } @Override public void computeScroll(){ if(mScroller.computeScrollOffset()){ scrollTo(mScroller.getCurrX(),mScroller.getCurrY()); postInvalidate(); } }
1.2 View的滑动
1.2.1 使用scrollTo/scrollBy
- scrollTo实现了基于所传递参数的绝对滑动;scrollBy实现了基于当前位置的相对滑动。scrollBy实质上调用的也是scrollTo方法。
- View内部存在两个属性mScrollX和mScrollY(单位是像素);这两个属性可以通过getScrollX和getScrollY获得。mScrollX的值等于View左边缘和View内容左边缘在水平方向上的距离;mScrollY的值等于View上边缘和View内容上边缘在垂直方向上的距离。
- scrollTo和scrollBy只能改变View内容的位置而不能改变View在布局中的位置。
- 从左向右滑动mScrollX为负值,反之为正值;从上往下滑动mScrollY为负值,反之为正值。
1.2.2 使用动画
通过动画让一个View进行平移(平移是一种滑动),主要通过操作View的translationX和translationY属性实现。可以采用View动画或者属性动画。
View动画:(100ms内将一个View从原始位置向右下角移动100个像素)
<?xml version="1.0" encoding="utf-8"?> <set xmlns:android="http://schemas.android.com/apk/res/android" android:fillAfter="true" android:zAdjustment="normal"> <translate android:duration="100" android:fromXDelta="0" android:fromYDelta="0" android:interpolator="@android:anim/linear_interpolator" android:toXDelta="100" android:toYDelta="100"/> </set>
View动画是对View的影像做操作,不能真正的改变View的位置参数;如果希望动画后的状态得以保留需要将fillAfter属性设为true,否则动画完成后回到初始状态。
属性动画:(100ms内将一个View从原始位置向右移动100个像素)
ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
注意:在很久的版本上(现在的版本不存在这个问题),因为动画移动的是影像,而真身还在原先位置,从而导致,经过动画移动后的控件的触控事件在新位置上无法触发,在原先位置上可以触发。
1.2.3 改变布局参数
改变布局参数,即改变LayoutParams。
ViewGroup.MarginLayoutParams params =(ViewGroup.MarginLayoutParams)mButton.getLayoutParams(); params.width +=100; params.leftMargin +=100; mButton.requestLayout(); //或者mButton.setLayoutParams(params);
1.2.4 滑动方式的对比
- scrollTo/scrollBy:操作简单,适合对View内容的滑动。
- 动画:操作简单,适合用于没有交互的View和实现复杂的动画效果。
- 改变布局参数:操作稍微复杂,适用于有交互的View。
1.3 弹性滑动
1.3.1 使用Scroller
典型代码:
Scroller mScroller = new Scroller(mContext); //缓慢滚动到指定位置 private void smoothScrollTo(int destX,int destY){ int scrollX = getScrollX(); int delta = destX - scrollX; // 1000ms 内滑向 destX,效果就是慢慢滑动 mScroller.startScroll(scrollX,0,delta,1000); invalidate(); } @Override public void computeScroll(){ if(mScroller.computeScrollOffset()){ scrollTo(mScroller.getCurrX(),mScroller.getCurrY()); postInvalidate(); } }
Scroller实现让View弹性滑动原理:
- startScroll方法:仅是保存传递的参数。
- invalidate方法:该方法会导致View重绘,在View的draw方法中会调用computeScroll方法,computeScroll在View中是一个空实现,需要我们实现。
- computeScroll方法:示例代码实现该方法,完成弹性滑动。当View重绘后会在draw方法中调用computeScroll,computeScroll会向Scroller获取当前的ScrollX和ScrollY,然后通过scrollTo方法滑动到当前位置,之后postInvalidate进行二次重绘。与第一次重绘相似,还是会调用computeScroll方法,然后获取当前的ScrollX和ScrollY,再通过scrollTo方法滑动到当前位置,如此反复,直至滑动过程结束。
- computeScrollOffset方法:该方法通过时间的流逝计算出当前的ScrollX和ScrollY的值,类似于差值器的概念。该方法的返回为true表示滑动还未结束,false表示滑动结束。
Scroller的工作原理:
Scroller 本身并不能实现View的滑动,需要配合View的computeScroll方法才能完成弹性滑动的效果。通过不断地重绘View,每一次重绘距离滑动起始时间会有一个时间间隔,通过这个时间间隔Scroller计算出View当前的滑动位置,知道位置后就可以通过scrollTo方法完成View的滑动。View的每一次重绘都会导致View进行小幅度的滑动,多次的小幅度滑动组成了弹性滑动。
1.3.2 使用动画
动画本身就是一种渐近过程。
//100ms内将一个View从原始位置向右移动100个像素 ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
模仿Scroller实现弹性滑动:
final int startX = 0; final int deltaX = 100; final 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); } });
1.3.3 使用延时策略
核心思想:
通过发送一系列延时消息从而达到一种渐进式的效果。可以使用Handler或View的postDelayed方法,也可以使用线程的sleep方法。对于postDelayed方法,可以通过其延时发送一个消息,在消息中进行View滑动,连续地发送这种延时消息,就可以实现弹性滑动效果。对于sleep方法,通过在while循环中不断地滑动View和sleep,也可以实现弹性滑动效果。
示例代码:
private static final int MESSAGE_SCROLL_TO = 1; private static final int FRAME_COUNT = 30; private static final int DELAYED_TIME = 30; private int mCount = 0; @SuppressLint("HandlerLeak") private Handler mHandler = new Handler() { @Override 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); mButton.scrollTo(scrollX,0); mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO,DELAYED_TIME); } break; } default: break; } } };
1.4 View的事件分发机制
1.4.1 点击事件的传递规则
点击事件,即MotionEvent。点击事件的事件分发,就是对MotionEvent事件的分发过程,即当MotionEvent产生后,系统需要把这个事件传递给一个具体的View,这个过程就是分发过程。
点击事件的分发过程中三个重要的方法:
- public boolean dispatchTouchEvent(MotionEvent event):进行事件分发。如果事件能够传递给当前View,此方法一定会被调用,返回结果受当前View的onTouchEvent和下级View的dispatchTouchEvent方法影响,表示是否消耗当前事件。
- public boolean onInterceptTouchEvent(MotionEvent ev):判断是否拦截某个事件。如果当前View拦截了某个事件,那么在同一个事件蓄力中,此方法不会再被调用,返回结果表示是否拦截当前事件。
- public boolean onTouchEvent(MotionEvent event):处理点击事件。返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前View不再接收事件。
dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent关系:
@Override public boolean dispatchTouchEvent(MotionEvent event) { boolean consume = false; if (onInterceptTouchEvent(event)){ consume = onTouchEvent(event); }else { consume = getFocusedChild().dispatchTouchEvent(event); } return consume; }
- 对于一个根ViewGroup而言,点击事件产生后,首先会传递给它,这时它的dispatchTouchEvent方法被调用;如果这个ViewGroup的onInterceptTouchEvent方法返回true,表示它要拦截当前事件,接着事件交付给ViewGroup处理(即调用它的onTouchEvent方法),如果这个ViewGroup的onInterceptTouchEvent方法返回false,表示它不拦截当前事件,事件会继续传递给它的子元素,子元素的dispatchTouchEvent方法被调用,如此反复直到事件被最终处理。
- 当一个View需要处理事件时,如果设置了OnTouchListener,那么OnTouchListener中的onTouch方法会被回调。如果onTouch方法返回false,则当前View的onTouchEvent方法会被调用;如果返回true,则当前View的onTouchEvent方法不会被调用。View的OnTouchListener优先级比onTouchEvent高。在onTouchEvent中,如果当前设置的有OnClickListener,那么其onClick方法会被调用,可见OnClickListener优先级最低,位于事件传递的尾端。
- 当一个点击事件产生后,其传递顺序:Activity->Window->View(顶级View),顶级View再按照事件分发机制去分发事件。
- 如果一个View的onTouchEvent返回false,那么它的父容器的onTouchEvent将会被调用,以此类推,如果所有元素都不处理这个事件,那么该事件最终会传递给Activity处理,即Activity的onTouchEvent方法会被调用。
事件传递机制结论:
- 1**.事件序列**是指从手指接触屏幕的那一刻起,到手指离开屏幕的那一刻结束,在这个过程中所产生的一系列事件,该事件序列以down事件开始,中间包含数量不定的move事件,最终以up事件结束。
- 2.正常情况下,一个事件序列只能被一个View拦截并消耗。一旦一个元素拦截了某次事件,那么同一个事件序列的所有事件都会直接交给它处理。
- 3.某个View一旦决定拦截,那么这一个事件序列都只能由它处理,并且它的onInterceptTouchEvent不会再被调用。
- 4.某个View一旦开始处理事件,如果它不消耗ACTION_DOWN事件(onTouchEvent返回false),那么同一事件序列中的其他事件都不会再交给它来处理,并且事件将重新交给它的父元素去处理,即父元素的onTouchEvent会被调用。换个说法:事件一旦决定交给一个View处理,它就必须消耗掉,否则,同一事件序列中的其它事件就不交给它处理了。
- 5.如果View不消耗ACTION_DOWN以外的其他事件,那么这个点击事件会消失,父元素的onTouchEvent也不会被调用,当前View可以持续接受到后续事件,最终消失的点击事件会传递给Activity处理。
- 6.ViewGroup默认不拦截任何事件。
- 7.View没有onInterceptTouchEvent方法,一旦有点击事件传递给它,它的onTouchEvent方法会被调用。
- 8.View的onTouchEvent默认都会消耗事件(返回true),除非它是不可点击的(clickable和longClickable同时为false)。View的longClickable属性默认都为false,clickable属性分情况,Button的clickable属性默认为true;TextView的clickable属性默认为false。
- 9.View的enable属性不影响onTouchEvent的默认返回值。即使一个View是disable状态的,只要它的clickable或者longClickable有一个为true,那么它的onTouchEvent就返回true。
- 10.onClick会发生的前提是当前的View是可点击的,并且它收到了down和up的事件。
- 11.事件传递过程是有外向内的,即事件总是先传递给父元素,然后再由父元素分发给子View,通过requestDisallowInterceptTouchEvent方法可以在子元素中干预父元素的事件分发过程,但是ACTION_DOWN事件除外。
1.4.2 事件分发源码解析
- 见博客:Android事件分发源码解析
1.5 View的滑动冲突
- 界面中存在内外两层可以同时滑动的情况,就会产生滑动冲突。
1.5.1 常见的滑动冲突场景
场景一:外部滑动方向和内部滑动方向不一致。
- 主要是将ViewPager和Fragment配合使用所组成的页面滑动效果,可以通过左右滑动来切换界面,每个界面往往是一个ListView。ViewPager内部处理了这种滑动冲突,使用ViewPager时无需关心。如果使用的是ScrollView而非ViewPager,就需要手动处理滑动冲突。
场景二:外部滑动方向和内部滑动方向一致。
- 系统无法判断用户是希望那一层滑动,还是都滑动。
场景三:场景一、场景二嵌套。
1.5.2 滑动冲突的处理规则
- 场景一:用户左右滑动时,需要让外部的View拦截点击事件;上下滑动时,需要让内部的View拦截点击事件。我们可以根据特征来解决滑动冲突:1.可以根据滑动路径和水平方向所形成的夹角;2.可以根据水平方向和垂直方向上的距离;3.可以根据水平和竖直方向上的速度。
- 场景二:根据业务上的规定制定相应的处理规则。
- 场景三:结合场景一、场景二。
1.5.3 滑动冲突的解决方式
外部拦截法:
点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要就不拦截。外部拦截法需要重写父容器的onInterceptTouchEvent方法,在其中做相应的拦截即可:
//伪代码 @Override 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; } default: break; } mLastXIntercept = x; mLastYIntercept = y; return intercepted; }
针对不同滑动冲突,只需修改父容器需要当前点击事件的这个条件,其他不需也不能修改。对于ACTION_DOWN事件,父容器必须返回false(即不拦截),如果父容器拦截了ACTION_DOWN事件,后续的ACTION_MOVE和ACTION_UP事件都会交由父容器处理,无法传递给子元素。对于ACTION_MOVE事件,根据需要决定是否拦截,如果需要就返回true,不需要就返回false。对于ACTION_UP事件,需要返回false,该事件本身不存在太多意义。
补充:假设事件交由子元素处理,如果父容器在ACTION_UP时返回true,就会导致子元素无法接收到ACTION_UP事件,这时,子元素onClick事件无法触发。由于父容器的特殊性,一旦开始拦截任何一个事件,后续事件都会交由其处理,即使onInterceptTouchEvent方法在ACTION_UP时返回false,ACTION_UP事件也会作为最后一个事件传递给父容器。
内部拦截法:
父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交由父容器进行处理。
这种方法和Android中的事件分发机制不一致, 需要配合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 (父容器需要此点击事件) { getParent().requestDisallowInterceptTouchEvent(false); } break; } case MotionEvent.ACTION_UP: { break; } default: break; } mLastX = x; mLastY = y; return super.dispatchTouchEvent(event); }
针对不同滑动冲突,只需修改父容器需要当前点击事件的这个条件,其他不需也不能修改。子元素需要做处理以外,父元素也要默认拦截除了ACTION_DOWN以外的其他事件,这样当子元素调用parent.requestDisallowInterceptTouchEvenf(false)方法时,父元素才能继续拦截所需的事件。
父容器不能拦截ACTION_DOWN事件是因为ACTION_DOWN事件不受FLAG_DISALLOW_INTERCEPT标记位控制;一旦父容器拦截ACTION_DOWN事件,那么所有的事件都无法传递到子元素中去,内部拦截就无法起作用了。父元素如下修改:
public boolean onInterceptTouchEvent(MotionEvent ev) { int action = ev.getAction(); if (action==MotionEvent.ACTION_DOWN){ return false; }else { return true; } }
1.6 参考资料
- Android开发艺术探索