android 从 0 开始自定义控件之 View 的滑动冲突详解(四)

1,986 阅读9分钟

转载请标明出处: blog.csdn.net/airsaid/art…
本文出自:周游的博客

前言

滑动冲突可以说每一个 Android 开发者都遇到过,虽然 Android 已经在如 ViewPager 这些控件内部处理了滑动冲突,但是在我们自己定义控件,或者一些复杂的布局情况下,依然要去解决滑动冲突的情况。
这一篇文章总结了下滑动冲突出现的场景,以及其中的规则和解决方法。

常见的滑动冲突场景

  • 外部滑动方向和内部滑动方向不一致。
  • 外部滑动方向和内部滑动方向一致。
  • 上面两种情况的嵌套。

第一种场景:
出现这种情况,主要的情景是ViewPager和Fragment组合使用时,ViewPager需要左右滑动,而其内部的控件需要上下滑动。虽然ViewPager内部已经为我们处理了这种冲突,但是我们在自定义控件时,有很大几率遇到这种冲突情况,所以还是很必要了解下如何处理的。

第二种场景:
第二种情况比较特殊,因为外部控件和内部控件的滑动方向是一致的。那么这时候,系统就不知道该去如何响应滑动了。在实际的开发中,出现这种情况,一般是内外层控件同时可以上下或者左右滑动。其他情况,则根据业务需求进行处理。比如ListView头部有一个可下拉的刷新头,那么就要判断ListView是否滑动到顶部,到顶部时滑动出现刷新头。

第三种场景:
第三种则是前二种的组合嵌套情况,比较复杂。在处理时,需要一层层的解决冲突。

滑动冲突的处理规则

一般来说,不管滑动冲突有多么复杂,都有一套规律,我们可以按照规律来进行一一解决。

第一种场景的处理规则:
根据用户是水平还是垂直滑动来进行处理。主要是判断用户的滑动方向,当用户左右滑动时,让需要左右滑动的控件拦截事件进行处理。相反的,当用户上下滑动时,则让需要上下滑动的控件拦截事件进行处理。

那么如何获取用户的滑动方向呢?主要是根据滑动过程中的两个点之间的坐标就可以得出是水平还是垂直滑动。我们可以根据滑动路径和水平方向所形成的夹角,也可以依据水平方向和竖直方向的距离差,某些特殊的情况下,还可以根据水平和垂直方向的速度差来做判断。

一般情况下的话,都是根据水平和竖直方向的距离差来进行判断的。比如竖直方向的滑动距离大就判断为竖直滑动,否则为水平滑动。

第二种场景的处理规则:
第二种场景主要是根据业务上的不同来处理。比如说,业务上有规则,当在某种状态时需要外部的View响应滑动,当在另一种状态时,内部的View相应滑动。根据这种业务上的需求,我们就可以得出具体的规则了。

第三种场景的处理规则:
第三种的情况就复杂了,可能是第一种和第二种的“合体”,也可能是多重嵌套。这时候,也需要像第二种的处理规则一样,根据业务来得出规则。

滑动冲突的解决方式

上面说过,针对场景一的解决方案,可以根据滑动的距离差来判断,这个距离差就是所谓的滑动规则。但是要怎么做才能够将事件交给指定的View去处理呢?这里就要用到事件分发机制了。针对滑动冲突,这里给出两种解决滑动冲突的方式:外部拦截法和内部拦截法。

外部拦截法

外部拦截法是指当父控件接收到事件后,判断该事件是否需要,如果需要则就行拦截,否则就不拦截。外部拦截法,需要重写父控件的onInterceptTouchEvent()方法,在内部做拦截。相应的伪代码如下:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    boolean intercepted = false;
    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;
    }
    return intercepted;
}

上面的是外部拦截法的典型写法,面对不同的滑动类型,只需要修改上面的判断条件即可,其他不用修改也不能够修改。这里再简要描述下,在onInterceptTouchEvent方法中,首先是ACTION_DOWN这个事件,父容器必须返回false,即不拦截ACTION_DOWN事件,这是因为一旦父容器拦截了ACTION_DOWN事件,那么后续的ACTION_MOVE和ACTION_UP事件都会直接交由父容器进行处理,这个时候就没有办法再传递给子元素了。其次是ACTION_MOVE事件,这个事件可以根据需求来决定是否拦截,如果父容器需要拦截就返回true,否则返回false。最后是ACTION_UP事件,这里也直接返回false。这是因为,如果父容器在ACTION_UP时返回true,就会导致子元素无法触发onClick事件(在View的源码中onClick事件是在ACTION_UP中触发的)。

