Android View 事件体系

一、 View 基础知识
1.1 View 介绍

View 是 Android 中所有控件的基类,也就是说 View 是界面层控件的抽象,除了 View,还有 ViewGroup,即 控件组,ViewGroup 同样继承 View,因此 View 既可以单个控件,也可以是一组控件,如 Button 就是一个View,而 LinearyLayout 是一个 View,而且是一个 ViewGroup。

1.2 View 位置参数

View 的位置主要由四个顶点来决定,分别对应 View 的四个属性:top、left、right、bottom。top 为 左上角纵坐标,left 为左上角横坐标,right 为右下角横坐标,bottom 为右下角纵坐标。
View 位置坐标和父容器的关系:


View 位置坐标.png

因此,View 的宽高和坐标的关系:

widht = right - left;
height = bootom - top;

获取 View 的这四个参数:

left = getLeft();
right = getRight();
top = getTop();
bootm = getBottom();

除了这几个参数,还有额外的几个参数:x,y,translationX,translationY。其中,x 和 y 是 View 的左上角坐标,translationX 和 translationY 为 View 左上角坐标相对于父容器的偏移量。
这几个参数的换算关系:

x = left +translationX;
y = top + translationY;

需要注意的是,当 View 平移时,top 和 left 表示原始左上角的位置信息,其值不变,变化的是 x,y 和 translationX,translationY。

1.3 MotionEvent 和 TouchSlp

主要的事件有:

  • ACTION_DOWN : 手指按下触摸屏幕
  • ACTION_MOVE: 手指滑动
  • ACTION_UP: 手指抬起
    主要的情况:
  • 点击一下屏幕: Down —> Up;
  • 在屏幕上滑动:Down —>Move(一系列) —>Up。
    同时,通过 MotionEvent 对象,可以得到点击事件的坐标。
  • getX/getY: 相对于当前 View 左上角的 x 和 y 坐标值。
  • getRawX/getRawY: 相对于手机屏幕左上角的 x 和 y 坐标值。

TouchSlop
TouchSlop 是系统能识别出的被认为是滑动的最小距离,这是一个常量,和设备有关,也就是在不同的设备上,这个值可能是不同的,可以通过如下方式获取这个值:

  ViewConfiguration.get(getContext()).getScaledTouchSlop();
1.4 VelocityTracker、GestureDetector 和 Scroller

VelocityTracker,速度追踪,用于追踪手指在滑动过程中的速度,包括水平和竖直方向的速度。使用如下:

  // 在 View 的 ONToucheEvent 中
  VelocityTracker velocityTracker = VelocityTracker.obtain();
  velocityTracker.addMovement(event);

  // 接着,可以通过如下方式获取速度
  // 首先要计算速度,需要传入时间间隔,这里为 1000 ms
  velocityTracker.computeCurrentVelocity(1000);
  // 获取水平和竖直方向上,在1000ms 滑动的像素数
  int xVelocity = (int)velocityTracker.getXVelocity();
  int yVelocity = (int)veloctiyTracker.getYVelocity();
  // 当不使用时,要clear 重置并回收内存。
  velocityTracker.clear();
  velocityTracker.recycle();


GestureDetector 手势检测,用于辅助检测用户的单击,滑动,长按和双击等行为。使用如下:

   // 首先要创建 GestureDetector 对象并实现 OnGestureListenner 接口,根据需要我们还可以实现 OnDoubleTapListenner 从而能够监听 双击行为。
GestureDetector mGestureDetector = new GestureDetector(this);
//解决长按屏幕无法拖动的现象
mGestureDetector.setIsLongpressEnabled(false);

//接着,接管目标 View 的 onTouchEvent 方法,在待监听 view 的 onTouch 方法中,添加如下实现
boolean consume = mGestureDetector.onTouchEvent(event);
return consume;

通过上面两步就可以有选择的实现 OnGestureListener 和 OnDoubleTapListner。
OnTestureListener 中的方法:

  • onDown :手指按下的一瞬间。
  • onShowPress :手指已经按下,但是没有拖动和松开。
  • onSingleTapUp: 手指单击后松开。
  • onScroll : 手指按下屏幕并拖动。
  • onLongPress: 长按。
  • onFing: 快速滑动并松开。

onDoubleTapListneer 中的方法:

  • onDoubleTap : 双击。
  • onSingleTapConfirmed: 严格的单击行为,不可能是双击中的一个单击。
  • onDoubleTapEnent: 双击。

一般的经验:
如果只是监听滑动,则在 onTouchEvent 中实现,如果监听双击行为,则可以使用 GestureDetector。

Scroller,弹性滑动对象,用于实现 View 的弹性滑动,使用 View 的 scrollTo/scrollBy 方法实现滑动时,是瞬间完成的,没有动画效果,通过 Scroller 可以实现动画效果,其过程更不是瞬间完成的。Scroller 本身无法让 View 弹性滑动,需要和 View 的 computeScroll 配合使用。其使用如下:

   Scroller scroller = new Scroller(mContext);
    //缓慢滚动到指定位置
    private void smoothScrollTo(int destX,int destY){
        int scrollX = getScrollX();
        int delta = destX - scrollX;
        //1000ms 内滑动到 destX ,效果就是慢慢滑动
        scroller.startScroll(scrollX,0,delta,0,1000);
        invalidate();
    }

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

二、 View 滑动

常见的实现 View 滑动的方式:

  • 通过 View 本身的 ScrollTo/ScrollBy。
  • 通过动画给 View 施加平移效果。
  • 通过改变 LayoutParams 使得 View 重新布局实现滑动。
2.1 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 实现了基于当前位置的相对滑动,而 scrollTo 则实现了基于传递参数的绝对滑动。View 内部有两个属性,mScrollX/mScrollY,可以通过 getScrollX/getScrollY 获取,在滑动的过程中,mScrollX 总等于 View 左边缘和 View 内容左边缘在水平方向的距离,mScrollY 同理为竖直方向上的距离。scrollTo 和 scrollBy 只能改变 View 的内容的位置,而不能改变 View 在布局中的位置。当 View 左边缘在 View 内容左边缘右边时,mScrollX 为正值,也就是,当从左向右滑动是, mScrollX 为 负值。
使用示例:

               mTextView.scrollTo(-10, -10);
