Android开发自定义ViewPager以及滑动冲突的分析与解决

在前两篇文章中分别介绍了Android事件分发机制和3大流程,本文将我们将自己实现一个自定义ViewGroup来深刻理解一下这些知识点的用法。

自定义简易的ViewPager

不多bb,首先来看效果图:

Android开发自定义ViewPager以及滑动冲突的分析与解决_第1张图片

很简单的效果,手指左右滑动时会将容器中的内容拖动,手指松开之后可以自动翻页,也就是一个简易版ViewPager的效果。在看代码之前要先说一个前提,就是这个自定义的ViewPager在宽度方向上不能设置为wrap_content。这是因为ViewPager中滑动的子元素宽度都必须设为match_parent,否则如果不充满MyViewPager的话这个左右翻页效果也就没什么意义了。

首先我们创建一个MyViewPager类并继承自ViewGroup,在上一篇文章中已经说过了,自定义ViewGroup要重写onMeasure和onLayout方法,代码如下:

public class MyViewPager extends ViewGroup {

    private int maxValue;   //滑动的最大值
    private int minValue = 0;   //滑动的最小值

    private int width;  //ScrollLayout的宽度

    public MyViewPager(Context context) {
        super(context);
    }

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

    public MyViewPager(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //获取ScrollLayout尺寸的建议值和模式
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        //子元素最大高度
        int maxHeight = 0;

        //获取子元素个数
        int childNumber = getChildCount();
        //遍历测量子元素,并找到高度的最大值
        for (int i=0; i<childNumber; i++) {
            View child = getChildAt(i);
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
            int childHeight = child.getMeasuredHeight();
            maxHeight = childHeight>maxHeight? childHeight:maxHeight;  //刷新最大高度
        }

        //在wrap_content情况下容器高度等于子元素最大高度加上下边距
        int height = maxHeight + getPaddingTop() + getPaddingTop();
        //确定ScrollLayout自己尺寸,宽度采用建议值,高度分情况讨论
        setMeasuredDimension(widthSize, heightMode==MeasureSpec.EXACTLY? heightSize:height);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        //位置指针,记录从哪里开始摆放子元素
        int leftCur = 0;
        //获取子元素个数
        int childNumber = getChildCount();
        for (int i=0; i<childNumber; i++) {
            View child = getChildAt(i);
            //确定子元素位置
            child.layout(leftCur, getPaddingTop(), leftCur + child.getMeasuredWidth(), getPaddingTop() + child.getMeasuredHeight());
            //更新指针位置
            leftCur += child.getMeasuredWidth();
        }
        width = getWidth();
        maxValue = (getChildCount()-1)*width;   //滑动距离最大值
    }
}

首先解释一下onMeasure方法中的内容。上一篇文章中提到过了,onMeasure方法的两个参数是父容器对MyViewPager尺寸的建议值,所以我们首先通过解包的方式获取到建议宽高的SpecSize和SpecMode。接着定义了一个局部变量maxHeight来记录子元素的最大高度,为什么要这样做呢?因为在布局文件中如果将MyViewPager高度设置为wrap_content的话那么MyViewPager的高度就跟最高子元素相关了。接着就是for循环遍历测量子元素了,在循环中用的是measureChild方法(我懒得去处理子元素的margin了,如果要处理会代码更复杂一些,不过思想还是一样的),在measureChild方法之后调用了getMeasuredHeight来获取子元素的测量高度,最后刷新一下maxHeight变量。

在测量完子元素之后,就要确定自己的尺寸了。在wrap_content情况下容器高度等于子元素最大高度加上下边距,所以height变量保存的就是wrap_content情况下容器的高度值。接下来就是调用setMeasuredDimension确定自己的尺寸了,在之前说过MyViewPager的宽度不能采用wrap_content,所以它宽度的SpecMode一定是精确模式EXACTLY,因此宽度直接采用父容器的建议尺寸即可;而高度上就要分情况了,如果是wrap_content,那么就采用我们手动计算出的height变量,反之则采用父容器的建议尺寸,在代码中是用三元表达式体现的。