实例

下面用一个栗子来演示下外部拦截法的用法,这里通过上篇文章中写到的Scroller来实现一个简易的ViewPager,并处理其与内部ListView的滑动冲突:

  • 自定义ScrollerLayout:

/**
 * 作者:周游
 * 时间:2016/11/13
 * 博客:http://blog.csdn.net/airsaid
 */
public class ScrollerLayout extends ViewGroup {

    private final Scroller mScroller;
    private int mLeftBorder;
    private int mRightBorder;
    private float mXDown;
    private float mXMove;
    private float mXLastMove;
    private int mLastXIntercept;
    private int mLastYIntercept;

    public ScrollerLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        mScroller = new Scroller(context);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childAt = getChildAt(i);
            // 为每一个子View测量大小
            measureChild(childAt, widthMeasureSpec, heightMeasureSpec);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if(changed){
            int childCount = getChildCount();
            for (int i = 0; i < childCount; i++) {
                View childAt = getChildAt(i);
                // 为每一个子View重新布局
                childAt.layout(i * childAt.getMeasuredWidth(), 0
                        , (i + 1) * childAt.getMeasuredWidth(), childAt.getMeasuredHeight());
            }
            // 初始化左右边界值
            mLeftBorder = getChildAt(0).getLeft();
            mRightBorder = getChildAt(getChildCount() - 1).getRight();
        }
    }

    @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:
                mXDown = ev.getRawX();
                mXLastMove = mXDown;

                intercepted = false;
                // 如果滑动没有完成,就继续由父控件处理
                if(!mScroller.isFinished()){
                    mScroller.abortAnimation();
                    intercepted = true;
                }
                break;
            case MotionEvent.ACTION_MOVE:
                mXMove = ev.getRawX();
                mXLastMove = mXMove;

                int deltaX = x - mLastXIntercept;
                int deltaY = y - mLastYIntercept;
                // 判断是否左右滑动,是则拦截事件
                if(Math.abs(deltaX) > Math.abs(deltaY)){
                    intercepted = true;
                }else{
                    intercepted = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercepted = false;
                break;
        }
        mLastXIntercept = x;
        mLastYIntercept = y;
        return intercepted;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()){
           case MotionEvent.ACTION_DOWN:
                if(!mScroller.isFinished()){
                    mScroller.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_MOVE:
                mXMove = event.getRawX();
                int scrolledX = (int) (mXLastMove - mXMove);
                if(getScrollX() + scrolledX < mLeftBorder){
                    scrollTo(mLeftBorder, 0);
                    return true;
                }else if(getScrollX() + getWidth() + scrolledX > mRightBorder){
                    scrollTo(mRightBorder - getWidth(), 0);
                    return true;
                }
                scrollBy(scrolledX, 0);
                mXLastMove = mXMove;
                break;
            case MotionEvent.ACTION_UP:
                int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
                int dx = targetIndex * getWidth() - getScrollX();
                mScroller.startScroll(getScrollX(), 0 , dx, 0);
                invalidate();
                break;
        }
        return super.onTouchEvent(event);
    }

    @Override
    public void computeScroll() {
        if(mScroller.computeScrollOffset()){
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            invalidate();
        }
    }
}
  • 布局:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    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"
    android:orientation="vertical"
    tools:context="com.airsaid.viewdemo.MainActivity">

    <com.airsaid.viewdemo.widget.ScrollerLayout
        android:id="@+id/scrollerLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</LinearLayout>
  • 代码中,添加3个ListView:
public class MainActivity extends AppCompatActivity {

    private ScrollerLayout mLayout;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mLayout = (ScrollerLayout) findViewById(R.id.scrollerLayout);

        for (int i = 0; i < 3; i++) {
            ListView listView = new ListView(this);
            List<String> list =  new ArrayList<>();
            for (int i1 = 0; i1 < 50; i1++) {
                list.add("page" + i + ", name: " + i1);
            }
            ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, list);
            listView.setAdapter(adapter);
            mLayout.addView(listView);
        }
    }

}

运行结果:
这里写图片描述

内部拦截法