2.2 使用动画

使用动画来移动 View,主要是操作 View 的 translationX 和 translationY 属性,既可以采用传统的 View 动画 ,也可以采用属性动画,如果采用属性动画,为了兼容 3.0 一下的版本,需要采用开源动画库 nineoldandroids。
通过补间动画实现动画:anim.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:fillAfter="true">

    <translate
        android:duration="100"
        android:fromXDelta="0"
        android:fromYDelta="0"
        android:interpolator="@android:anim/linear_interpolator"
        android:toXDelta="100"
        android:toYDelta="100"/>

</set>

然后在onCreat方法中调用startAnimation方法即可。使用补间动画实现View的滑动有一个缺陷,那就是移动的知识View的“影像”,这意味着其实View并未真正的移动,只是我们看起来它移动了而已。拿Button来举例,假若我们通过补间动画移动了一个Button,我们会发现,在Button的原来位置点击屏幕会出发点击事件,而在移动后的Button上点击不会触发点击事件。
属性动画实现动画:

ObjectAnimator.ofFloat(targetView, "translationX", 0, 100).setDuration(100).start();
2.3 改变布局参数

第三种实现 View 的滑动的方法,那就是改变布局参数,即改变 LayoutParams。比如,我们要把一个 Button 平移 100px,我们只需要把这个 Button 的 LayoutParams 里的 marginLeft 参数增加 100px 即可。还有一种方法就是在 Button 的左边放置一个空的 View,默认宽度为0,当需要右移 Button 时,只需要从新设置空 View 的宽度即可。
三种方式的对比:

  • scrollTo/scrollBy
    View 提供的原生方法,专门用来处理 View 的滑动,但是只能滑动 View 的内容,并不能滑动 View 本身。

  • 动画
    可以通过属性动画来实现 View 的滑动,同时能实现复杂的效果。主要适用于没有交互的 View 和实现复杂效果。

  • 改变布局
    操作稍微复杂,适用于有交互的 View 。

三、 弹性滑动

弹性滑动,可以改善用户体验,基本的思想:将一次较大的滑动分成若干次小的滑动,并在一小段时间内完成。实现方式基本有三种,如: Scroller、Handler#postDelayed 以及 Thread#sleep 。

3.1 使用 Scroller
  Scroller scroller = new Scroller(mContext);
    //缓慢滚动到指定位置
    private void smoothScrollTo(int destX,int destY){
        int scrollX = getScrollX();
        int delta = destX - scrollX;
        //1000ms 内滑动到 destX ,效果就是慢慢滑动
        scroller.startScroll(scrollX,0,delta,0,1000);
        invalidate();
    }

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

上面是 Scroller 的典型用法,它的原理是:当我们构造一个 Scroller 对象并且调用它的 startScroll 方法,Scroller 内部其实其实什么都没做,只是保存了我们传递的参数,其源码如下:

 public void startScroll(int startX, int startY, int dx, int dy) {
        startScroll(startX, startY, dx, dy, DEFAULT_DURATION);
    }

    /**
     * Start scrolling by providing a starting point, the distance to travel,
     * and the duration of the scroll.
     * 
     * @param startX Starting horizontal scroll offset in pixels. Positive
     *        numbers will scroll the content to the left.
     * @param startY Starting vertical scroll offset in pixels. Positive numbers
     *        will scroll the content up.
     * @param dx Horizontal distance to travel. Positive numbers will scroll the
     *        content to the left.
     * @param dy Vertical distance to travel. Positive numbers will scroll the
     *        content up.
     * @param duration Duration of the scroll in milliseconds.
     */
    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;
    }

其 startX 和 startY 表示的是滑动的起点,dx 和 dy 表示的是滑动的距离,而 duration 表示的是滑动的时间,需要注意的是,这里的滑动,滑动的是 View 内的内容,而不是 View 本身的改变。invalidate() 可以真正实现 弹性滑动,invalidate() 可方法可能导致 View 重绘,在 View 的 draw 方法中又会调用 computeScroll 方法,computeScroll 方法是 View 中是一个空实现,也就是我们自己在代码中的实现。当 View 重绘会在 draw 方法中调用 computeScroll,而 computeScroll 方法,接着又调用 postInvalidate 方法进行第二次重绘,这一次,重绘的过程和第一次重绘,还会导致 computeScroll 方法被调用,然后继续向 Scroller 获取当前的 scrollX 和 scrollY,并通过scrollTo 方法滑动到新的位置,如此反复,知道整个滑动过程结束。

computeScrollOffset 方法:

    /**
     * Call this when you want to know the new location.  If it returns true,
     * the animation is not yet finished.
     */ 
    public boolean computeScrollOffset() {
        if (mFinished) {
            return false;
        }

        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;
            case FLING_MODE:
                final float t = (float) timePassed / mDuration;
                final int index = (int) (NB_SAMPLES * t);
                float distanceCoef = 1.f;
                float velocityCoef = 0.f;
                if (index < NB_SAMPLES) {
                    final float t_inf = (float) index / NB_SAMPLES;
                    final float t_sup = (float) (index + 1) / NB_SAMPLES;
                    final float d_inf = SPLINE_POSITION[index];
                    final float d_sup = SPLINE_POSITION[index + 1];
                    velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);
                    distanceCoef = d_inf + (t - t_inf) * velocityCoef;
                }

                mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;
                
                mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
                // Pin to mMinX <= mCurrX <= mMaxX
                mCurrX = Math.min(mCurrX, mMaxX);
                mCurrX = Math.max(mCurrX, mMinX);
                
                mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));
                // Pin to mMinY <= mCurrY <= mMaxY
                mCurrY = Math.min(mCurrY, mMaxY);
                mCurrY = Math.max(mCurrY, mMinY);

                if (mCurrX == mFinalX && mCurrY == mFinalY) {
                    mFinished = true;
                }

                break;
            }
        }
        else {
            mCurrX = mFinalX;
            mCurrY = mFinalY;
            mFinished = true;
        }
        return true;
    }

