滑动冲突简要分析

滑动冲突的场景

滑动冲突常发生于两个可滑动的控件发生嵌套的情况下。比如RecyclerView嵌套ListView,RecyclerView嵌套ScrollView,ViewPager嵌套RecyclerView等。ViewPager之所以没有滑动冲突是因为它本身就已经帮我们解决掉了。但其它没帮我们处理的情况就需要我们自己写代码去处理。
典型的,根据两个控件的滑动方向,可以将滑动冲突分成两类:一个是不同方向的滑动冲突,如外层控件垂直滑动,内层控件水平滑动。另一个就是同方向的滑动冲突,如内外两层控件都是垂直滑动。
下面举一个不同方向滑动冲突的例子。父容器ScrollViewParent,嵌套HorizontalScrollViewChild,ScrollViewParent可垂直滑动,HorizontalScrollViewChild可水平滑动。

当我们手指放在HorizontalScrollViewChild的区域内并竖直滑动时,我们发现是可以滚动外层的ScrollViewParent的。说明ScrollView本身是解决了部分的滑动冲突的,否则HorizontalScrollViewChild如果消费了MOVE事件,ScrollViewParent就消费不了了,也就无法竖直滑动。观察日志:
日志最终我们可以看到,在绿色处,HorizontalScrollViewChild是有消费MOVE事件的,那之前不是讲错了吗?既然HorizontalScrollViewChild有消费MOVE事件,为啥ScrollViewParent还能滑动呢?因为在刚开始滑动的时候,滑动的距离还太小,因此ScrollViewParent的onInterceptTouchEvent还没有拦截这个事件,所以HorizontalScrollViewChild可以消费到MOVE事件。但后面一旦垂直滑动了一定距离,MOVE事件就会直接被ScrollViewParent消费掉,从而实现竖直滑动。
我们可以看ScrollViewParent的源码佐证一下:

final int y = (int) ev.getY(pointerIndex);
final int yDiff = Math.abs(y - mLastMotionY);
if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) {//关键
    mIsBeingDragged = true;
    mLastMotionY = y;
    initVelocityTrackerIfNotExists();
    mVelocityTracker.addMovement(ev);
    mNestedYOffset = 0;
    if (mScrollStrictSpan == null) {
        mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll");
    }
    final ViewParent parent = getParent();
    if (parent != null) {
        parent.requestDisallowInterceptTouchEvent(true);
    }
}

可以看到第3行,只有当yDiff大于一定滑动距离即mTouchSlop时,才会被认定为在垂直方向上滑动,将mIsBeingDragged设置为true。若我们水平滑动HorizontalScrollViewChild,可以断定,因为yDiff是没有超过mTouchSlop的,所以HorizontalScrollViewChild就可以正常滑动了。观察日志:
可以看到,水平滑动HorizontalScrollViewChild时,ScrollViewParent没有在onInterceptTouchEvent拦截MOVE事件,MOVE事件得以顺利被HorizontalScrollViewChild消费,实现水平滑动。
那看样子,两个ScrollView并不冲突呀,他们都已经写好内部逻辑了。其实不然。假设我们现在手指放在HorizontalScrollViewChild区域中,滑斜上方45角度向左/向右滑动,这时候我们就会发现,有时候我们滑动的是HorizontalScrollViewChild,有时候我们滑动的却是ScrollViewParent,这就是典型的不同方向上的滑动冲突。

通用解决方案

外部拦截法

外部拦截法,指的是从外部容器入手,去决定是否要去拦截事件,若拦截掉,子View就没法消费了。现在为了处理斜方向的滑动冲突,我们可以简单地做一个逻辑:当在竖直方向滑动超过15像素时,我们就认为是滑动外部容器ScrollViewParent。代码如下:

public class ScrollViewParent extends ScrollView {

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

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

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

    private float downX;
    private float downY;

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        Log.i("touch_event_test", "ScrollViewParent dispatchTouchEvent");
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downX = ev.getX();
                downY = ev.getY();
        }
        return super.dispatchTouchEvent(ev);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        Log.i("touch_event_test", "ScrollViewParent onInterceptTouchEvent. deltaY:" + (ev.getY() - downY));
        if (ev.getAction() == MotionEvent.ACTION_MOVE) {
            return Math.abs(ev.getY() - downY) > 15;//move事件的拦截逻辑
        }
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        Log.i("touch_event_test", "ScrollViewParent onTouchEvent");
        return super.onTouchEvent(ev);
    }
}

观察日志,可以看到此时ScrollViewParent会拦截MOVE事件,直接分发给自己的onTouchEvent,后续的MOVE事件也会直接到onTouchEvent事件去。HorizontalScrollViewChild将收不到MOVE事件。实现效果上也符合我们的预期,当我们斜方向滑动时,滑动的大概率(除非dy足够小)都是外部容器ScrollViewParent了。
当然,这其实是我们假定的一个处理冲突的逻辑,真实的产品逻辑需要根据业务情况去调整。比如当竖直滑动速度超过xx时,滑动外部容器;或者当HorizontalScrollViewChild内部某个View可见时,滑动外部容器,都有可能,但万变不离其宗,最终都是通过改变事件分发的路径去实现。

