Android 手把手进阶自定义View(十六)- 滑动冲突

一、前言


在界面中,只要内外两层同时可以滑动就会产生滑动冲突。而滑动冲突的解决都是由固定的套路的,下面我们来学习一下。

 

二、常见的滑动冲突场景


常见的滑动冲突场景可以简单的分为如下三种:

场景1、外部滑动方向与内部滑动方向不一致

比如 ViewPager 中有多个 Fragment,而 Fragment 往往有一个 ListView。这时 ViewPager 可以左右滑动,而 ListView 可以上下滑动,这就造成了滑动冲突。注意这里只是举个例子,事实上 ViewPager 内部已经处理了这种滑动冲突,在采用 ViewPager时,我们无需关注这个问题。但如果我们采用的是 ScrollView 等,那就必须手动处理滑动冲突了。否则就会造成内外两层只有一层能够滑动。

场景2、外部滑动方向和内部滑动方向一致

这种情况稍微复杂一点,因为当我们的手指开始滑动的时候,系统无法知道用户到底是想让哪一层滑动,所以就会出现问题,要么只有一层能够滑动,要么就是内外两层都滑动得很卡顿。

场景3、上面两种场景的嵌套

看起来更复杂,但是它是几个单一得滑动冲突得叠加,因此只要分别处理即可。

 

三、处理规则


一般来说,不管滑动冲突多么复杂,它都有既定得规则,根据这些我们就可以选择合适的方法去处理。

对于场景1,当用户左右滑动时,让外部的 View 拦截点击事件,当用户上下滑动时,让内部的View拦截点击事件。这个时候我们就可以根据它们的特征来解决滑动冲突,具体来讲就是根据滑动是水平滑动还是竖直滑动来判断到底由谁来拦截事件。我们可以这么判断用户的滑动方向,如果用户手指滑动的水平距离大于垂直距离,则左右滑动,反之则上下滑动。还可以根据滑动的角度、速度差来做判断。

场景2的处理规则比较特殊,无法根据滑动的角度、距离差、速度差来判断。因为场景2内部、外部的滑动方向一致。这时候一般都能在业务上找到突破点,如业务上规定:当处于某种状态需要外部View响应用户的滑动,而处于另一种状态时则需要内部View响应用户的滑动,所以我们可以根据业务的需求得出相应的处理规则。

场景3的处理规则就是将场景1的处理规则和场景2的处理规则一起使用。

 

四、解决方式


4.1、外部拦截法

所谓外部拦截法是指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要此事件就不拦截,这样就可以解决滑动冲突的问题,这种事件比较符合点击事件的分发机制。外部拦截法需要重写父容器的 onInterceptTouchEvent 方法,在内部做相应的拦截即可,这种方法的伪代码如下:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted = false;
        int x = (int)ev.getX();
        int y = (int)ev.getY();
 
        switch (ev.getAction()){ 
            case MotionEvent.ACTION_DOWN:
                //父容器不需要拦截
                intercepted = false;
                break; 
            case MotionEvent.ACTION_MOVE:
                //在此判断父容器是否需要拦截
                if (父容器需要当前点击事件){
                    intercepted = true;
                }else {
                    //父容器不需要当前的点击事件
                    intercepted = false;
                }
                break; 
            case MotionEvent.ACTION_UP:
                //父容器不需要拦截
                intercepted = false;
                break;
            default:
                break;
        }
        //重置手指的起始位置
        mLastXIntercept = x;
        mLastYIntercept = y;
        return intercepted;
    }

上述代码是外部拦截法的典型逻辑,针对不同的滑动冲突,只需要修改父容器需要当前点击事件这个条件即可。这里再描述一下上述代码:

在 onInterceptTouchEvent 方法中,首先是 ACTION_DOWN 事件,父容器必须返回 false,即父容器不拦截 ACTION_DOWN 事件。因为一旦父容器拦截了 ACTION_DOWN 事件,那么后续的 ACTION_MOVE、ACTION_UP 事件都会直接交给父容器处理,这个时候事件无法传递给子元素了。其次是 ACTION_MOVE 事件,在这里可以根据我们的需求来决定父容器是否需要拦截事件,需要则返回 true,否则返回 false。最后是 ACTION_UP 事件,这里必须返回 false,即父容器不拦截 ACTION_UP 事件,首先我们要知道 onClick 事件是在 ACTION_UP 事件之后执行的,当子元素有一个onClick事件,而这时候父容器拦截了 ACTION_UP 事件,那子元素的 onClick 事件就无法执行了。

4.1、内部拦截法

首先我们了解下 ViewGroup.requestDisallowInterceptTouchEvent(boolean) 方法,此方法就是在子 View 中通知父容器拦截或不拦截点击事件,false - 拦截,true - 不拦截。

内部拦截法的思想是指父容器先不拦截任何事件,即所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交由父容器进行处理。这种方法和 Android 中的事件分发机制不一致,需要配合 requestDisallowInterceptTouchEvent 方法。具体做法是我们重写子 View 的 dispatchTouchEvent 方法,伪代码如下:

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        //获得当前的位置坐标
        int x = (int) ev.getX();
        int y = (int) ev.getY();
 
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN: 
                //通知父容器不要拦截事件
                panrentLayout.requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                if (父容器需要此事件){
                    //通知父容器拦截此事件
                    parentLayout.requestDisallowInterceptTouchEvent(false);
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
            default:
                break;
        }
 
        //重置手指的初始位置
        mLastY = y;
        mLastX = x; 
        return super.dispatchTouchEvent(ev);
    }

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

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

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

 

五、实例说明