接下来就是onLayou方法了。首先定义了一个位置指针,记录从哪里开始摆放子元素。接着for循环确定子元素在MyViewPager中的位置,也就是调用子元素的layout方法,每次layout之后要更新一下指针的位置。for循环结束之后又给两个成员变量赋了值(为后续的滑动做准备),其中width表示MyViewPager自己的宽度,maxValue表示滑动距离的最大值,其值等于子元素个数减一再乘以MyViewPager的宽度,这是很好理解的,如果有n个子元素那么最大滑动距离当然就是(n-1)width 了。

这样一来MyViewPager的3大流程也就结束了,在activity_main.xml文件中放置这个自定义的控件并为它添加几个子元素(3个按钮):

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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.Demo.myapplication.MyViewPager
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/colorPrimary"
        android:paddingTop="50dp"
        android:paddingBottom="50dp">
        <Button
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="button1"/>
        <Button
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="button2"/>
        <Button
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="button3"/>

    </com.Demo.myapplication.MyViewPager>

</FrameLayout>

运行效果如下:
Android开发自定义ViewPager以及滑动冲突的分析与解决_第2张图片
可以看到子元素已经被摆放到了MyViewPager中了,为了显眼给MyViewPager添加了一个colorPrimary的背景颜色。

接下来就要实现左右滑动的效果了,我们重写onTouchEvent方法:

	private int lastX; //记录手指上一次按下的X坐标

	@Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getRawX();
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastX;  //计算手指滑动的举例
                if(getScrollX()-deltaX < minValue)  {
                	//如果左越界则强制滑动到左边界
                    scrollTo(minValue, 0);
                } else if(getScrollX()-deltaX > maxValue) {
                	//如果右越界则强制滑动到右边界
                    scrollTo(maxValue, 0);
                } else {
                	//没越界正常滑动
                    scrollBy(-deltaX, 0);
                }
                break;
        }
        lastX = x; //更新lastX
        return true;
    }

在ACTION_MOVE到来时首先要计算一下手指的滑动距离,如果当前的位置加上滑动的距离越界了,那么就要通过scrollTo的方式强制滑动到左/右边界,没越界则通过scrollBy方式滑动deltaX。这里要注意的是scrollBy方法参数的符号,负号表示MyViewPager的内容向左滑动,正号表示向右滑动。方法的最后更新一下lastX并返回true,那么为什么要返回true呢,返回false不行吗?

在之前的文章中提过,如果返回了false代表MyViewPager不消耗事件,不消耗的话同一事件序列的所有后续事件将会被MyViewPager的父容器拦截,这样MyViewPager就不会收到这个事件序列了。

这样我们再来运行一下看看效果:

Android开发自定义ViewPager以及滑动冲突的分析与解决_第3张图片
离最终的效果已经很近了!最后要做的是给MyViewPager添加一个翻页的效果:当手指松开时要根据当前滑动的距离来让MyViewPager自动翻页(否则的话手指松开后可能有两个子元素同时出现在屏幕中),也就是说给MyViewPager添加一个二次滑动的功能。这里采用的是Scroller来实现这个二次滑动,因为Scroller的滑动是有弹性的,而不像scrollTo和scrollBy这样嗖一下子就过去了。

首先我们要在MyViewPager的构造函数中创建一个Scroller对象:

	private Scroller mScroller;

    public MyViewPager(Context context) {
        super(context);
        init();
    }

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

    public MyViewPager(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        if (mScroller == null) {
            mScroller = new Scroller(getContext()); //获取Scroller实例
        }
    }

之后再onTouchEvent方法的ACTION_UP中添加二次滑动的代码:

	@Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getRawX();
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:            
                ……               
                break;

            case MotionEvent.ACTION_UP:
                float floatValue = (float) getScrollX()/width;  //小数值
                int intValue = getScrollX()/width;  //整数值
                if (floatValue-intValue > 0.5) {
                	//滑动到右面的页面
                    smoothScrollTo((intValue+1)*width,0);
                } else {
                	//滑动到左面的页面
                    smoothScrollTo(intValue*width, 0);
                }
                break;
        }
        lastX = x;
        return true;
    }

	private void smoothScrollTo(int destx, int desty) {
        int scrollX = getScrollX();
        int deltaX = destx - scrollX;
        mScroller.startScroll(scrollX, 0, deltaX,0,800);
        invalidate();
    }

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

