本来认为自己对嵌套滑动的理解和应用还是不错的,但是最近做了一个跟手动画的需求,使用嵌套滑动发现了这里有了很多的坑,本文来根据自身的踩坑经历和经验来总结使用嵌套滑动的注意项。
本文不会介绍嵌套滑动的基本使用,不了解的同学可以参考我的文章:Android 源码分析 - 嵌套滑动机制的实现原理。同时,本文嵌套滑动皆以RecyclerView
为例。
1. 不要在onInterceptTouchEvent方法里面拦截事件
如果你有一个ViewGroup作为RecyclerView
的父布局,这个ViewGroup主要来处理一些嵌套滑动的逻辑,比如说使用系统的SwipeRefreshLayout
来做下拉刷新。如果这个ViewGroup
不可能有父布局处理嵌套滑动,那么是否重写onInterceptTouchEvent
可以自身需求来定,比如说SwipeRefreshLayout
就重写了。
但是如果你的业务场景可能还会有ViewGroup来处理嵌套滑动,作为关系链中间的View千万不要重写onInterceptTouchEvent
。
可能有对此有疑惑,现在我以一个具体的场景来解释具体的原因,假设有如下一个场景:
整个事件传递的流程是:首先由
RecyclerView
产生嵌套滑动的事件,然后提交给SwipeRefreshLayout
尝试着处理, SwipeRefreshLayout
收到事件之后,发现还有父View可能会处理,然后在提交给ViewGroup,ViewGroup
根据自身条件选择消费一定的距离,然后又返回给SwipeRefreshLayout
,SwipeRefreshLayout
在根据自身条件选择消费,最后RecyclerView
在消费。整个事件传递和消费的流程如下:这里存在一种特殊情况,如果中间的
SwipeRefreshLayout
重写了onInterceptTouchEvent
方法,导致事件不能传递到RecyclerView
,从而导致了嵌套滑动的机制不能触发。有人可能有人疑问: SwipeRefreshLayout
自己想拦截事件,并且处理事件,这难道有问题吗?
针对这个问题,我想说的是,正常情况下是没有问题的,但是如果ViewGroup
必须跟手变化,只有ViewGroup
跟手变化到最终态才能让 SwipeRefreshLayout
下拉或者RecyclerView
滑动,这种情况下,不走嵌套滑动的逻辑根本没法实现。
可能有人会提出相应的解决方法:我重写ViewGroup
的onInterceptTouchEvent
方法来拦截事件,然后消费事件不行吗?针对于这种解决方法,我想问的是,如果一次滑动产生10px的有效距离,而ViewGroup
只能消费其中的5px,剩下的5px怎么办呢?根据情况传递到子View中去或者不消费?首先不消费是肯定不行的,否则就会显得滑动不灵敏,其次如果传递到子View中去,这也太麻烦了嘛。
像这种情况,我们最好的解决方法就是所有的滑动走嵌套滑动的逻辑,因为嵌套滑动本身自己支持消费部分距离的功能,而不用我们去特殊处理。
解释了在什么情况下不要重写onInterceptTouchEvent
方法之后,我们现在来解释一下系统的SwipeRefreshLayout
为什么要重写onInterceptTouchEvent
。
- Google爸爸默认为
SwipeRefreshLayout
已经嵌套滑动关系链上最后一个View了,SwipeRefreshLayout
不可能再有父View处理嵌套滑动。- 重写
onInterceptTouchEvent
可以为SwipeRefreshLayout
增加一个新特性--就是不用依赖子View就可以实现下拉刷新。也是说,我们在xml布局中直接添加一个SwipeRefreshLayout
,不用给它添加子View就能下拉刷新。这也是嵌套滑动的弊端,必须得有一个View来产生嵌套滑动。
针对于上面两个原因,还是不能说服我坚持的观点--在嵌套滑动链上的View不用重写onInterceptTouchEvent
方法。为什么呢?上面的第二个问题,我们还是可以避免:既然是链上最底端的View,可以完全自己产生嵌套滑动事件,然后尝试着传递到父View,然后自己在消费,而不用去拦截事件。这样的话,整个关系链都不会破坏。所以我对系统的SwipeRefreshLayout
的设计抱有迟疑态度。
2. 不要私自在dispatchTouchEvent的ACTION_CANCEL时机或者ACTION_UP时机调用stopNestedScroll方法
在解释具体原因,我们来看一下NestedScrollingChildHelper
的startNestedScroll
方法和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分为两种:
- TYPE_TOUCH,表示正常滑动,然后手指松开。
- TYPE_NON_TOUCH,表示手指松开之后还在滑动。
所以在RecyclerView
中,一次带fling操作的滑动stopNestedScroll
方法会被调用两次,一次是ACTION_UP
和ACTION_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_UP
和ACTION_CANCEL
调用了stopNestedScroll
方法,切断了它与父View的关系链,但是没有切断它与RecyclerView
的关系链,导致后面再有事件来的话,只能传递到SwipeRefreshLayout
中去,而再也不能传递到SwipeRefreshLayout
的父View上去。
有人说,这没事啊,RecyclerView
也会在ACTION_UP
和ACTION_CANCEL
切断关系啊。但是有没有考虑到一种情况--就是ACTION_UP
和ACTION_CANCEL
事件不能传递到RecylcerView
当中。有很多场景都存在这种情况,比如说我们长按RecyclerView的ItemView然后弹出一个Dialog或者浮层,然后松开,这些都有可能导致事件不能传递到RecyclerView
中去。
我们一旦在ACTION_UP
和ACTION_CANCEL
时切断SwipeRefreshLayout
与父View的关系,但是没有切断RecyclerView
与SwipeRefreshLayout
的关系,整个关系链就变成这样了:
事件传递就变成了这样:
从而会导致一种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:我们假设·SwipeRefreshLayou
在dispathcTouchEvent
方法里面就把传递链给中断)。这就是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
方法里面,做了比较重要两件事:
- 分发
fling
事件。如果我们在处理嵌套滑动,很少会自己处理fling
事件,所以dispatchNestedPreFling
方法通常返回为false,从而进入了if的判断语句中。- 通过
startNestedScroll
方法建立type为TYPE_NON_TOUCH
的嵌套滑动传递链。由于,我们在上层View中没有对type进行判断,所以最终的传递链中会有我们的ViewGroup
。
然后,我们再来看看ViewFlinger
run方法的一段代码:
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. 总结
最后,我在此说几句,嵌套滑动是爸爸给我们的好东西,但是我也们不能乱用,否则出了问题真的是太难找的根本原因了,血的教训啊!!!😭😭