Android——View的事件体系

1. View的基础知识

View的基础知识主要有:View的位置参数、MotionEvent、TouchSlop对象、VelocityTracker、GestureDelector和Scroller对象等等。

1.1 什么是View

View是Android中所有控件的基类,View可以是单个控件,也可以是多个控件组装起来的一组控件。

1.2 View的位置参数

View的位置由它的四个顶点来决定,分别对应于View的四个属性:top、left、right、bottom,这些属性都是相对于父容器来说的,所以它们是相对坐标

从Android 3.0 开始,View新增了一些新的参数:x、y、translationX 和 translationY,其中 x 和 y 是View左上角的坐标,translationX 和 translationY 是 View 相对于父容器的偏移量,这几个参数也是相对于父容器的坐标。

注意:View在平移过程中,View的 top 和 left 表示View的原始左上角位置,其值不会发生改变,此时发生改变的是 x、y、translationX、translationY。

1.3 MotionEvent 和 TouchSlop

1.3.1 MotionEvent

手指接触屏幕产生的一系列事件,典型的事件如下几种:

  • ACTION_DOWN:手指刚接触到屏幕
  • ACTION_MOVE:手指在屏幕上移动
  • ACTION_UP:手指从屏幕上松开一瞬间

同时通过MotionEvent对象我们可以获取到点击事件发生的 x 和 y 坐标。系统提供了两组方法:

  1. getX/getY:返回相对于当前View的左上角的 x 和 y 坐标;
  2. getRawX/getRawY:返回的是相对于手机屏幕左上角的 x 和 y 坐标;
1.3.2 TouchSlop

TouchSlop 是系统所能识别出的被认为是滑动的最小距离,如果两次滑动之间的距离小于这个常量,那么系统就不认为是在进行滑动操作。这是一个常量,与设别有关,不同设备该常量可能不一样。

可以通过代码获取这个TouchSlop常量值:ViewConfiguration.get(context).getScaledTouchSlop()

应用场景:当需要处理滑动时,可以利用这个常量来做一些过滤,例如两次滑动的距离小于这个值,我们就可以认为未达到滑动的临界值,因此可以认为它们不是滑动。

1.4 VelocityTracker、GestureDelector 和 Scroller

1.4.1 VelocityTracker

速度追踪,用于追踪手指在滑动过程中的速度。包括 水平方向的速度 和 垂直方向的速度。

VelocityTracker vt = VelocityTracker.obtain();
vt.addMovement(event);

vt.computeCurrentVelocity(1000);
int xVelocity = (int) vt.getXVelocity();
int yVelocity = (int) vt.getYVelocity();

在获取速度之前:需先计算速度。然后获取到的速度是指一段时间内手指划过的像素数。

当使用完成时,我们需要调用 clear 方法,将其重置并回收内存。

vt.clear();
vt.recycle();
1.4.2 GestureDelector

手势检测,用于辅助检测用户的 单击、滑动、长按、双击 等行为。

在GestureDelector使用过程中,首先需要创建一个GestureDelector对象并实现 OnGestureListener 接口,根据需要我们实现内部对应的接口。接着我们需要接管目标View的 onTouchEvent 方法,在待监听的View的 onTouchEvent 方法中实现:

boolean consume = mGestureDelector.onTouchEvent(event);
returen consume;

在日常开发中,比较常用的有:onSingleTapUp(单击)、onFling(快速滑动)、onScroll(拖动)、onLongPress(长按)和 onDoubleTap(双击)。

建议:如果只是监听滑动相关的,建议自己在onTouchEvent中实现,如果要监听双击等这种行为的,那么使用GestureDelector。

1.4.3 Scroller

弹性滑动对象,用于实现View的弹性滑动。当使用View的 scrollTo 或者 scrollBy 进行滑动时,没有过渡动画效果,因此可以使用 Scroller 来实现有过渡效果的滑动。