其中smoothScrollTo和computeScroll是Scroller的标准用法,就不细说了。要弹性滑动只需要调用smoothScrollTo方法即可,两个参数的意义是滑动的目的地的xy坐标,那么现在要做的就是求出要滑动到的页数,然后根据页数求出坐标。

首先通过(float)getScrollX()/width得到了一个小数值floatValue,再通过getScrollX()/width得到了一个整数值intValue,也就是说intValue是floatValue取整得到的数值。那么floatValue-intValue得到的值就是一个大于0小于1的小数,这个小数值的意义可以理解为页面滑动的百分比。如果这个小数大于0.5则说明应该滑动到屏幕显示的两个页面中靠右面的页面,反之则说明应该滑动到靠左面的页面。下面这张图可以更好的理解一下:
Android开发自定义ViewPager以及滑动冲突的分析与解决_第4张图片

再次运行,就得到了开篇的效果了。完整代码如下:

public class MyViewPager extends ViewGroup {

    private int lastX; //记录手指上一次按下的X坐标

    private int maxValue;   //滑动的最大值
    private int minValue = 0;   //滑动的最小值

    private int width;  //ScrollLayout的宽度

    private Scroller mScroller;

    public MyViewPager(Context context) {
        super(context);
        init();
    }

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

    public MyViewPager(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        if (mScroller == null) {
            mScroller = new Scroller(getContext()); //获取Scroller实例
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //获取ScrollLayout尺寸的建议值和模式
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        //子元素最大高度
        int maxHeight = 0;

        //获取子元素个数
        int childNumber = getChildCount();
        //遍历测量子元素,并找到高度的最大值
        for (int i=0; i<childNumber; i++) {
            View child = getChildAt(i);
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
            int childHeight = child.getMeasuredHeight();
            maxHeight = childHeight>maxHeight? childHeight:maxHeight;  //刷新最大高度
        }

        //容器高度等于子元素最大高度加上下边距
        int height = maxHeight + getPaddingTop() + getPaddingTop();
        //确定ScrollLayout自己尺寸,宽度采用建议值,高度分情况讨论
        setMeasuredDimension(widthSize, heightMode==MeasureSpec.EXACTLY? heightSize:height);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        //位置指针,记录从哪里开始摆放子元素
        int leftCur = 0;
        //获取子元素个数
        int childNumber = getChildCount();
        for (int i=0; i<childNumber; i++) {
            View child = getChildAt(i);
            //确定子元素位置
            child.layout(leftCur, getPaddingTop(), leftCur + child.getMeasuredWidth(), getPaddingTop() + child.getMeasuredHeight());
            //更新指针位置
            leftCur += child.getMeasuredWidth();
        }

        width = getWidth();
        maxValue = (getChildCount()-1)*width;   //确定指针最大值
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getRawX();
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastX;  //计算手指滑动的举例
                if(getScrollX()-deltaX < minValue)  {
                    //如果左越界则强制滑动到左边界
                    scrollTo(minValue, 0);
                } else if(getScrollX()-deltaX > maxValue) {
                    //如果右越界则强制滑动到右边界
                    scrollTo(maxValue, 0);
                } else {
                    //没越界正常滑动
                    scrollBy(-deltaX, 0);
                }
                break;

            case MotionEvent.ACTION_UP:
                float floatValue = (float) getScrollX()/width;
                int intValue = getScrollX()/width;
                if (floatValue-intValue > 0.5) {
                    smoothScrollTo((intValue+1)*width,0);
                } else {
                    smoothScrollTo(intValue*width, 0);
                }
                break;
        }
        lastX = x;
        return true;
    }

    private void smoothScrollTo(int destx, int desty) {
        int scrollX = getScrollX();
        int deltaX = destx - scrollX;
        mScroller.startScroll(scrollX, 0, deltaX,0,800);
        invalidate();
    }

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

}

