Android滑动冲突的场景及解决方案

滑动冲突的场景

滑动冲突常发生于两个可滑动的控件发生嵌套的情况下。比如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;
        }
        return super.onInterceptTouchEvent(ev);
    }

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

代码32-33行实现了MOVE事件的拦截逻辑。然后观察日志:

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

内部拦截法

下面再讲“内部拦截法”怎么处理滑动冲突。“内部拦截法”跟“外部拦截法”相反,是从内部容器出发去解决冲突。这依赖于ViewParent#requestDisallowInterceptTouchEvent(),看其源码的注释我们很容器知道它是什么意思:

Called when a child does not want this parent and its ancestors to intercept touch events with ViewGroup.onInterceptTouchEvent(MotionEvent).
This parent should pass this call onto its parents. This parent must obey this request for the duration of the touch (that is, only clear the flag after this parent has received an up or a cancel.
Params:
disallowIntercept – True if the child does not want the parent to intercept touch events.

public void requestDisallowInterceptTouchEvent(boolean disallowIntercept);

当子类不想让其父类/祖先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 {
        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;
}

核心代码是4-9行,当disallowIntercept结果为true时,就不会走onInterceptTouchEvent()方法了。
现在我们仍是以上面的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);
    }
}

在27行,调用了requestDisallowInterceptTouchEvent()方法。上面有说到,一旦标记位被设置为true,后续事件序列中的所有事件都不会调用了父类的onInterceptTouchEvent了。因此我们需要视情况将限制放开,如30行的写法就将flag重新设为false。另外还要注意,因为子View是DOWN事件的消费点,那么MOVE事件是不会经过子View的onInterceptTouchEvent方法的,所以在dispatchTouchEvent或者onTouchEvent中都可以设置标记位,唯独不能放到onInterceptTouchEvent方法;
现在我们斜方向滑动内部容器HorizontalScrollViewChild,观察日志:
可以看到红框区域,当dx大于15后,后续的MOVE事件就不会经过ScrollViewParent的onInterceptTouchEvent了,我们要的效果也就达到了。

小结

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

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

来自:https://juejin.cn/post/7168445102984003591

最后

如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。

如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。

相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。

全套视频资料:
一、面试合集

二、源码解析合集

三、开源框架合集

欢迎大家一键三连支持,若需要文中资料,直接点击文末CSDN官方认证微信卡片免费领取

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值