Scroller 本身是无法进行滑动的,需要 View 的 computeScroll 方法配合使用才能共同完成。

实现原理:通过滑动的百分比,不断绘制View,从而不断调用 View 的 computeScroll 方法来达到滑动的效果。

Scroller mScroller = new Scroller(context);

private void smoothScrollTo(int destX, int destY) {
    int scrollX = getScrollX();
    int delta = destX - scrollX;
    mScroller.startScroll(scrollX, 0, delta, 0, 1000);
    // 重新绘制View
    invalidate();
}

@Override
public void computeScroll() {
    if(mScroll.computeScrollOffset()) {
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        postInvalidate();
    }
}

2. View的滑动

View的滑动可以通过三种方式来实现:

  1. 通过View本身的 scrollTo / scrollBy 来实现
  2. 通过动画给View施加平移的效果来实现
  3. 通过View的 LayoutParams 使得View重新布局从而实现滑动

2.1 使用 scrollTo/scrollBy

scrollBy 实际上也调用了 scrollTo,实现了基于当前位置的相对滑动,而 scrollTo 实现了基于传递参数的绝对滑动。

在滑动的过程中,View内部有两个属性 mScrollX 和 mScrollY,mScrollX 始终等于View的左边缘与View内容左边缘的水平方向的距离,mScrollY 总是等于View的上边缘与View的内容上边缘的竖直方向的距离

scrollTo 和 scrollBy 只能改变 View内容的位置 而不能改变 View的位置。

2.2 使用动画

主要操作的是View的 translationX 和 translationY 属性。可以采用传统的动画,也可以采用属性动画。

需要注意的一点是,View动画只是对View的影像做操作,并不能改变View的位置参数,包括宽高。若希望动画结束后可以保留当前的状态,则必须将fillAfter属性设置为true,否则动画结束后结果会消失。

注意:使用动画来实现View动画并不能改变View的位置。使用属性动画可以解决该问题。

2.3 改变布局参数

改变布局参数,即改变LayoutParams。

2.4 各种滑动方式的对比

  1. scrollTo/scrollBy:可以方便地实现滑动效果且不影响内部元素的单击事件,只能滑动View的内容,不能滑动View本身。
  2. 动画:不能改变View本身的属性,包括宽高。优点用来进行一些复杂的效果动画。
  3. 改变布局参数:会改变View的本身属性,适用于有交互的View。

3. 弹性滑动

思路:将一次大的滑动分成几次小的滑动并在一个时间段内完成。

实现弹性滑动的方式主流的做法如下:

  1. 使用Scroller
  2. 使用Handler#postDelayed
  3. 使用Thread#Sleep

3.1 Scroller

通过Scroller的滑动指View的内容滑动而不是View本身位置的参数。

Scroller本身是无法实现滑动效果的,需要结合View的 computeScroll 方法来配合完成。通过不断地让View重绘,而每一次重绘距滑动起始时间会有一个时间间隔。

View的每一次重绘都会导致View进行小幅度的滑动,而多个小幅度的滑动就组成了弹性滑动,这就是Scroller的工作原理。

3.2 通过动画

思想其实与Scroller类似,都是通过改变一个百分比配合scrollTo来完成View的重绘。

3.3 使用延时策略

通过 Handler#postDelayed 或者 Thread#sleep 来完成延时策略。

注意:采用Handler来完成延时时,所设定的时间是无法精准地定时,因为系统的消息调度也是需要时间的。

4. View的事件分发机制

通过View的事件分发机制,可以解决View的一大难题——View的滑动冲突。

4.1 点击事件的传递规则

点击事件的传递规则,要分析的对象就是 MotionEvent,即点击事件。