这样就实现了一个简单的ViewPager的滑动和翻页效果,但是它还有几个不足的地方可以改进:

  1. 没有处理MyViewPager的左右padding和子元素的margin。如果要处理的话测量、布局整体的流程还是一样的,只不过后面在计算滑动的时候代码要复杂一些。
  2. 真正的ViewPager如果手指滑动速度很快就会翻页,而MyViewPager没有实现这个功能。如果要实现的话,就要用VelocityTracker来检测手指滑动的速度。
  3. 没有处理滑动冲突。在效果图中可以发现,滑动的时候我们是在MyViewPager的空白区域进行拖拽(图中的绿色区域),如果在子元素的位置去拖拽,那么MyViewPager将不会滑动,因为此时事件会被交给子元素处理,这时候MyViewPager的onTouchEvent就不会被执行了。

那么下面就来讨论一下滑动冲突的问题。

滑动冲突的解决

只要两个可以滑动的控件有嵌套关系,那么一定会产生冲突,比较典型的例子就是ViewPager中嵌套了ListView这种情况。但是如果有开发经验的话,就会发现在ViewPager中不管放置什么控件都可以正常滑动,难道是结论错了吗?其实它确实有冲突,只不过被ViewPager处理好了,也就是说谷歌工程师帮开发者处理了这个冲突。事实上对于可以滑动的控件有的已经处理好了冲突而有的没处理,所以我们有必要学习滑动冲突的解决方案,这样除了能解决项目中的问题,还能增强对Android事件分发的理解。

在之前事件分发的文章中提到过一条重要的结论:对于一个ViewGroup来说,如果事件传到了它的手里,那么它有两种情况决定是否消耗掉这个事件:第一种是事件刚传到它手里的时候可以选择拦截并消耗;第二种是如果它的子元素不消耗,那么它可以选择自己消耗。如果这两种情况没有消耗,事件将被交给它的上级处理。根据这条结论,便产生了解决滑动冲突的两种方案:外部拦截法和内部拦截法。

外部拦截法对应着第一种情况,当事件到来时,由父容器决定到底是拦截自己处理还是不拦截交给子元素处理;内部拦截法对应着第二种情况,当事件到来时,父容器首先不拦截,将事件分发到子元素,子元素决定到底是自己处理还是抛给父容器处理。下面这张图可以更好的理解这个过程:

Android开发自定义ViewPager以及滑动冲突的分析与解决_第5张图片
为了更好的展现出滑动冲突现象,我们将布局文件中的按钮换成ListView:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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.Demo.myapplication.MyViewPager
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/colorPrimary"
        android:paddingTop="50dp"
        android:paddingBottom="50dp">

        <ListView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:id="@+id/lv1"
            android:background="@color/color1"/>

        <ListView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:id="@+id/lv2"
            android:background="@color/color2"/>

        <ListView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:id="@+id/lv3"
            android:background="@color/color3"/>

    </com.Demo.myapplication.MyViewPager>

</FrameLayout>

在MainActivity中加入如下内容:

public class MainActivity extends AppCompatActivity {

    private ListView listView1, listView2, listView3;

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

        listView1 = findViewById(R.id.lv1);
        listView2 = findViewById(R.id.lv2);
        listView3 = findViewById(R.id.lv3);

        String[] data = {"刘能", "王大拿", "赵玉田", "谢永强", "王老七", "赵四", "王小蒙", "谢广坤", "刘大脑袋", "谢大脚", "王长贵", "刘英", "谢腾飞", "王云" };
        ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, data);

        //为ListView设置适配器
        listView1.setAdapter(adapter);
        listView2.setAdapter(adapter);
        listView3.setAdapter(adapter);
    }
}

运行结果如下:
Android开发自定义ViewPager以及滑动冲突的分析与解决_第6张图片
可以发现,拖拽MyViewPager的空白区域时可以实现左右滑动,但是拖拽ListView区域时只能上下滑动了,也就是说产生了滑动冲突。

外部拦截法