内部拦截法是指父容器不拦截任何事件,所有的事件传递给子元素,如果子元素需要此事件则直接消耗掉,否则就交由父控件进行处理。这种方法和Android中的事件分发机制不一致,需要配合requestDisallowInterceptTouchEvent()方法才能正常工作,使用起来比较外部拦截法要稍微复杂一点,我们需要重写子元素的dispatchTouchEvent()方法,它的伪代码如下:

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    switch (ev.getAction()){
        case MotionEvent.ACTION_DOWN:
            // 要求父控件不拦截事件
            getParent().requestDisallowInterceptTouchEvent(true);
            break;
        case MotionEvent.ACTION_MOVE:
            if(父容器需要点击事件){
                // 要求父控件拦截事件
                getParent().requestDisallowInterceptTouchEvent(false);
            }
            break;
        case MotionEvent.ACTION_UP:
            break;
    }
    return super.dispatchTouchEvent(ev);
}

上面代码是内部拦截法的典型写法,面对不同滑动类型,只需要修改上面的判断条件即可,其他不用修改也不能够修改。除了子元素要做处理之外,父元素也要默认拦截除了ACTION_DOWN以外的其他事件,这样当子元素调用parenet.requestDisallowInterceptTouchEvent(false)方法时,父元素才能继续拦截所需的事件。

为什么父容器不能拦截ACTION_DOWN事件呢? 这是因为 ACTION_DOWN 事件不受 FLAG_DISALLOW_INTERCEPT 这个标记位的控制,所以一旦父容器拦截ACTION_DOWN事件,那么所有的事件都无法传递到子元素中去,这样内部拦截法就失去了作用了。父元素所做的修改如下:

switch (ev.getAction()){
    case MotionEvent.ACTION_DOWN:
        intercepted = false;
        break;
    case MotionEvent.ACTION_MOVE:
        intercepted = true;
        break;
    case MotionEvent.ACTION_UP:
        intercepted = true;
        break;
}

实例

这里根据上一个实例做出修改,首先是自定义一个ListView:


/**
 * 作者:周游
 * 时间:2016/11/20
 * 博客:http://blog.csdn.net/airsaid
 */
public class MyListView extends ListView {
    public MyListView(Context context) {
        super(context);
    }

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

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

    private int mLastX;
    private int mLastY;

    @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 = x - mLastX;
                int deltaY = y - mLastY;
                // 如果是左右滑动
                if(Math.abs(deltaX) > Math.abs(deltaY)){
                    // 要求父控件拦截事件
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        mLastX = x;
        mLastY = y;
        return super.dispatchTouchEvent(ev);
    }
}

在代码中将添加的ListView改为刚刚自定义好的ListView:

for (int i = 0; i < 3; i++) {
    MyListView listView = new MyListView(this);
    List<String> list =  new ArrayList<>();
    for (int i1 = 0; i1 < 50; i1++) {
        list.add("page" + i + ", name: " + i1);
    }
    ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, list);
    listView.setAdapter(adapter);
    mLayout.addView(listView);
}

修改父控件的onInterceptTouchEvent方法,拦截除ACTION_DOWN以外的事件:

@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:
            mXDown = ev.getRawX();
            mXLastMove = mXDown;

            intercepted = false;
            // 如果滑动没有完成,就继续由父控件处理
            if(!mScroller.isFinished()){
                mScroller.abortAnimation();
                intercepted = true;
            }
            break;
        case MotionEvent.ACTION_MOVE:
            mXMove = ev.getRawX();
            mXLastMove = mXMove;

            intercepted = true;
            break;
        case MotionEvent.ACTION_UP:
            intercepted = true;
            break;
    }
    mLastXIntercept = x;
    mLastYIntercept = y;
    return intercepted;
}

运行结果就不贴了,和上个例子一样。对比两种方式可以看出,实现的效果是一样的,但是内部拦截法要稍微复杂了一些。

场景二和场景三的解决方案和上面说的解决方案都一样,只不过滑动的规则不同而已。我们如果遇到了场景而和场景三的情况,只需要改变滑动规则就可以了。场景三可能要复杂一些,需要一层层的解决滑动冲突。

总结

解决滑动冲突,有一定的规则,万变不离其宗,只要掌握了其中的规则,那么多复杂的滑动冲突都可以游刃而解。不过前提还是需要了解事件的传递机制,这样才能很清晰的知道事件传递到了哪里。

参考

  • 《Android开发艺术探索》