点击事件在分发的过程中,有三个很重要的方法:dispatchTouchEvent、onInterceptTouchEvent 和 onTouchEvent

  1. dispatchTouchEvent:用来进行事件分发。如果当前的事件能够传递给当前的View,那么此方法一定会被调用。表示是否消耗当前事件,返回结果受当前View的onTouchEvent下级的dispatchTouchEvent影响。
  2. onInterceptTouchEvent:表示是否拦截某个事件,用于ViewGroup中。如果当前View拦截某个事件,那么在同一个事件序列当中,此方法不会再次被调用,返回结果表示是否拦截。
  3. onTouchEvent:在dispatchTouchEvent中调用,用来处理点击事件,返回结果表示是否消耗当前的事件。如果不消耗,那么在同一个事件序列当中,当前View无法再次接收到事件。
public boolean dispatchTouchEvent(MotionEvent ev) {
    boolean consume = false;
    if(onInterceptTouchEvent(ev)) {
        consume = onTouchEvent(ev);
    } else {
        consume = child.dispatchTouchEvent(ev);
    }
    
    return consume;
}

事件传递优先级:OnTouchListener > onTouchEvent > OnClickListener

  • 当一个View需要处理事件时,如果它设置了OnTouchListener,那么onTouch方法会被回调,这时事件的处理还要看onTouch的返回值,如果返回false,那么View的onTouchEvent就会被调用;否则,View的onTouchEvent就不会被调用。
  • 在onTouchEvent方法中,如果设置了OnClickListener,那么它的onClick方法就会被调用。

当一个点击事件产生后,传递过程是:Activity -> Window -> View

4.2 事件传递结论

  1. 同一个事件序列是指从手指接触屏幕那一刻起,到手指离开屏幕那一刻结束。
  2. 正常情况下,一个事件序列只能被一个View拦截且消耗。因此同一个事件序列是不可以被两个View同时处理,当然也可通过特殊的手段做到,例如当一个View处理事件时,通过onTouchEvent强行传递给其他的View。
  3. 某个View一旦决定拦截,那么这个事件序列只能由它处理。并且onInterceptTouchEvent不会再被调用。
  4. 事件一旦交给一个View处理,那么它就必须消耗掉,否则同一事件序列中剩下的事件就不再交给它处理。
  5. View默认是消耗事件,即返回true。除非它是不可点击的(clickable和longClickable同时为false)。
  6. View的enable属性onTouchEvent的返回值。只要它的 clickable 或者 longClickable 有一个为true,那么它的onTouchEvent就返回true。
  7. 事件传递过程是从外向内,即事件先是传递给父元素,然后再由父元素传递给子元素。在子View中,可以通过 requestDisallowInterceptTouchEvent 方法干预父元素的事件分发过程,但是 ACTION_DOW 除外。

4.3 事件分发源码解析

4.3.1 Activity对点击事件的分发过程

当一个点击事件发生时,最先传递给Activity,由Activity的dispatchTouchEvent来进行事件分发,具体工作由Activity内部的Window来完成。

在此过程中,Window的实现类即是 PhoneWindow。Window可以控制顶级View的显示和行为策略。接着PhoneWindow会将当前的事件传递给 DecorView。

我们可以通过 ((ViewGroup)getWindow().getDecorView().findViewById(android.R.id.content)).getChildAt(0) 来获取Activity所设置的View。DecorView即是视图中顶层的View。

4.3.2 顶级View的点击事件分发过程

ViewGroup在如下两种情况下会判断是否拦截当前事件:

  1. 事件类型为ACTION_DOWN,点击事件是DOWN时。
  2. mFirstTarget != null,即当事件由ViewGroup的子元素成功处理时,mFirstTarget 会被赋值并指向子元素,换句话说,就是当ViewGroup不拦截事件并将事件交由子元素处理时 mFirstTarget != null。

FLAG_DISALLOW_INTERCEPT,该标志位是通过 requestDisallowInterceptTouchEvent 来设置的,一般用于子View中。一旦该标志位被设置,那么ViewGroup无法拦截除了ACTION_DOWN以外的点击事件。为什么是ACTION_DOWN以外的事件?因为ACTION_DOWN会重置FLAG_DISALLOW_INTERCEPT,将导致子View中设置这个标志位无效。