首先用外部拦截法解决冲突。外部拦截法只需要重写MyViewPager的onInterceptTouchEcent方法即可:

	@Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted = false;
        int x = (int) ev.getRawX();
        int y = (int) ev.getRawY();

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                intercepted = false;  //不拦截ACTION_DOWN
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = Math.abs(x - lastX);
                int deltaY = Math.abs(y - lastY);
                if(deltaX >= deltaY) {
                    intercepted = true; //横向滑动拦截
                } else {
                    intercepted = false; //纵向滑动不拦截
                }
                break;
            case MotionEvent.ACTION_UP:
                intercepted = false; //不拦截ACTION_UP
                break;
        }
        lastX = x;
        lastY = y;
        return intercepted;
    }

首先获取到了事件的xy坐标,然后用switch语句分情况讨论。在ACTION_DOWN事件到来时一定不能拦截,因为如果拦截了那么子元素将接收不到后续同一序列的所有事件了;ACTION_MOVE到来时首先计算出ΔX和ΔY,如果ΔX>ΔY则说明用户的手势偏向于横向滑动,此时这个事件序列当然要交给MyViewPager处理,所以MyViewPager要拦截掉,如果ΔX<ΔY说明手势偏向于纵向滑动,这时就不拦截分发给子元素处理了;
ACTION_UP到来时一定不能拦截,因为如果拦截,那么子元素接收不到ACTION_UP事件就没办法处理点击事件了。在onInterceptTouchEvent方法的最后刷新lastX和lastY,返回intercepted。

分析完原理之后,再一次运行试试看:

Android开发自定义ViewPager以及滑动冲突的分析与解决_第7张图片
可以看到,滑动冲突问题已经解决了。

内部拦截法

接下来再说说内部拦截法吧。内部拦截法的思想是先将事件传给子元素,子元素再根据情况选择是否自己消耗掉事件。在事件分发那篇文章中提到过了一点,子元素可以通过一个标记位mGroupFlags来干涉父容器拦截事件,内部拦截法的根本道理就在于此。

既然让子元素去决定是否消耗,那么在布局中就不能使用ListView了,我们创建一个MyListView继承自ListView并重写dispatchTouchEvent方法:

public class MyListView extends ListView {

    private int lastX, lastY;

    public MyListView(Context context) {
        super(context);
    }

    public MyListView(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: {
                getParent().requestDisallowInterceptTouchEvent(true); //不让父容器拦截
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                int deltaX = Math.abs(x - lastX);
                int deltaY = Math.abs(y - lastY);
                if (deltaX >= deltaY) {
                    //子元素让父元素拦截
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                break;
            }
            default:
                break;
        }
        lastX = x;
        lastY = y;
        return super.dispatchTouchEvent(ev);
    }
}

在dispatchTouchEvent方法中先获取了手指触摸屏幕的坐标,之后用switch语句对事件进行判断。ACTION_DOWN到来之时调用父容器的requestDisallowInterceptTouchEvent方法阻止父容器拦截事件,这样一来后续的事件就都会传到MyListView手里了;接着当ACTION_MOVE到来时进行判断,其方法是一样的,若手势横向滑动趋势较大,那么就调用父容器的requestDisallowInterceptTouchEvent让父容器拦截;方法的最后刷新lastX和lastY。

有了这些还不够,我们还要再MyViewGroup中修改onInterceptTouchEvent方法:

	//内部拦截法
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        int x = (int) ev.getRawX();
        int y = (int) ev.getRawY();

        if(ev.getAction() == MotionEvent.ACTION_DOWN) {
        	//刷新lastX和lastY
            lastX = x;
            lastY = y;
            return false;
        } else {
            return true;
        }
    }

当ACTION_DOWN事件到来时不拦截(若拦截了那么MyListView就不会收到后续事件了),别的事件都拦截。这里要注意一点的是除了拦截的逻辑,还要在ACTION_DOWN事件中刷新 lastX和lastY,这一点很重要。如果不刷新的话,因为ACTION_DOWN事件被MyListView消耗掉了,那么ACTION_DOWN事件就进不到MyViewGroup的onTouchEvent方法中了,这样一来MyViewGroup就没有在ACTION_DOWN事件到来时刷新lastX和lastY,也就是说此时的lastX和lastY还是上一次MyViewGroup滑动到最后时手指的坐标,那么下一次横向滑动开始的一瞬间就会产生突变。我不知道这样有没有说清楚,如果不懂的话可以把那几行代码注释掉再来运行,就明白我想表达的意思了。