即 Scroller 本身并不能实现 View 的滑动,它需要配合 View 的 computeScroll 方法才能完成弹性的效果,不断地让 View 重绘,而每一次重绘距滑动起始时间会有一个时间间隔,通过这个时间间隔 Scroller 就可以得出 View 当前的滑动位置,知道了滑动位置就可以通过 scrollTo 方法来完成 View 的滑动,就这样,View 的每一次重绘都会导致 View 进行小幅度的滑动,而多次的小幅度滑动就组成了弹性滑动,这就是 Scroller 的工作机制。

3.2、通过动画

动画本身就是渐进的效果,因此可以用来实现滑动的弹性效果,如让一个 View 的内容在 100ms 内向左边移动 100 像素:

 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();

也就是,我们的动画本质上没有做任何事情,只是利用动画的特性,在每一帧到来的时候,我们计算动画完成的比例,在算出 View 要滑动的距离。通过这种方法不仅可以实现弹性效果,还可以实现其他的效果。

3.3、使用延时策略

延时策略的核心思想就是,通过发送一系列的延时消息从而达到一种渐进式的效果,具体来说可以使用 Handler 或者 View 的postDelayed 方法,也可以使用 sleep 方法。
下面示例使用 Handler 在大约1000ms 内,View 内容向左移动 100 像素。

    private static final int MESSAGE_SCROLL_TO = 1;
    private static final int FRAME_COUNT = 30;
    private static int DELAYED_TIME = 33;

    private int mCount = 0;
    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);
                        mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO,DELAYED_TIME);
                    }
                    break;
                }
                default:
                    break;
            }
        }
    };
四、 View 事件分发机制
4.1 点击事件的传递规则

事件列:


事件列.png

主要的事件有:
主要发生的Touch事件有如下四种:

  • MotionEvent.ACTION_DOWN:按下View(所有事件的开始)
  • MotionEvent.ACTION_MOVE:滑动View
  • MotionEvent.ACTION_CANCEL:结束本次事件
  • MotionEvent.ACTION_UP:抬起View(与DOWN对应)

点击事件,即 MotionEvent,当一个 MotionEvent 产生后,系统需要把这个事件传递给一个具体的 View,而这个传递过程就是事件分发。一个点击事件产生后,传递顺序是:Activity(Window) -> ViewGroup -> View 。

事件分发中,有三个重要的方法:

  • dispatchTouchEvent
  • onInterceptTouchEvent
  • onTouchEvent
事件分发方法.png
public boolean dispatchTouchEvent(MotionEvent ev)

用来进行事件分发,如果能够传递到当前 View ,此方法一定会被调用,但返回结果受当前 View 的 onTouchEvent 和下级 View 的 dispatchTouchEvent 方法的影响,表示是否消耗当前事件。

public boolean onInterceptTouchEvent(MotionEvent event)

在 dispathcTouchEvent() 内部调用,用来判断是否某个事件,如果当前 View 拦截了某个事件,那么在同一个事件列中,此方法不再被调用,返回结果表示拦截当前事件。

public boolean onTouchEvent(MotionEvent ev)

在 dispatchTouchEvent 中调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件列中,当前 View 无法再次接收到事件。

上面的三个方法可用伪代码表示:

// 点击事件产生后,会直接调用dispatchTouchEvent()方法
public boolean dispatchTouchEvent(MotionEvent ev) {

    //代表是否消耗事件
    boolean consume = false;


    if (onInterceptTouchEvent(ev)) {
    //如果onInterceptTouchEvent()返回true则代表当前View拦截了点击事件
    //则该点击事件则会交给当前View进行处理
    //即调用onTouchEvent ()方法去处理点击事件
      consume = onTouchEvent (ev) ;

    } else {
      //如果onInterceptTouchEvent()返回false则代表当前View不拦截点击事件
      //则该点击事件则会继续传递给它的子元素
      //子元素的dispatchTouchEvent()就会被调用,重复上述过程
      //直到点击事件被最终处理为止
      consume = child.dispatchTouchEvent (ev) ;
    }

    return consume;
   }

通过上述伪代码,可以看出点击事件的传递规则:
对于 ViewGroup 来说,点击事件产生后,首先会传递给它,这时会调用它的 dispatchTouchEvent,如果这个 ViewGroup 的 onInterceptTouchEvent 方法返回 true 就表示这个 ViewGroup 要拦截这个事件,然后这个 ViewGroup 的 onTouchEvent 就会被调用,处理事件。如果 onInterceptTouchEvent 返回 false,就表示不拦截事件,然后事件就会传递给这个 ViewGroup 的子元素,然后子元素的 dispatchTouchEvent 就会调用,如此反复直到事件得到最终的处理。

当一个 View 需要处理事件时,如果它设置了 OnTouchListener,那么 OnTouchListener 中的 onTouch 方法就会被回调,这时,如果返回 false,则当前 View 的 onTouchEvent 方法会被调用,如果返回 true ,那么 onTouchEvent 方法将不会调用。因此, View 中,OnTouchListener 优先级比 onTouchEvent 高,在 onTouchEvent 中,如果当前设置了 OnClickListener,那么 onClick 方法会被调用。我们平时使用的 OnClickListener 优先级最低,也就是处于事件传递的尾端。

当一个事件产生后,它的传递顺序是:Activity->Window->View。当一个 View 的 onTouchEvent 返回 false,那么它父容器的 onTouchEvent 将会被调用,以此类推,当所有的 View 都不处理这个事件,最终将交给 Activity处理。

事件分发机制的一些结论:

(1) 正常情况下,一个事件列,只能被一个 View 拦截且消耗。
(2) 如果一个 View 决定拦截事件,那么这个事件列只能由他处理,并且 onInterceptTouchEvent 不会被调用。
(3) View 一旦不处理 ACTION_DOWN,那么这个事件列都不会在交给这个 View 处理,直接交给上级。
(4) 如果 View 不消耗除 ACTION_DOWN 以外的其他事件,那么这个点击事件将会消失,并且当前 View 会持续收到事件列的后续事件,但是最终这些消失的的点击事件将会传递给 Activity 处理。
(5) ViewGroup 默认不拦截任何事件。
(6) 如果 View 没有onInterceptTouchEvent 方法,那么一旦点击事件传递给他,那么他的 onTouchEvent 方法就会被调用。
(7) View 的 onTouchEvent 默认是会消耗事件的(返回 true),除非是不可点击的事件(clickable 和 longClickable 属性同时为 false)。
(8) View 的 enable 属性不影响 onTouchEvent 的默认返回值。
(9) onClick 会发生的前提是当前 View 是可点击的,并且收到了 down 和 up 事件。
(10) 事件分发总是由父元素传递给子元素,但是子元素可以通过 requestDisallowInterceptTouchEvent 方法干预父元素的事件分发。(ACTION_DOWN)除外。