注意:

  1. 当ViewGroup决定拦截事件后,那么后续的点击事件会默认交给它处理并且不再调用它的onInterceptTouchEvent方法。因此onInterceptTouchEvent不是每次都调用的,如果要处理所有事件,选中在dispatchTouchEvent中处理
  2. FLAG_DISALLOW_INTERCEPT 该标志位可以用于处理滑动冲突,用于内部拦截法。

当ViewGroup不再拦截事件时,事件会向下分发交由子View处理。首先遍历ViewGroup的所有子元素,然后判断子元素是否可以接收到点击事件。

子元素能否接收到点击事件由两点来衡量:

  1. 子元素是否在播动画
  2. 点击事件的坐标是否落入到子元素的区域内

mFirstTouchTarget 真正赋值是在 addTouchTarget 中完成的,mFirstTouchTarget 其实是一种单链表的结构,mFirstTouchTarget是否被赋值将会直接影响到ViewGroup对事件的拦截策略,如果mFirstTouchTarget为null,则ViewGroup将会默认拦截接下来的同一序列中所有的点击事件。

4.3.3 View对点击事件的处理过程

View对点击事件的处理就比较简单了,因为View是一个单独的元素,不会有子元素从而无法向下传递事件,所以只能由他自个处理。

View对点击事件的处理过程:

  1. 首先会判断是否设置了OnTouchListener,如果OnTouchListener中的onTouch返回true,那么onTouchEvent就不会被调用。
  2. 接着判断只要View的 CLICKABLE 和 LONG_CLICKABLE 中有一个为true,那么它就会消耗这个事件,onTouchEvent就会返回true。不管是不是VISIBLE。
  3. 如果当ACTION_UP事件发生时,就会触发performClick方法,如果View设置了OnClickListener,那么performClick方法就会调用onClick。

5. View的滑动冲突

5.1 常见的滑动冲突场景

  1. 外部滑动方向与内部滑动方向不一致;
  2. 外部滑动方向与内部滑动方向一致;
  3. 1与2 两种情况结合;

5.2 滑动冲突处理规则

  1. 根据滑动方向是水平滑动还是竖直滑动来判断交由谁拦截处理;
  2. 根据滑动方向与水平方向所形成的夹角进行判断;
  3. 水平方向与竖直方向 距离差速度差
  4. 根据业务需求来处理判断;

5.3 滑动冲突的解决方案

  1. 外部拦截法:
  • 由ViewGroup的 onInterceptTouchEvent 来进行拦截处理。
  • 对 ACTION_DOWN 这个事件不做拦截,因为一旦拦截此事件,那么该序列的剩下事件都将会交由父容器处理
  • 对 ACTION_UP 这个事件也不做拦截,因为若拦截此事件,将可能导致子View无法触发onClick事件。因为 ACTION_UP 会触发 performClick 方法,从而可能会调用 onClick 方法。
  • 所以一般情况下,对 ACTION_DOWN 与 ACTION_UP 事件都返回false,ACTION_MOVE 视情况及需求而定是否拦截。
  1. 内部拦截法:
  • 指父容器对任何事件不做拦截,所有事件传递给子View,但是需要配合子View的 requestDisallowInterceptTouchEvent 方法来工作。
  • 除了子元素需要做处理外,父元素也要默认拦截除了 ACTION_DOWN 以外的其他事件。为什么不拦截 ACTION_DOWN 事件,因为 FLAG_DISALLOW_INTERCEPT 不会影响 ACTION_DOWN 事件,因为收到 ACTION_DOWN 事件时,标志位将会被重置,所以一旦父容器拦截了该事件,那么所有事件将无法传递到子元素里去。

你可能感兴趣的:(Android,android)