这样内部拦截法要写的代码就都写完了,其实这些代码和《Android开发艺术探索》是差不多的。不过对于内部拦截法,有一点值得一提:

内部拦截法重写的是子元素的dispatchTouchEvent方法,那么就意味着每当有事件传到子元素时,都会进到switch语句中去判断是否调用requestDisallowInterceptTouchEvent方法。我们考虑这样一种情况,如果手指这样滑动:

Android开发自定义ViewPager以及滑动冲突的分析与解决_第8张图片
也就是刚开始竖直方向趋势比较大,然而到后来水平方向趋势反超了。那么一开始的时候滑动事件确实交给MyListView处理,但是后来因为水平方向滑动占据优势,那么事件就会被MyViewPager处理了。站在用户的角度上来讲,本来用户就想操作MyListView,开始竖直滑动好好的,在手指没松开的情况下由于失误导致手指横向移动趋势变大,这时候突然MyViewPager就起作用开始横向滑动,像这种样的效果对用户而言是很不友好的。我们想达到这样一种效果:这个组合滑动控件到底是水平滑动还是竖直滑动取决于用户手指刚按下时水平滑动趋势较大还是竖直趋势较大,如果水平趋势大,那么只要手指不松开,后面即使竖直趋势反超了控件也不会竖直滑动;如果竖直趋势较大,那么只要手指不松开,后面即使水平趋势反超了控件也不会水平滑动。

根据这样的分析,我们要对子元素的dispatchTouchEvent方法进行小小的修改:

//内部拦截法
	boolean flag = false;
	
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        Log.d("hahaha", "ListView的dispatchTouchEvent执行");
        int x = (int) ev.getX();
        int y = (int) ev.getY();

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            }
            
            case MotionEvent.ACTION_MOVE: {
                if (!flag) {
                    int deltaX = Math.abs(x - lastX);
                    int deltaY = Math.abs(y - lastY);
                    if (deltaX > deltaY) {
                        //交给父容器处理
                        getParent().requestDisallowInterceptTouchEvent(false);
                    } else {
                        flag = true;
                    }
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                flag = false;
                break;
            }
            default:
                break;
        }
        lastX = x;
        lastY = y;
        return super.dispatchTouchEvent(ev);
    }

首先我们定义了一个标记位flag,如果MyListView决定自己消耗事件,那么就将flag置为true。这样下一次ACTION_MOVE到来时就不用判断是否要让父元素拦截了。通过这个标记位我们让MyListView只判断一次,这样就实现了滑动方向依照用户最开始手势趋势这个原则。

再次运行,就得到了和外部拦截法一样的效果。

那么我们再深入探讨一下,为什么用外部拦截法处理滑动冲突时不用考虑这个问题呢?为了搞清楚这个问题,我们在MyViewPager的onInterceptTouchEvent方法中打印一行日志:

//外部拦截法
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        Log.d("hahaha", "父容器onInterceptTouchEvent执行");
        
        ……
        
    }

运行一下,手指竖直滑动并查看日志:

Android开发自定义ViewPager以及滑动冲突的分析与解决_第9张图片
可以发现,MyViewPager的onInterceptTouchEvent只执行了5次,后面就不再执行了,这是为什么呢?按理来说每次MyViewPager分发事件的时候都应该调用onInterceptTouchEvent判断是否拦截不是吗?

为了说明这个问题,我们再来回顾一下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;
            }

根据这块代码我们发现onInterceptTouchEvent只有在disallowIntercept为false时才会执行,也就是说如果子元素调用了MyViewPager的requestDisallowInterceptTouchEvent(true)后,onInterceptTouchEvent才不会被执行。这样我们就可以做出一个猜想,有没有可能是MyListView在滑动的过程中调用了requestDisallowInterceptTouchEvent呢?