内部拦截法

其实我没太看懂。。。。

“内部拦截法”跟“外部拦截法”相反,是从内部容器出发去解决冲突。当子类不想让其父类/祖先ViewGroup.onInterceptTouchEvent(MotionEvent)方法时,可以调用requestDisallowInterceptTouchEvent(),从而保证父类/祖先无法通过ViewGroup.onInterceptTouchEvent(MotionEvent)方法对事件进行拦截。且一旦设置了这个flag,那么这次事件序列中后续的所有事件,都不会经过父类/祖先的onInterceptTouchEvent(MotionEvent)方法了。直到下次触摸发生,才会清楚掉这个flag。
我们可以直接看源码,看看这是怎么起作用的。requestDisallowInterceptTouchEvent实现:

@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {

    if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
        // We're already in this state, assume our ancestors are too
        return;
    }

    if (disallowIntercept) {//更改标记位
        mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
    } else {
        mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    }

    // Pass it up to our parent
    if (mParent != null) {
        mParent.requestDisallowInterceptTouchEvent(disallowIntercept);//递归访问父节点执行该方法
    }
}

在View的dispatchTouchEvent方法中,对标记位进行了判断:

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 {//当disallowIntercept结果为true时,就不会走onInterceptTouchEvent()方法了
        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;
}

现在我们仍是以上面的ScrollViewParent和HorizontalScrollViewChild举例去解决滑动冲突。“内部”要求我们以内部容器的视角去考虑冲突,那么就假定当在水平方向滑动超过15像素时,滑动内部容器HorizontalScrollViewChild。这时候就需要这样编码:

public class HorizontalScrollViewChild extends HorizontalScrollView {

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

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

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

    private float downX;
    private float downY;

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        Log.i("touch_event_test", "HorizontalScrollViewChild dispatchTouchEvent. deltaX:" + Math.abs(ev.getX() - downX));
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downX = ev.getX();
                downY = ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                if (Math.abs(ev.getX() - downX) > 15) {
                    //关键
                    getParent().requestDisallowInterceptTouchEvent(true);
                } else {
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
        }
        return super.dispatchTouchEvent(ev);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        Log.i("touch_event_test", "HorizontalScrollViewChild onInterceptTouchEvent");
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        Log.i("touch_event_test", "HorizontalScrollViewChild onTouchEvent");
        return super.onTouchEvent(ev);
    }
}

一旦标记位被设置为true,后续事件序列中的所有事件都不会调用了父类的onInterceptTouchEvent了。因此我们需要视情况将限制放开,如30行的写法就将flag重新设为false。另外还要注意,因为子View是DOWN事件的消费点,那么MOVE事件是不会经过子View的onInterceptTouchEvent方法的,所以在dispatchTouchEvent或者onTouchEvent中都可以设置标记位,唯独不能放到onInterceptTouchEvent

小结

虽然上面只举了不同方向滑动冲突的例子,但不同方向冲突与同方向冲突,其最终处理的思路都是一样的,要么改变事件分发的路径,要么设置FLAG_DISALLOW_INTERCEPT标记位。它们在解决上的不同之处,就只在于“条件判断” 。
对于不同方向的冲突,我们给的条件判断可以是:当DX>X时,水平滑动、当DY>Y时,竖直滑动。
对于同方向的冲突,我们给的条件判断可以是:当滑动速度大于X时,滑动外部View,反之滑动内部View;当内部View已经滑到底了,才滑动外部View;当内部View中的某个子View不在屏幕中时,滑动外部View。
一般这些条件判断也都是基于各自的业务出发去进行选择,但只要我们知道通用的处理思路,问题就都可以迎刃而解了。至此,对滑动冲突的通用处理方法(“内部拦截法”与“外部拦截法”)就讲完了。下面进行个小结:

“外部拦截法”所使用的原理是运用事件分发机制,去改变事件分发的路径,拦截内部容器的事件。
“内部拦截法”使用的是requestDisallowInterceptTouchEvent()方法设置FLAG,不让父容器/祖先容器用onInterceptTouchEvent拦截方法。
使用“内部拦截法”还是“外部拦截法”,首先需要去看实际业务需要我们怎么做,是从“内部”实现比较方便,还是从“外部”实现比较方便。
相较于“外部拦截法”,“内部拦截法”并没有减少事件分发的层级,因此看起来可能会更加复杂一些。并且也需要注意requestDisallowInterceptTouchEvent方法具体在哪个方法中使用。若两个方法都能实现最终的效果,建议优先使用“外部拦截法”。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值