事件分发流程图.png
4.2 事件分发源码解析
  • Activity 对事件的处理
    当一个点击操作发生后,先传递给 Activity,由 Activity 的dispatchTouchEvent 进行事件派发,具体来说,由 Activity 内部的 Windwo 来完成。 Window 会将事件传递给 decor view,decor view 是当前界面的底层容器(也就是 setContentView 设置的 view 的父容器),通过 Activity.getWindow.getDecorView() 可以得到。

Activity 的 dispatchTouchEvent:

   public boolean dispatchTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
            onUserInteraction();
        }
        if (getWindow().superDispatchTouchEvent(ev)) {
            return true;
        }
        return onTouchEvent(ev);
    }

只看事件分发,首先事件会交给 Activity 所附属的 Window 进行分发,如果返回true,整个事件结束,返回false,则整个事件没人处理,也就是所有的 View 的 onTouchEvent 都返回了 false,那么 Activity 的 onTouchEvent 就会被调用。
superDispatchTouchEvent 主要是用来将事件传递给 ViewGroup,因为 Window 是一个抽象类,而其唯一的实现是 PhoneWindow,因此要接着分析 PhoneWindow:

 public boolean superDispatchTouchEvent(MotionEvent event){
        return mDecor.superDispatchTouchEvent(event);
    }

也就是,PhtoneWindow 直接将事件传递给了 mDecor(DecorView)。因为通过 ((ViewGroup)getWindow().getDecorView().findViewById(android.R.id.content)).getChildAt(0) 可以获取 Activity 所设置的 View,而这个 mDecor 就是 getWindow().getDecorView() 返回的 View,而我们通过 setContentView 设置的 View 是这个 DecorView 的子 View,因此目前位置,事件传递给了所以 View 的父 View,也就是顶级View,或者根View。

  • 顶级 View 对事件的处理:
    在 ViewGroup 中,dispatchTouchEvent 是用来描述是否拦截事件的:
    // Check for interception.
            final boolean intercepted;
            if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
                final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
                if (!disallowIntercept) {
                    intercepted = onInterceptTouchEvent(ev);
                    ev.setAction(action); // restore action in case it was changed
                } else {
                    intercepted = false;
                }
            } else {
                // There are no touch targets and this action is not an initial down
                // so this view group continues to intercept touches.
                intercepted = true;
            }

以上代码的意思:
ViewGroup 会在两种情况判断是否要拦截事件,一个是 ACTION_DOWN,另一个是 mFistTouchTarget != null (表示事件交给子元素)。当这两个条件都不成立时,直接交由 ViewGroup 处理。
还一种特殊情况,就是子元素设置了父元素的 FLAG_DISALLOW_INTERCEPT 标记位,一旦设置了这个,ViewGroup 将无法拦截除了 ACTION_DOWN 以外的其他事件,ACTION_DOWN 事件会重置这个标志位。
接下来就是事件分发到子 View 处理。

    // Handle an initial down.
            if (actionMasked == MotionEvent.ACTION_DOWN) {
                // Throw away all previous state when starting a new touch gesture.
                // The framework may have dropped the up or cancel event for the previous gesture
                // due to an app switch, ANR, or some other state change.
                cancelAndClearTouchTargets(ev);
                resetTouchState();
            }

表示当 ViewGroup 决定拦截事件后,后续事件直接交给他处理并且不在调用 onInterceptTouchEvent 方法。

  final int childrenCount = mChildrenCount;
                    if (newTouchTarget == null && childrenCount != 0) {
                        final float x = ev.getX(actionIndex);
                        final float y = ev.getY(actionIndex);
                        // Find a child that can receive the event.
                        // Scan children from front to back.
                        final ArrayList<View> preorderedList = buildTouchDispatchChildList();
                        final boolean customOrder = preorderedList == null
                                && isChildrenDrawingOrderEnabled();
                        final View[] children = mChildren;
                        for (int i = childrenCount - 1; i >= 0; i--) {
                            final int childIndex = getAndVerifyPreorderedIndex(
                                    childrenCount, i, customOrder);
                            final View child = getAndVerifyPreorderedView(
                                    preorderedList, children, childIndex);

                            // If there is a view that has accessibility focus we want it
                            // to get the event first and if not handled we will perform a
                            // normal dispatch. We may do a double iteration but this is
                            // safer given the timeframe.
                            if (childWithAccessibilityFocus != null) {
                                if (childWithAccessibilityFocus != child) {
                                    continue;
                                }
                                childWithAccessibilityFocus = null;
                                i = childrenCount - 1;
                            }

                            if (!canViewReceivePointerEvents(child)
                                    || !isTransformedTouchPointInView(x, y, child, null)) {
                                ev.setTargetAccessibilityFocus(false);
                                continue;
                            }

                            newTouchTarget = getTouchTarget(child);
                            if (newTouchTarget != null) {
                                // Child is already receiving touch within its bounds.
                                // Give it the new pointer in addition to the ones it is handling.
                                newTouchTarget.pointerIdBits |= idBitsToAssign;
                                break;
                            }

                            resetCancelNextUpFlag(child);
                            if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                                // Child wants to receive touch within its bounds.
                                mLastTouchDownTime = ev.getDownTime();
                                if (preorderedList != null) {
                                    // childIndex points into presorted list, find original index
                                    for (int j = 0; j < childrenCount; j++) {
                                        if (children[childIndex] == mChildren[j]) {
                                            mLastTouchDownIndex = j;
                                            break;
                                        }
                                    }
                                } else {
                                    mLastTouchDownIndex = childIndex;
                                }
                                mLastTouchDownX = ev.getX();
                                mLastTouchDownY = ev.getY();
                                newTouchTarget = addTouchTarget(child, idBitsToAssign);
                                alreadyDispatchedToNewTouchTarget = true;
                                break;
                            }

                            // The accessibility focus didn't handle the event, so clear
                            // the flag and do a normal dispatch to all children.
                            ev.setTargetAccessibilityFocus(false);
                        }
                        if (preorderedList != null) preorderedList.clear();
                    }

