原文作者:Alex Lockwood
原文地址: Experimenting with Nested Scrolling
Demo: https://github.com/alexjlockwood/adp-nested-scrolling
从API 21开始,support库提供了一套处理嵌套滑动的API(以下简称NS),用于可滑动的父布局可以嵌套可滑动的子View,从而实现 Material Design提供的一些列滑动效果(效果集合传送门)。如图1效果,就是使用了CoordinatorLayout和NestedScrollView,
如果没有nested scrolling,NestedScrollView的滑动将不能和其他空间的效果融为一体;使用nested scrolling,CoordinatorLayout和NestedScrollView轮流拦截和消费滑动事件,也使得‘collapsing toolbar’ 的效果看起来更加连贯, 如图2。
那么,NS是如何工作的呢?首先,父布局需要实现NestedScrollingParent,子View需要实现NestedScrollChild,如图3所示,以NestedScrollView(以下简称NSV)和RecyclerView(以下简称RV)为例:
NSV嵌套RV,如果没有嵌套滑动,RV会拦截并消费掉滑动事件,这显然不是我们想要的,我们希望一次滑动事件能同时作用于两个View,也就是说
- 如果RV滑动到最顶部即没有滑动的初始状态,那么RV的向上的滑动事件要作用于NSV,使NSV向上滑动。
- 如果NSV没有滑动到底部,那么RV向下的滑动事件要作用于NSV,使NSV向下滑动。
NS提供了一种方式,让NSV和RV之间可以传递所有的滑动事件,每一个View自己来决定是否消费滑动事件,当需要处理一系列的MotionEvents和复杂的用户场景时,使用NS更加清晰简单。
NS的工作过程:
- RV的 onTouchEvent(ACTINON_MOVE)会被调用
- RV调用dispatchNestedPreScroll(),通知NSV即将要消费一部分滑动事件
- NSV的onNestedPreScroll会被调用,使得NSV有机会在RV消费掉滑动事件之前对该事件作出响应。
- RV消费剩余的滑动事件,NSV消费了整个事件的话,RV将不做处理
- RV调用自身的dispatchNestedScroll()方法,通知NSV它消费了一部分滑动事件
- NSV的onNestedScroll()方法被调用,NSV有机会去消费剩余未被消费的滑动事件
- RV的onTouchEvent(ACTINON_MOVE) return true,消费掉touch事件
然鹅,但是,Unfortunately,简单的使用NSV和RV并不能满足我们的需求,如图4所示,简单使用NSV和RV存在两个问题:
- 左边的RV在不应当消费滑动事件的时候消费了滑动事件,NSV还没有滑动到底部,RV就开始滑动了。
- 右边RV的fling事件没有继续传递给父控件,使得顶部的空间展开和折叠非常生硬。
我们在了解了NestScrolling是如何工作的以后,修复这两个问题就比较简单了。我们只需要创建一个CustomNestedScrollView通过重写onNestedPreScroll()和onNestedPreFling()方法来修正滑动效果。
/**
* A NestedScrollView with our custom nested scrolling behavior.
*/
public class CustomNestedScrollView extends NestedScrollView {
/* NestedScrollView 在一下两种情况中将拦截scroll/fling事件:
(1) RecyclerView已经滑动到顶部,用户手指继续向下滑动
(2) NestedScrollView已经滑动到底部,用户手指继续向上滑动*/
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
final RecyclerView rv = (RecyclerView) target;
if ((dy < 0 && isRvScrolledToTop(rv)) || (dy > 0 && !isNsvScrolledToBottom(this))) {
// 滑动NestedScrollView并且标记滑动距离,
// 这样RecyclerView就可以知道有多少滑动距离是不用去处理的
scrollBy(0, dy);
// consumed[0]表示横向滑动, consumed[1]表示纵向滑动
consumed[1] = dy;
return;
}
super.onNestedPreScroll(target, dx, dy, consumed);
}
@Override
public boolean onNestedPreFling(View target, float velX, float velY) {
final RecyclerView rv = (RecyclerView) target;
if ((velY < 0 && isRvScrolledToTop(rv)) || (velY > 0 && !isNsvScrolledToBottom(this))) {
// 处理NestedScrollView的fling,并return true,
同样的RecyclerView也会收到通知,不用处理这次的Fling事件了
fling((int) velY);
return true;
}
return super.onNestedPreFling(target, velX, velY);
}
/**
* 判断NestedScrollView是否滑动到底部。
*
* @return NestedScrollView 滑动到底部的时候return true
* 即RecyclerView完全可见的时候return true
*/
private static boolean isNsvScrolledToBottom(NestedScrollView nsv) {
return !nsv.canScrollVertically(1);
}
/**
* 判断RecyclerView是否滑动到顶部
*
* @return RecyclerView 滑动到顶部的的时候return true,
* 即RecyclerView的第一个item完全可见的时候return true。
*/
private static boolean isRvScrolledToTop(RecyclerView rv) {
final LinearLayoutManager lm = (LinearLayoutManager) rv.getLayoutManager();
return lm.findFirstVisibleItemPosition() == 0
&& lm.findViewByPosition(0).getTop() == 0;
}
}
哎呀,好像解决了!然鹅,但是,Unfortunately,这里又出现了一个新的bug如图5所示:左边部分RecyclerView fling到顶部的时候的fling事件被中断了,我们想要的是右边的效果,可以顺畅的fling下来。
问题的关键在于,support库中并没有提供方法,能让NestedScrolling中的子View把剩余的fling的速率传递给父布局。这个问题Chris Banes已经给出了详细的解释并给出了解决方案,博客传送门,这里就不再赘述了。总的来说,我们需要让我们的父布局和子View去实现新的接口—— NestedScrollingParent2 和 NestedScrollingChild2,这两个接口在v26的support库中添加。由于NestedScrollView依然是实现的NestedScrollingParent,我们需要继承NestedScrollView2并实现 NestedScrollingParent2 ,代码如下:
public class CustomNestedScrollView2 extends NestedScrollView2 {
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type) {
final RecyclerView rv = (RecyclerView) target;
if ((dy < 0 && isRvScrolledToTop(rv)) || (dy > 0 && !isNsvScrolledToBottom(this))) {
scrollBy(0, dy);
consumed[1] = dy;
return;
}
super.onNestedPreScroll(target, dx, dy, consumed, type);
}
// 我们不需要重写 onNestedPreFling() ,新的API已经默认帮我们实现了我们想要的效果。
private static boolean isNsvScrolledToBottom(NestedScrollView nsv) {
return !nsv.canScrollVertically(1);
}
private static boolean isRvScrolledToTop(RecyclerView rv) {
final LinearLayoutManager lm = (LinearLayoutManager) rv.getLayoutManager();
return lm.findFirstVisibleItemPosition() == 0
&& lm.findViewByPosition(0).getTop() == 0;
}
}
chenxi小结
按照时间线对Nest Scrolling 进行一个小结(v25):
(按在子View上)
- 用户接触屏幕,产生ACTIION_DOWN事件,子View会调用所有的父布局的 startNestedScroll()方法,直到某一个父布局的改方法返回了true;如过所有的度不去都返回false,子View就正常该干嘛干嘛了,不再分发滑动事件。接下的内容,我们都假定父布局的startNestedScroll()方法返回了true
-
用户手指移动,产生ACTION_MOVE事件 dispatchNestedPreScroll() 方法会被调用,父布局在这个方法中去决定此次滑动事件消费不消费,消费多少,
刷卡还是现金,,如果父布局没有消费掉所有的滑动动作,那么子View会获取到剩余的滑动动作,并把该值传入 dispatchNestedScroll() 方法,调用此方法来消费滑动剩余价值。 - 用户手指离开屏幕,产生ACTION_UP事件 子View 计算是否需要 fling ,如果需要 fling,则调用 dispatchNestedPreFling() ,先询问父布局是否要处理,然后调用 dispatchNestedFling(), 如果父类返回 true 那么父布局就消费掉此次事件,子View不再做任何事。否则,子View将fling,然后立即调用 dispatchNestedFling()。接下来,即使子View还在fling,也会立即调用 stopNestedScroll(),标记嵌套滑动已完成。
最后一点是关键,其实父布局有时候并不想消费掉整个fling事件,也想想分发scroll一样,分发掉fling,但v25及以下的的support库中并不支持。
Nested Scrolling 加强版(v26):
新的api已经修复了上述问题:在新的api中在每一个方法中增加了一个type参数,type有两个值:ViewCompat.TYPE_TOUCH 和 ViewCompat.TYPE_NON_TOUCH, 根据 type 的值,我们可以对不同的行为做出不同的处理。
实际上我们大多数时候不需要关心这个type的值,按需处理滚动就好了。