阅读 657

从一次真实经历中说说使用嵌套滑动过程中常见的坑

  本来认为自己对嵌套滑动的理解和应用还是不错的,但是最近做了一个跟手动画的需求,使用嵌套滑动发现了这里有了很多的坑,本文来根据自身的踩坑经历和经验来总结使用嵌套滑动的注意项。

  本文不会介绍嵌套滑动的基本使用,不了解的同学可以参考我的文章:Android 源码分析 - 嵌套滑动机制的实现原理。同时,本文嵌套滑动皆以RecyclerView为例。

1. 不要在onInterceptTouchEvent方法里面拦截事件

  如果你有一个ViewGroup作为RecyclerView的父布局,这个ViewGroup主要来处理一些嵌套滑动的逻辑,比如说使用系统的SwipeRefreshLayout来做下拉刷新。如果这个ViewGroup不可能有父布局处理嵌套滑动,那么是否重写onInterceptTouchEvent可以自身需求来定,比如说SwipeRefreshLayout就重写了。

  但是如果你的业务场景可能还会有ViewGroup来处理嵌套滑动,作为关系链中间的View千万不要重写onInterceptTouchEvent

  可能有对此有疑惑,现在我以一个具体的场景来解释具体的原因,假设有如下一个场景:

  整个事件传递的流程是:首先由RecyclerView产生嵌套滑动的事件,然后提交给SwipeRefreshLayout尝试着处理, SwipeRefreshLayout收到事件之后,发现还有父View可能会处理,然后在提交给ViewGroup,ViewGroup根据自身条件选择消费一定的距离,然后又返回给SwipeRefreshLayoutSwipeRefreshLayout在根据自身条件选择消费,最后RecyclerView在消费。整个事件传递和消费的流程如下:
  这里存在一种特殊情况,如果中间的SwipeRefreshLayout重写了onInterceptTouchEvent方法,导致事件不能传递到RecyclerView,从而导致了嵌套滑动的机制不能触发。有人可能有人疑问: SwipeRefreshLayout自己想拦截事件,并且处理事件,这难道有问题吗?

  针对这个问题,我想说的是,正常情况下是没有问题的,但是如果ViewGroup必须跟手变化,只有ViewGroup跟手变化到最终态才能让 SwipeRefreshLayout下拉或者RecyclerView滑动,这种情况下,不走嵌套滑动的逻辑根本没法实现。

  可能有人会提出相应的解决方法:我重写ViewGrouponInterceptTouchEvent方法来拦截事件,然后消费事件不行吗?针对于这种解决方法,我想问的是,如果一次滑动产生10px的有效距离,而ViewGroup只能消费其中的5px,剩下的5px怎么办呢?根据情况传递到子View中去或者不消费?首先不消费是肯定不行的,否则就会显得滑动不灵敏,其次如果传递到子View中去,这也太麻烦了嘛。

  像这种情况,我们最好的解决方法就是所有的滑动走嵌套滑动的逻辑,因为嵌套滑动本身自己支持消费部分距离的功能,而不用我们去特殊处理。

  解释了在什么情况下不要重写onInterceptTouchEvent方法之后,我们现在来解释一下系统的SwipeRefreshLayout为什么要重写onInterceptTouchEvent

  1. Google爸爸默认为SwipeRefreshLayout已经嵌套滑动关系链上最后一个View了,SwipeRefreshLayout不可能再有父View处理嵌套滑动。
  2. 重写onInterceptTouchEvent可以为SwipeRefreshLayout增加一个新特性--就是不用依赖子View就可以实现下拉刷新。也是说,我们在xml布局中直接添加一个SwipeRefreshLayout,不用给它添加子View就能下拉刷新。这也是嵌套滑动的弊端,必须得有一个View来产生嵌套滑动。

  针对于上面两个原因,还是不能说服我坚持的观点--在嵌套滑动链上的View不用重写onInterceptTouchEvent方法。为什么呢?上面的第二个问题,我们还是可以避免:既然是链上最底端的View,可以完全自己产生嵌套滑动事件,然后尝试着传递到父View,然后自己在消费,而不用去拦截事件。这样的话,整个关系链都不会破坏。所以我对系统的SwipeRefreshLayout的设计抱有迟疑态度。