下面通过一个实例来分别介绍这两种写法。我们来实现一个类似于 ViewPager 中嵌套 ListView 的效果,为了制造滑动冲突,我们写一个类似于 ViewPager 的控件即可。为了实现 ViewPager 的效果,我们定义了一个类似于水平的 LinearLayout 的 ViewGroup,只不过它可以水平滑动,初始化时我们在它的内部添加若干个竖直滚动的 ListView,因此一个典型的滑动冲突场景就出现了。根据滑动策略,我们可以选择水平和竖直的滑动距离差来解决滑动冲突。

我们先来看看外部拦截法实现:

public class HorizontalScrollLayout extends ViewGroup {
    private static final String TAG = "HorizontalScrollLayout";
    private int mChildrenSize;
    private int mChildWidth;
    private int mChildIndex;
    // 分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;
    // 分别记录上次滑动的坐标(onInterceptTouchEvent)
    private int mLastXIntercept = 0;
    private int mLastYIntercept = 0;
    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;
 
    public HorizontalScrollLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }
 
    private void init() {
        mScroller = new Scroller(getContext());
        mVelocityTracker = VelocityTracker.obtain();
    }
 
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        return super.dispatchTouchEvent(ev);
    }
 
    @Override
    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;
                if (!mScroller.isFinished()) {
                    //优化滑动体验
                    mScroller.abortAnimation();
                    intercepted = true;
                }
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                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;
            }
            default:
                break;
        }
 
        Log.d(TAG, "intercepted=" + intercepted);
        mLastX = x;
        mLastY = y;
        mLastXIntercept = x;
        mLastYIntercept = 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: {
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                scrollBy(-deltaX, 0);
                break;
            }
            case MotionEvent.ACTION_UP: {
                //根据抬起时的速度判断是否是fling,来自动切换或者归位
                int scrollX = getScrollX();
                int scrollToChildIndex = scrollX / mChildWidth;
                mVelocityTracker.computeCurrentVelocity(1000);
                float xVelocity = mVelocityTracker.getXVelocity();
                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;
                smoothScrollBy(dx, 0);
                mVelocityTracker.clear();
                break;
            }
            default:
                break;
        }
 
        mLastX = x;
        mLastY = y;
        return true;
    }
 
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measuredWidth = 0;
        int measuredHeight = 0;
        final int childCount = getChildCount();
        measureChildren(widthMeasureSpec, heightMeasureSpec);
 
        int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        if (childCount == 0) {
            setMeasuredDimension(0, 0);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(widthSpaceSize, childView.getMeasuredHeight());
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            setMeasuredDimension(measuredWidth, heightSpaceSize);
        } else {
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measuredWidth, measuredHeight);
        }
    }
 
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        final int childCount = getChildCount();
        mChildrenSize = childCount;
 
        for (int i = 0; i < childCount; i++) {
            final View childView = getChildAt(i);
            if (childView.getVisibility() != View.GONE) {
                final int childWidth = childView.getMeasuredWidth();
                mChildWidth = childWidth;
                childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
                childLeft += childWidth;
            }
        }
    }
 
    private void smoothScrollBy(int dx, int dy) {
        mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
        invalidate();
    }
 
    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidate();
        }
    }
 
    @Override
    protected void onDetachedFromWindow() {
        mVelocityTracker.recycle();
        super.onDetachedFromWindow();
    }
}

在滑动过程中,当水平方向的距离大时就判断为水平滑动,为了能够水平滑动所以让父容器拦截事件。而竖直距离大时父容器就不拦截事件,于是事件就传递给了 ListView,所以 ListView 也能上下滑动,如此滑动冲突了。

考虑一种情况,如果此时用户正在水平滑动,但是在水平滑动停止前如果用户再迅速进行竖直滑动,就会导致界面在水平方向无法滑动到终点从而处于一种中间状态。为了避免这种不好的体验,当水平方向正在滑动时,下一个序列的点击事件仍然交给父容器处理,这样水平方向就不会停留在中间状态了。

如果采用内部拦截法也是可以的,按照前面对内部拦截法的分析,我们只需要修改 ListView 的 dispatchTouchEvent 方法中的父容器的拦截逻辑,同时让父容器拦截 ACTION_MOVE 和 ACTION_UP 事件即可。

/**
 * 采用内部拦截法解决滑动冲突
 */
public class MyListView extends ListView {
 
    private final static String TAG = "MyListView";
    //listView的容器
    private HorizontalScrollLayout horizontalScrollLayout;
    //记录上次滑动的位置
    private int mLastX = 0;
    private int mLastY = 0;

    public MyListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
 
    public void setHorizontalScrollLayout(HorizontalScrollLayout horizontalScrollLayout) {
        this.horizontalScrollLayout = horizontalScrollLayout;
    }
 
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        //获得当前的位置坐标
        int x = (int) ev.getX();
        int y = (int) ev.getY();
 
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                //通知父容器不要拦截事件
                horizontalScrollLayout.requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                int delatX = x-mLastX;
                int delatY = y - mLastY;
                if (Math.abs(delatX) > Math.abs(delatY)){
                    //通知父容器拦截此事件
                    horizontalScrollLayout.requestDisallowInterceptTouchEvent(false);
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
            default:
                break;
        }
 
        //重置手指的初始位置
        mLastY = y;
        mLastX = x;
 
        return super.dispatchTouchEvent(ev);
    }
}

除了上面对 ListView 所做的修改,我们还需要修改 HorizontalScrollLayout 的 onInterceptTouchEvent 方法:

/**
 * 内部拦截法
 */
public class HorizontalScrollLayout2 extends ViewGroup {
 
    @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;
        }
    }
}

从实现上来看,内部拦截法要复杂一些,因此推荐采用外部拦截法来解决常见的滑动冲突。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值