上面的代码表示,先遍历 ViewGroup 的所有子元素,然后判断子元素是否能够接收点击事件(子元素是否在播放动画 和 点击事件是否落到子元素区域内)。
如果遍历了所有子元素,事件都没有被处理(ViewGroup 没有子元素,或者处理了点击事件,但是 dispatchTouchEvent 返回了 false,这种情况一般是 TouchEnent 返回了 false),那么 ViewGroup 会自己处理。代码如下:

 // Dispatch to touch targets.
            if (mFirstTouchTarget == null) {
                // No touch targets so treat this as an ordinary view.
                handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
            } 
  • View 对事件的处理
   public boolean dispatchTouchEvent(MotionEvent event) {
        // If the event should be handled by accessibility focus first.
        if (event.isTargetAccessibilityFocus()) {
            // We don't have focus or no virtual descendant has it, do not handle the event.
            if (!isAccessibilityFocusedViewOrHost()) {
                return false;
            }
            // We have focus and got the event, then use normal event dispatch.
            event.setTargetAccessibilityFocus(false);
        }

        boolean result = false;

        if (mInputEventConsistencyVerifier != null) {
            mInputEventConsistencyVerifier.onTouchEvent(event, 0);
        }

        final int actionMasked = event.getActionMasked();
        if (actionMasked == MotionEvent.ACTION_DOWN) {
            // Defensive cleanup for new gesture
            stopNestedScroll();
        }

        if (onFilterTouchEventForSecurity(event)) {
            if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
                result = true;
            }
            //noinspection SimplifiableIfStatement
            ListenerInfo li = mListenerInfo;
            if (li != null && li.mOnTouchListener != null
                    && (mViewFlags & ENABLED_MASK) == ENABLED
                    && li.mOnTouchListener.onTouch(this, event)) {
                result = true;
            }

            if (!result && onTouchEvent(event)) {
                result = true;
            }
        }

        if (!result && mInputEventConsistencyVerifier != null) {
            mInputEventConsistencyVerifier.onUnhandledEvent(event, 0);
        }

        // Clean up after nested scrolls if this is the end of a gesture;
        // also cancel it if we tried an ACTION_DOWN but we didn't want the rest
        // of the gesture.
        if (actionMasked == MotionEvent.ACTION_UP ||
                actionMasked == MotionEvent.ACTION_CANCEL ||
                (actionMasked == MotionEvent.ACTION_DOWN && !result)) {
            stopNestedScroll();
        }

        return result;
    }

其处理过程是:
先判断是否设置了 OnTouchListener,如果 OnTouchListener 中的 onTouch 方法返回了 true,那么 onTouchEvent 就不会被调用。
在 onTouchEvent 中,只要 View 的 CLICKABLE 和 LONG_CLICKABLE 有一个 true,那么它就会消耗这个事件。需要注意的是,通过 setClickable 可以将 View 的 CLICKABLE 设为 true,setOnLongClickListener 同理。

五、 View 滑动冲突

滑动冲突的基本场景有三个:

  • 外部滑动和内部滑动方向不一致(左右 + 上下)
  • 内外滑动方向一致(比如都是上下滑动)
  • 多层嵌套滑动,上面两种形式的组合。
滑动冲突的解决方式
  • 外部拦截法
    外部拦截法的思想就是,重写父容器的 onInterceptTouchEvent 方法,如何父容器需要此事件,则拦截,不需要则不拦截。
    其伪代码:
    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  = false;
                }
                break;
            }
            case MotionEvent.ACTION_UP:{
                intercepted = false;
                break;
            }
            default:
                break;
        }
        
        mLastXIntercept  = x;
        mLastYIntercept = y;
        
        return intercepted;
    }

需要注意的是,ACTION_DOWN 是不能拦截的,因为一旦拦截了ACTION_DOWN,那么事件就直接交给父容器了,而不会再给子元素。

  • 内部拦截法
    内部拦截法就是,当子元素需要事件时,直接消耗,否则交给父容器。
    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:{
                parent.requestDisallowInterceptTouchEvent(true);
                break;
            }
            case MotionEvent.ACTION_MOVE:{

                int deltaX = x - mLastX;
                int deltaY = y - mLastY;

                if (父容器需要拦截当前点击事件){
                    parent.requestDisallowInterceptTouchEvent(false);
                }else {
                    intercepted  = false;
                }
                break;

                break;
            }
            case MotionEvent.ACTION_UP:{
                break;
            }
            default:
                break;
        }

        mLastX  = x;
        mLastY = y;

        return super.dispatchTouchEvent(event);
    }

也就是子元素调用 requestDisallowableInterceptetTouchEvent 来干预父容器是否拦截事件。

父元素所做修改如下:

    public boolean onInterceptTouchEvent(MotionEvent event){
        int action = event.getAction();
        if (action == MotionEvent.ACTION_DOWN){
            return false;
        }else {return true
        }
    }
实例介绍:

本实例实现一个类似 ViewPager 中嵌套 ListView 的效果,为了制造滑动冲突,因此不能使用 ViewPager,而是自己实现一个类似的控件 HorizontalScrollViewEx。这个控件可以水平滑动,在这个控件内部添加 ListView,因为 ListView 竖直滚动而这个控件要实现水平滚动,因此这是典型的滑动冲突。

1、自定义 View 实现:

public class HorizontalScrollViewEx extends ViewGroup {
    public HorizontalScrollViewEx(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measureWidth = 0;
        int measuredHeight = 0;
        final int childCount = getChildCount();
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);