2. 不要私自在dispatchTouchEvent的ACTION_CANCEL时机或者ACTION_UP时机调用stopNestedScroll方法

  在解释具体原因,我们来看一下NestedScrollingChildHelperstartNestedScroll方法和stopNestedScroll方法。

  stopNestedScroll方法比较简单,我们先来看看

    public void stopNestedScroll(@NestedScrollType int type) {
        ViewParent parent = getNestedScrollingParentForType(type);
        if (parent != null) {
            ViewParentCompat.onStopNestedScroll(parent, mView, type);
            setNestedScrollingParentForType(type, null);
        }
    }
复制代码

  stopNestedScroll表示的意思,当前type的嵌套滑动结束了,这里主要做的是将对应的ViewParent跟重置为null。这里为什么需要强调type呢?通常来说,在正常的滑动中,stopNestedScroll只会被调用一次,但是别忘了还有fling滑动,所以type分为两种:

  1. TYPE_TOUCH,表示正常滑动,然后手指松开。
  2. TYPE_NON_TOUCH,表示手指松开之后还在滑动。

  所以在RecyclerView中,一次带fling操作的滑动stopNestedScroll方法会被调用两次,一次是ACTION_UPACTION_CANCEL调用一次,此时type为TYPE_TOUCH,一次是fling完毕,此时type为TYPE_NON_TOUCH

  那么将ViewParent跟重置为null有什么意义呢?这个就得从startNestedScroll方法得到答案。

    public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
        if (hasNestedScrollingParent(type)) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {
            ViewParent p = mView.getParent();
            View child = mView;
            while (p != null) {
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                    setNestedScrollingParentForType(type, p);
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }
复制代码

  startNestedScroll先从缓存判断是否有View可以处理,然而就是因为这个缓存会导致一个问题。以上面的场景,SwipeRefreshLayout私自在ACTION_UPACTION_CANCEL调用了stopNestedScroll方法,切断了它与父View的关系链,但是没有切断它与RecyclerView的关系链,导致后面再有事件来的话,只能传递到SwipeRefreshLayout中去,而再也不能传递到SwipeRefreshLayout的父View上去。

  有人说,这没事啊,RecyclerView也会在ACTION_UPACTION_CANCEL切断关系啊。但是有没有考虑到一种情况--就是ACTION_UPACTION_CANCEL事件不能传递到RecylcerView当中。有很多场景都存在这种情况,比如说我们长按RecyclerView的ItemView然后弹出一个Dialog或者浮层,然后松开,这些都有可能导致事件不能传递到RecyclerView中去。

  我们一旦在ACTION_UPACTION_CANCEL时切断SwipeRefreshLayout与父View的关系,但是没有切断RecyclerViewSwipeRefreshLayout的关系,整个关系链就变成这样了:

  事件传递就变成了这样:
  从而会导致一种bug,在Dialog或者浮层View消失之后第一次滑动中,ViewGroup不能收到事件,第二次滑动能正常收到。这是为什么呢?因为第一次滑动之后,RecyclerView会调用stopNestedScroll方法;而第二次滑动会重新建立关系,本次关系链就是正常的。

  所以,我们千万不要在ACTION_CANCEL或者ACTION_UP时调用stopNestedScroll方法。研究过RecyclerView源码的同学应该都知道,RecyclerView却调用了,这是为什么呢?

这是因为,在整个嵌套滑动关系链中,RecyclerView只可能是最底层的View,也就是只能产生嵌套滑动,不可能作为关系中间的一员。这一点,我们可以从RecyclerView继承的接口加以证明,RecyclerView只实现了NestedScrollingChild接口,而没有实现NestedScrollingParent接口。

  所以,我们得出一个结论,如下:

一旦一个View实现了NestedScrollingParent接口,不能在ACTION_CANCEL或者ACTION_UP时调用stopNestedScroll方法。说到底就是,谁是startNestedScroll的源头,谁才有资格调用stopNestedScroll

  同时,有人可能会问,如果我们的工程已经这么干了,并且不能修改,或者修改的成本比较大怎么办呢?也是有解决方法的,在这个关系链中,凡是实现了NestedScrollingParent接口的View必须在ACTION_CANCEL或者ACTION_UP时调用stopNestedScroll方法。这种方法会强制RecyclerView在调用startNestedScroll方法时,不走缓存,而是重新建立关系链。有一个小小的弊端,就是fling开始的时候调用startNestedScroll方法时本可以使用缓存的,但是使用此方法之后,会重新建立关系链,性能有所损耗(当然这个性能微乎其微,几乎可以不计😂)。

  但是这种方法还有一个比较严重的缺点,就是从此以后fling事件,不能传递到ViewGroup。这是为什么呢?我们从源码找一下答案:

  首先,RecyclerView是在fling之后切断Type为TYPE_TOUCH的链:

    case MotionEvent.ACTION_UP: {
        mVelocityTracker.addMovement(vtev);
        eventAddedToVelocityTracker = true;
        mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
        final float xvel = canScrollHorizontally
                ? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
        final float yvel = canScrollVertically
                ? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
        if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
           setScrollState(SCROLL_STATE_IDLE);
        }
        resetTouch();
        }
        break;