根据这样的猜想,我们点开MyListView的onTouchEvent方法(其实是AbsListView的onTouchEvent方法):

	@Override
    public boolean onTouchEvent(MotionEvent ev) {
    
        ……
        
        final int actionMasked = ev.getActionMasked();
        if (actionMasked == MotionEvent.ACTION_DOWN) {
            mNestedYOffset = 0;
        }
        vtev.offsetLocation(0, mNestedYOffset);
        switch (actionMasked) {
            case MotionEvent.ACTION_DOWN: {
                onTouchDown(ev);
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                onTouchMove(ev, vtev);
                break;
            }
            case MotionEvent.ACTION_UP: {
                onTouchUp(ev);
                break;
            }         
			……
    }

再来点开onTouchMove方法:

	private void onTouchMove(MotionEvent ev, MotionEvent vtev) {
	
        ……

        switch (mTouchMode) {
            case TOUCH_MODE_DOWN:
            case TOUCH_MODE_TAP:
            case TOUCH_MODE_DONE_WAITING:
                // Check if we have moved far enough that it looks more like a
                // scroll than a tap. If so, we'll enter scrolling mode.
                if (startScrollIfNeeded((int) ev.getX(pointerIndex), y, vtev)) {
                    break;
                }
                // Otherwise, check containment within list bounds. If we're
                // outside bounds, cancel any active presses.
                final View motionView = getChildAt(mMotionPosition - mFirstPosition);
                final float x = ev.getX(pointerIndex);
                if (!pointInView(x, y, mTouchSlop)) {
                    setPressed(false);
                    if (motionView != null) {
                        motionView.setPressed(false);
                    }
                    removeCallbacks(mTouchMode == TOUCH_MODE_DOWN ?
                            mPendingCheckForTap : mPendingCheckForLongPress);
                    mTouchMode = TOUCH_MODE_DONE_WAITING;
                    updateSelectorState();
                } else if (motionView != null) {
                    // Still within bounds, update the hotspot.
                    final float[] point = mTmpPoint;
                    point[0] = x;
                    point[1] = y;
                    transformPointToViewLocal(point, motionView);
                    motionView.drawableHotspotChanged(point[0], point[1]);
                }
                break;
            case TOUCH_MODE_SCROLL:
            case TOUCH_MODE_OVERSCROLL:
                scrollIfNeeded((int) ev.getX(pointerIndex), y, vtev);
                break;
        }
    }

接下来点开startScrollIfNeeded方法:

	private boolean startScrollIfNeeded(int x, int y, MotionEvent vtev) {
        // Check if we have moved far enough that it looks more like a
        // scroll than a tap
        final int deltaY = y - mMotionY;
        final int distance = Math.abs(deltaY);
        final boolean overscroll = mScrollY != 0;
        if ((overscroll || distance > mTouchSlop) && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) {
        
            ……
            
            // Time to start stealing events! Once we've stolen them, don't let anyone
            // steal from us
            final ViewParent parent = getParent();
            if (parent != null) {
                parent.requestDisallowInterceptTouchEvent(true);
            }
            scrollIfNeeded(x, y, vtev);
            return true;
        }

        return false;
    }

这里面的英文注释写的已经很明白了,ListView开始滑动,那么就会调用parent.requestDisallowInterceptTouchEvent(true)方法阻止父容器拦截事件,现在终于真相大白了!原来是ListView在内部保护了这个滑动事件,而使用内部拦截法,如果ΔX>ΔY就会调用parent.requestDisallowInterceptTouchEvent(false)让这个保护失去作用。

总结一下:外部拦截法要重写父容器的onInterceptTouchEvent方法,而内部拦截法除了要重写父容器的onInterceptTouchEvent方法,还要重写子元素的dispatchTouchEvent方法。从实现角度上来看,外部拦截法更简单一些而且更容易理解,所以尽量使用外部拦截法。

本文代码已上传到GitHub上:https://github.com/Guohuidev/SlidingConflictDemo

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