        if (childCount == 0) {
            setMeasuredDimension(getLayoutParams().width, getLayoutParams().height);
        } else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            View childView = getChildAt(0);
            measureWidth = childView.getMeasuredWidth() * childCount;
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measureWidth, measuredHeight);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            View childView = getChildAt(0);
            measureWidth = widthSpaceSize;
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measureWidth, measuredHeight);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            View childView = getChildAt(0);
            measureWidth = childView.getMeasuredWidth() * childCount;
            measuredHeight = heightSpaceSize;
            setMeasuredDimension(measureWidth, measuredHeight);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);
            if (childView.getVisibility() != View.GONE) {
                int childWidth = childView.getMeasuredWidth();
                childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
                childLeft += childWidth;
            }
        }
    }
}

此自定义 View 继承 ViewGroup 只是重写了 onMeasue 和 onLayout方法。
2、 Activity

public class MainActivity extends AppCompatActivity {
    HorizontalScrollViewEx mListContainer;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        LayoutInflater inflater = getLayoutInflater();
        mListContainer = (HorizontalScrollViewEx) findViewById(R.id.horizontalScrollViewEx);
        final int screenWidth = MyUtils.getScreenMetrics(this).x;
        final int screenHeight = MyUtils.getScreenMetrics(this).y;
        for (int i = 0; i < 3; i++) {
            ViewGroup layout = (ViewGroup) inflater.inflate(
                    R.layout.content_layout, mListContainer, false);
            layout.getLayoutParams().width = screenWidth;
            TextView textView = (TextView) layout.findViewById(R.id.title);
            textView.setText("page " + (i + 1));
            layout.setBackgroundColor(Color.rgb(255 / (i + 1), 255 / (i + 1), 0));
            createList(layout);
            mListContainer.addView(layout);
        }
    }

    private void createList(ViewGroup layout) {
        ListView listView = (ListView) layout.findViewById(R.id.list);
        ArrayList<String> datas = new ArrayList<String>();
        for (int i = 0; i < 50; i++) {
            datas.add("name " + i);
        }

        ArrayAdapter<String> adapter = new ArrayAdapter<String>(this,
                R.layout.content_list_item, R.id.name, datas);
        listView.setAdapter(adapter);
        listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> parent, View view,
                                    int position, long id) {
                Toast.makeText(MainActivity.this, "click item",
                        Toast.LENGTH_SHORT).show();

            }
        });
    }
}

此 Activity 只是新建了三个 ListView,并且把 ListView 加入到了自定义的 ViewGroup 中。
布局文件:
3、activity_main.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.hcworld.viewstudy.HorizontalScrollViewEx
        android:id="@+id/horizontalScrollViewEx"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</RelativeLayout>

4、content_layout.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <TextView
        android:id="@+id/title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="5dp"
        android:layout_marginBottom="5dp"
        android:text="TextView" />

    <ListView
        android:id="@+id/list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#fff4f7f9"
        android:cacheColorHint="#00000000"
        android:divider="#dddbdb"
        android:dividerHeight="1.0px"
        android:listSelector="@android:color/transparent" />

</LinearLayout>

5、content_list_item.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="50dp"
    android:gravity="center_vertical"
    android:orientation="vertical" >

    <TextView
        android:id="@+id/name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView" />

</LinearLayout>

6、MyUtils

    public static Point getScreenMetrics(Context context) {
        DisplayMetrics dm = context.getResources().getDisplayMetrics();

        Boolean isPortrait = dm.widthPixels < dm.heightPixels;

        int w_screen ;
        int h_screen ;

        w_screen = dm.widthPixels;
        h_screen = dm.heightPixels;

        return new Point(w_screen, h_screen);

    }

  • 外部拦截法
    首先考虑外部拦截,外部拦截,就是在父控件的 onInterceptTouchEvent 中根据拦截条件进行事件拦截,这里就是水平距离大于垂直距离,因此 HorizontalScrollViewEx 修改为 如下:
public class HorizontalScrollViewEx extends ViewGroup {

    private int mChildrenSize;
    private int mChildWidth;
    private int mChildIndex;

    private int mLastX;
    private int mLastY;

    private int mLastInterceptX = 0;
    private int mLastInterceptY = 0;

    private Scroller mscroller;
    private VelocityTracker mVelocityTracker;



    public HorizontalScrollViewEx(Context context, AttributeSet attrs) {
        super(context, attrs);

        mscroller = new Scroller(getContext());
        mVelocityTracker = VelocityTracker.obtain();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measureWidth = 0;
        int measuredHeight = 0;
        final int childCount = getChildCount();
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);

        if (childCount == 0) {
            setMeasuredDimension(getLayoutParams().width, getLayoutParams().height);
        } else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            View childView = getChildAt(0);
            measureWidth = childView.getMeasuredWidth() * childCount;
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measureWidth, measuredHeight);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            View childView = getChildAt(0);
            measureWidth = widthSpaceSize;
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measureWidth, measuredHeight);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            View childView = getChildAt(0);
            measureWidth = childView.getMeasuredWidth() * childCount;
            measuredHeight = heightSpaceSize;
            setMeasuredDimension(measureWidth, measuredHeight);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);
            if (childView.getVisibility() != View.GONE) {
                int childWidth = childView.getMeasuredWidth();
                childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
                childLeft += childWidth;
            }
        }
    }

    @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:
                // 这里必须返回false,因为这里如果父View拦截了,子View收不到后续事件,应该让子View拿到Down事件,父View拦截Move事件。
                intercepted = false;
                if (!mscroller.isFinished()) {
                    // 没结束,强制结束
                    mscroller.abortAnimation();
                    intercepted = true;
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastInterceptX;
                int deltaY = y - mLastInterceptY;

                // 这里根据条件判断是否需要拦截
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    intercepted = true;
                } else {
                    intercepted = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                // 这里返回false,因为up没什么意义了
                intercepted = false;
                break;
            case MotionEvent.ACTION_CANCEL:
                break;
        }
        mLastX = x;
        mLastY = y;
        mLastInterceptX = x;
        mLastInterceptY = y;
        return intercepted;
    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mVelocityTracker.addMovement(event);
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!mscroller.isFinished()) {
                    // 没结束,强制结束
                    mscroller.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_MOVE:
                // move的时候,调用scrollBy跟着滚动就可以
                int deltaX = x - mLastX;
                scrollBy(-deltaX, 0);
                break;
            case MotionEvent.ACTION_UP:
                int scrollX = getScrollX();
                mVelocityTracker.computeCurrentVelocity(1000);
                float xVelocity = mVelocityTracker.getXVelocity();
                Log.e("aaa","xVelocity="+xVelocity);
                if (Math.abs(xVelocity) >= 50) {
                    // 有速度则根据速度判断上一页或下一页
                    // 速度为正,则向右正向,左一页
                    mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
                } else {
                    // 没有速度则根据位置判断上一页或下一页
                    mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
                }
                mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
                int dx = mChildIndex * mChildWidth - scrollX;
                // 使用scroller滚动
                smoothScrollBy(dx, 0);
                mVelocityTracker.clear();
                break;
            case MotionEvent.ACTION_CANCEL:
                break;
        }
        mLastX = x;
        mLastY = y;
        return super.onTouchEvent(event);
    }

    private void smoothScrollBy(int dx, int dy) {
        mscroller.startScroll(getScrollX(), 0, dx, 0, 500);
        invalidate();
    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mscroller.computeScrollOffset()) {
            scrollTo(mscroller.getCurrX(), mscroller.getCurrY());
            postInvalidate();
        }
    }
    @Override
    protected void onDetachedFromWindow() {
        // 做回收工作
        mVelocityTracker.recycle();
        super.onDetachedFromWindow();
    }
}