//----------------------------------------------------------------------------
    private void resetTouch() {
        if (mVelocityTracker != null) {
            mVelocityTracker.clear();
        }
        stopNestedScroll(TYPE_TOUCH);
        releaseGlows();
    }

复制代码

  其次通过在fling方法里面,我们都是通过TYPE_TOUCH的传递链传递事件的:

    public boolean fling(int velocityX, int velocityY) {
        // ·······
        if (!dispatchNestedPreFling(velocityX, velocityY)) {
            final boolean canScroll = canScrollHorizontal || canScrollVertical;
            dispatchNestedFling(velocityX, velocityY, canScroll);
            // ······
        }
        return false;
    }
复制代码

  因为我们在dispathcTouchEvent方法里面就把传递链给中断了,这个中断肯定在fling之前执行,进而导致fling事件只能传递到SwipeRefreshLayou,而不能传递到ViewGroup(Ps:我们假设·SwipeRefreshLayoudispathcTouchEvent方法里面就把传递链给中断)。这就是fling事件传递不过来的根的原因。所以,为了避免各种错误,我们千万不要在私自的调用stopNestedScroll方法。

3. 慎重重写onStartNestedScroll方法

  我们都知道onStartNestedScroll方法是用来标识当前ViewGroup是消费嵌套滑动的事件,但是你们不知道这里面也有坑。这里我以一个例子来解释其中奥妙,同时还会介绍RecyclerView的一个巨坑。

  我相信大家都做过RecyclerView加载更多的功能,如图:

  大家可能直接看这张图有点懵逼,我来解释一下:很多时候,我们使用RecyclerView来实现加载更多的功能,当加载完成之后,就让RecyclerView停在那里不再动,可是一旦我们给RecyclerView套上了一个ViewGroup之后,用来处理嵌套滑动,就会出现这种情况:
  我来解释一下上图中的情况:我们还在加载完成之后,RecyclerView还在继续fling。这种情况是不能容忍的,怎么来解决呢?这就需要正确的重写onStartNestedScroll方法,最简单和正确的方法是我们在重写onStartNestedScroll方法时,必须对type进行判断,代码如下:

  @Override
  public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ViewCompat.ScrollAxis int axes, @ViewCompat.NestedScrollType int type) {
    return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0 && type == ViewCompat.TYPE_TOUCH;
  }
复制代码

  我们在onStartNestedScroll方法对type进行了判断,这也是我们重写onStartNestedScroll方法时非常容易忽视的点。

  问题倒是解决了,可是大家肯定好奇为什么会出现这种情况,同时为什么加了type的判断就能解决呢?

  首先,我先来解释一下为什么会这种情况,其实答案是非常的简单,在加载完成过程中,ViewFlinger还在继续fling,当数据回来时,此时fling事件还未完成,新数据加载到RecyclerView中去,ViewFlinger发现此时已经有空间可以滑动了,那么就会继续滑动。我自己觉得这是RecyclerView挖的一个坑。

  其次,我们来看一下,为什么加上type判断就能解决问题呢?我们从RecyclerView的fling方法寻找答案:

    public boolean fling(int velocityX, int velocityY) {
        // ······
        // 1. 分发fling
        if (!dispatchNestedPreFling(velocityX, velocityY)) {
            final boolean canScroll = canScrollHorizontal || canScrollVertical;
            dispatchNestedFling(velocityX, velocityY, canScroll);
            // ······

            if (canScroll) {
                int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
                if (canScrollHorizontal) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
                }
                if (canScrollVertical) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
                }
                // 2. 建立type为TYPE_NON_TOUCH的传递链
                startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);

                velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
                velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
                mViewFlinger.fling(velocityX, velocityY);
                return true;
            }
        }
        return false;
    }
复制代码

  在fling方法里面,做了比较重要两件事:

  1. 分发fling事件。如果我们在处理嵌套滑动,很少会自己处理fling事件,所以dispatchNestedPreFling方法通常返回为false,从而进入了if的判断语句中。
  2. 通过startNestedScroll方法建立type为TYPE_NON_TOUCH的嵌套滑动传递链。由于,我们在上层View中没有对type进行判断,所以最终的传递链中会有我们的ViewGroup

  然后,我们再来看看ViewFlingerrun方法的一段代码:

                if (!dispatchNestedScroll(hresult, vresult, overscrollX, overscrollY, null,
                        TYPE_NON_TOUCH)
                        && (overscrollX != 0 || overscrollY != 0)) {
                    final int vel = (int) scroller.getCurrVelocity();

                    int velX = 0;
                    if (overscrollX != x) {
                        velX = overscrollX < 0 ? -vel : overscrollX > 0 ? vel : 0;
                    }

                    int velY = 0;
                    if (overscrollY != y) {
                        velY = overscrollY < 0 ? -vel : overscrollY > 0 ? vel : 0;
                    }

                    if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
                        absorbGlows(velX, velY);
                    }
                    if ((velX != 0 || overscrollX == x || scroller.getFinalX() == 0)
                            && (velY != 0 || overscrollY == y || scroller.getFinalY() == 0)) {
                        scroller.abortAnimation();
                    }
                }
复制代码

  这段代码中的作用就是,当fling的速度为0时或者滑动的距离为0时,会通过abortAnimation来中断后面的fling。因为我们在startNestedScroll成功的建立传递链,所以在这里dispatchNestedScroll肯定为true,所以永远走不到这段逻辑,最终就会导致上面出现的那个问题。

  而我们在我们ViewGroup的onStartNestedScroll方法对type加上了判断,在建立的传递链中不会有我们得ViewGroup,所以dispatchNestedScroll方法就会返回为false,在滑不动时,自然就会中断未完成的fling。最终我们证实了上面的解决方法为什么是正确的,而不是通过一种hack方式来实现。

到此,我就对此坑的分析就结束了。综上所述,我们在重写onStartNestedScroll方法一定要小心,一定要考虑到type为TYPE_NON_TOUCH的情况。

4. 总结

  最后,我在此说几句,嵌套滑动是爸爸给我们的好东西,但是我也们不能乱用,否则出了问题真的是太难找到根本原因了,血的教训啊!!!😭😭