最主要的思想是根据判断左右滑动的距离是否大于上下滑动的距离来决定是否拦截事件,而 onTouchEvent 则主要是实现弹性滑动。

  • 内部拦截法:
    如果要采用内部拦截法,则要修改 ListView 的 dispatchTouchEvent 方法中的父容器的拦截逻辑,同时让父容器拦截 ACTION_MOVE 和 ACTION_UP 事件即可,为了重写 ListView 的 dispatchTouchEvent 方法,我们需要自定义 ListView,为 ListViewEx,然后采用内部拦截法拦截事件,ListViewEx 代码如下:
public class ListViewEx extends ListView {
    int mLastX,mLastY;

    public ListViewEx(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                // True if the child does not want the parent to intercept touch events.
                // down时不希望父Veiw拦截,父View拦截后,后续事件无法传到子View中
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                if (Math.abs(deltaX)>Math.abs(deltaY)){
                    // false 子类希望父View拦截
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
            case MotionEvent.ACTION_CANCEL:
                break;
        }
        mLastX = x;
        mLastY = y;
        return super.dispatchTouchEvent(ev);
    }
}

采用内部拦截法,因此需要修改父控件中的 HorizontalScrollViewEx 中的 onInterceptTouchEvent 方法

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        int action = event.getAction();
        if (action == MotionEvent.ACTION_DOWN) {
            mLastX = x;
            mLastY = y;
            if (!mscroller.isFinished()) {
                mscroller.abortAnimation();
                return true;
            }
            return false;
        } else {
            return true;
        }
    }
    

上面代码中的 mScroller.abortAnimation() 不是必须的,只是弹性滑动优化体验。

接下来说一下当内外容器滑动方向相同时的场景。
现在,我们提供一个可以上下滑动的父容器,这里就叫 StickyLayout,就像是可以上下滑动的 LinearLayout,然后内部放一个 Header 和 一个 ListView,这样内外两层都能滑动,因此造成了滑动方向一致的滑动冲突。为了解决滑动冲突,需要重写父容器 StickyLayout 的 onInterceptTouchEvent 方法,而 ListView 不做任何修改。

1、首先实现滑动效果 StickyLayout 如下 :

public class StickyLayout extends LinearLayout {
    private String TAG = "StickyLayout";
    private int mLastY = 0;

    public StickyLayout(Context context) {
        this(context, null);
    }

    public StickyLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                int dy = y - mLastY;
                scrollBy(0, -dy);
                Log.e(TAG, "  deltaY=" + dy + "  mLastY=" + mLastY);
                break;
        }
        mLastY = y;
        return true;
    }
}

上述代码主要是重写 onTouch 方法的 ACTION_MOVE 事件,实现ViewGroup内容(TextView)跟随触摸滑动。
布局如下:

<com.hcworld.viewstudy.sameOritation.StickyLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    >
    <TextView1
        android:id="@+id/id_content_view"
        android:layout_width="match_parent"
        android:layout_height="700dp"
        android:background="@color/colorAccent"
        android:gravity="center"
        android:text="@string/app_name"
        android:textColor="@android:color/white" />
</com.hcworld.viewstudy.sameOritation.StickyLayout>

2、增加内容滑动边界控制,也就是内容不能无限滑动

public class StickyLayout extends LinearLayout {

    private String TAG = "StickyLayout";
    private int mLastY = 0;
    //内容View
    private View mContentView;
    //内容View的高度
    private int mContentHeight = 0;
    //内容View可见高度
    private int mContentShowHeight = 0;

    public StickyLayout(Context context) {
        this(context, null);
    }

    public StickyLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }


    /**
     * 布局加载完成
     */
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mContentView = findViewById(R.id.id_content_view);
    }

    /**
     * 计算控件高度
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        mContentShowHeight = getMeasuredHeight();
    }


    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mContentHeight = mContentView.getMeasuredHeight();
    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                int dy = y - mLastY;
                scrollBy(0, -dy);
                Log.e(TAG, "  deltaY=" + dy + "  mLastY=" + mLastY);
                break;
        }
        mLastY = y;
        return true;
    }

    /**
     * 重写scrollTo方法,进行边界控制
     */
    @Override
    public void scrollTo(int x, int y) {
        if (y < 0) {
            y = 0;
        }
        if (y > mContentHeight - mContentShowHeight) {
            y = mContentHeight - mContentShowHeight;
        }
        if (y != getScrollY()) {
            super.scrollTo(x, y);
        }
    }

}

这里我们重写 scrollTo 控制 y 的最小值为 0;最大值是内容高度 mContentHeight - mContentShowHeight

在函数 onFinishInflate() 中获取内容View
在函数onMeasure 中获取内容View的实际高度 mContentHeight
在函数 onSizeChanged 中获取获取内容View可见高度 mContentShowHeight

y 可以滚动的 范围 0 -> mContentHeight - mContentShowHeight
y 最大可以滚动的值:(假如)内容View的高度假如有 1000px ,内容View的可见高度有700px ,那么只需要滚动 y = 1000 - 700 = 300 就可以滚动到底。

3、添加 ListViwe 制造滑动冲突
首先布局如下:

<?xml version="1.0" encoding="utf-8"?>
<com.hcworld.viewstudy.sameOritation.StickyLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:rtv="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <ImageView
        android:id="@+id/id_header_view"
        android:layout_width="match_parent"
        android:layout_height="150dp"
        android:scaleType="centerCrop"
        android:src="@mipmap/header" />

    <RelativeLayout
        android:id="@+id/id_sticky_view"
        android:layout_width="match_parent"
        android:layout_height="45dp"
        android:background="@color/colorPrimary">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:layout_centerHorizontal="true"
            android:layout_marginLeft="16dp"
            android:text="titleText" />

    </RelativeLayout>

    <ListView
        android:id="@+id/id_content_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#fff4f7f9"
        android:cacheColorHint="#00000000"
        android:divider="#dddbdb"
        android:dividerHeight="1.0px"
        android:listSelector="@android:color/transparent" />

</com.hcworld.viewstudy.sameOritation.StickyLayout>

修改 StickyLayout 代码如下:

public class StickyLayout extends LinearLayout {

    private String TAG = "StickyLayout";
    private int mLastY = 0;

    private View mHeader;
    private View mContent;
    private View mSticky;
    private int mTouchSlop;
    //头部View是否隐藏
    boolean isTopHidden = false;
    private boolean mDragging = false;

    private Scroller mScroll;
    private VelocityTracker mVelocityTracker;
    private int mTopViewHeight;

    public StickyLayout(Context context) {
        this(context, null);
    }

    public StickyLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    private void init(Context context) {
        mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
        mScroll = new Scroller(context);
        mVelocityTracker = VelocityTracker.obtain();
    }

    /**
     * 布局加载完成
     */
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mHeader = findViewById(R.id.id_header_view);
        mContent = findViewById(R.id.id_content_view);
        mSticky = findViewById(R.id.id_sticky_view);
    }

    /**
     * 计算控件高度
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        ViewGroup.LayoutParams params = mContent.getLayoutParams();
        params.height = getMeasuredHeight() - mSticky.getMeasuredHeight();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mTopViewHeight = mHeader.getMeasuredHeight();
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mVelocityTracker.addMovement(event);
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroll.isFinished()) {
                    mScroll.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_CANCEL:
                mDragging = false;
                if (!mScroll.isFinished()) {
                    mScroll.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_MOVE:

                int dy = y - mLastY;
                if (!mDragging && Math.abs(dy) > mTouchSlop) {
                    mDragging = true;
                }
                if (mDragging) {
                    scrollBy(0, -dy);
                }
                Log.e(TAG, "  deltaY=" + dy + "  mLastY=" + mLastY);
                break;
            case MotionEvent.ACTION_UP:
                mDragging = false;
                mVelocityTracker.computeCurrentVelocity(1000);
                int yVelocity = (int) mVelocityTracker.getYVelocity();
                fling(-yVelocity);
                mVelocityTracker.clear();
                break;
        }
        mLastY = y;
        return true;
    }

    /**
     * 滑动
     *
     * @param dy
     */
    private void fling(int dy) {
        mScroll.fling(0, getScrollY(), 0, dy, 0, 0, 0, mTopViewHeight);
        invalidate();
    }

    /**
     * 计算滑动
     */
    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mScroll.computeScrollOffset()) {
            scrollTo(0, mScroll.getCurrY());
            postInvalidate();
        }
    }

    /**
     * 重写scrollTo方法,进行边界控制
     */
    @Override
    public void scrollTo(int x, int y) {
        if (y < 0) {
            y = 0;
        }
        if (y > mTopViewHeight) {
            y = mTopViewHeight;
        }
        if (y != getScrollY()) {
            super.scrollTo(x, y);
        }
        isTopHidden = getScrollY() == mTopViewHeight;
    }

    /**
     * 事件拦截
     */
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept = false;
        int y = (int) ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_MOVE:

                /**
                 * 控制权交换逻辑
                 * 1.头部view 没有隐藏{   本身控制     }
                 * 2.头部view     隐藏{  子view滚动到最顶部再往下滑动  : 本身控制  }
                 */
                int dy = y - mLastY;
                ListView lv = (ListView) mContent;
                View c = lv.getChildAt(lv.getFirstVisiblePosition());
                if (!isTopHidden || (c != null && c.getTop() == 0 && isTopHidden && dy > 0)) {
                    intercept = true;
                }

                break;
        }
        mLastY = y;
        return intercept;
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        mVelocityTracker.recycle();
    }
}

我们要实现的效果是,当头部View 没有隐藏的时候,直接拦截上下拖动
还有一点需要拦截,当头部View完全隐藏了,此时ListView本身滑动,当ListView滑到顶部再往下拉的时候,头部View需要可以滑下来。所以 OnInterceptTouchEvent 对这两种情况进行了判断。其他的,c.getTop() == 0 是ListView是否滚动到顶部的一种判断方法,也可以用其他方式实现,如果ListView换成其他的View,例如 RecycleView 那么此处逻辑需要更改为 RecycleView 滚动到顶部的逻辑判断。

MainActivity

public class MainActivity extends AppCompatActivity {

    private ListView mListView;
    private List<String> mList;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initView();
    }

    private void initView() {
        mListView = (ListView) findViewById(R.id.id_content_view);
        mList = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            mList.add("iPhone " + (i + 1));
        }
        ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, mList);
        mListView.setAdapter(adapter);
        mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                Toast.makeText(MainActivity.this, mList.get(position), Toast.LENGTH_SHORT).show();
            }
        });
    }
}

上述就是滑动方向相同的滑动冲突的处理,而最后一种滑动冲突,是前两种的组合,可以通过上面的原理具体问题具体分析解决。

本文参考: 《Android 开发艺术探索》 Android View 事件体系

推荐阅读更多精彩内容