原文作者:Alex Lockwood
原文地址: Experimenting with Nested Scrolling
Demo: https://github.com/alexjlockwood/adp-nested-scrolling
從API 21開始,support庫提供了一套處理嵌套滑動的API(以下簡稱NS),用于可滑動的父布局可以嵌套可滑動的子View,從而實現(xiàn) Material Design提供的一些列滑動效果(效果集合傳送門)。如圖1效果,就是使用了CoordinatorLayout和NestedScrollView,

如果沒有nested scrolling,NestedScrollView的滑動將不能和其他空間的效果融為一體;使用nested scrolling,CoordinatorLayout和NestedScrollView輪流攔截和消費滑動事件,也使得‘collapsing toolbar’ 的效果看起來更加連貫, 如圖2。

那么,NS是如何工作的呢?首先,父布局需要實現(xiàn)NestedScrollingParent,子View需要實現(xiàn)NestedScrollChild,如圖3所示,以NestedScrollView(以下簡稱NSV)和RecyclerView(以下簡稱RV)為例:

NSV嵌套RV,如果沒有嵌套滑動,RV會攔截并消費掉滑動事件,這顯然不是我們想要的,我們希望一次滑動事件能同時作用于兩個View,也就是說
- 如果RV滑動到最頂部即沒有滑動的初始狀態(tài),那么RV的向上的滑動事件要作用于NSV,使NSV向上滑動。
- 如果NSV沒有滑動到底部,那么RV向下的滑動事件要作用于NSV,使NSV向下滑動。
NS提供了一種方式,讓NSV和RV之間可以傳遞所有的滑動事件,每一個View自己來決定是否消費滑動事件,當需要處理一系列的MotionEvents和復(fù)雜的用戶場景時,使用NS更加清晰簡單。
NS的工作過程:
- RV的 onTouchEvent(ACTINON_MOVE)會被調(diào)用
- RV調(diào)用dispatchNestedPreScroll(),通知NSV即將要消費一部分滑動事件
- NSV的onNestedPreScroll會被調(diào)用,使得NSV有機會在RV消費掉滑動事件之前對該事件作出響應(yīng)。
- RV消費剩余的滑動事件,NSV消費了整個事件的話,RV將不做處理
- RV調(diào)用自身的dispatchNestedScroll()方法,通知NSV它消費了一部分滑動事件
- NSV的onNestedScroll()方法被調(diào)用,NSV有機會去消費剩余未被消費的滑動事件
- RV的onTouchEvent(ACTINON_MOVE) return true,消費掉touch事件
然鵝,但是,Unfortunately,簡單的使用NSV和RV并不能滿足我們的需求,如圖4所示,簡單使用NSV和RV存在兩個問題:
- 左邊的RV在不應(yīng)當消費滑動事件的時候消費了滑動事件,NSV還沒有滑動到底部,RV就開始滑動了。
- 右邊RV的fling事件沒有繼續(xù)傳遞給父控件,使得頂部的空間展開和折疊非常生硬。

我們在了解了NestScrolling是如何工作的以后,修復(fù)這兩個問題就比較簡單了。我們只需要創(chuàng)建一個CustomNestedScrollView通過重寫onNestedPreScroll()和onNestedPreFling()方法來修正滑動效果。
/**
* A NestedScrollView with our custom nested scrolling behavior.
*/
public class CustomNestedScrollView extends NestedScrollView {
/* NestedScrollView 在一下兩種情況中將攔截scroll/fling事件:
(1) RecyclerView已經(jīng)滑動到頂部,用戶手指繼續(xù)向下滑動
(2) NestedScrollView已經(jīng)滑動到底部,用戶手指繼續(xù)向上滑動*/
@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,這里又出現(xiàn)了一個新的bug如圖5所示:左邊部分RecyclerView fling到頂部的時候的fling事件被中斷了,我們想要的是右邊的效果,可以順暢的fling下來。

問題的關(guān)鍵在于,support庫中并沒有提供方法,能讓NestedScrolling中的子View把剩余的fling的速率傳遞給父布局。這個問題Chris Banes已經(jīng)給出了詳細的解釋并給出了解決方案,博客傳送門,這里就不再贅述了??偟膩碚f,我們需要讓我們的父布局和子View去實現(xiàn)新的接口—— NestedScrollingParent2 和 NestedScrollingChild2,這兩個接口在v26的support庫中添加。由于NestedScrollView依然是實現(xiàn)的NestedScrollingParent,我們需要繼承NestedScrollView2并實現(xiàn) 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已經(jīng)默認幫我們實現(xiàn)了我們想要的效果。
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小結(jié)
按照時間線對Nest Scrolling 進行一個小結(jié)(v25):
(按在子View上)
- 用戶接觸屏幕,產(chǎn)生ACTIION_DOWN事件,子View會調(diào)用所有的父布局的 startNestedScroll()方法,直到某一個父布局的改方法返回了true;如過所有的度不去都返回false,子View就正常該干嘛干嘛了,不再分發(fā)滑動事件。接下的內(nèi)容,我們都假定父布局的startNestedScroll()方法返回了true
-
用戶手指移動,產(chǎn)生ACTION_MOVE事件 dispatchNestedPreScroll() 方法會被調(diào)用,父布局在這個方法中去決定此次滑動事件消費不消費,消費多少,
刷卡還是現(xiàn)金,,如果父布局沒有消費掉所有的滑動動作,那么子View會獲取到剩余的滑動動作,并把該值傳入 dispatchNestedScroll() 方法,調(diào)用此方法來消費滑動剩余價值。 - 用戶手指離開屏幕,產(chǎn)生ACTION_UP事件 子View 計算是否需要 fling ,如果需要 fling,則調(diào)用 dispatchNestedPreFling() ,先詢問父布局是否要處理,然后調(diào)用 dispatchNestedFling(), 如果父類返回 true 那么父布局就消費掉此次事件,子View不再做任何事。否則,子View將fling,然后立即調(diào)用 dispatchNestedFling()。接下來,即使子View還在fling,也會立即調(diào)用 stopNestedScroll(),標記嵌套滑動已完成。
最后一點是關(guān)鍵,其實父布局有時候并不想消費掉整個fling事件,也想想分發(fā)scroll一樣,分發(fā)掉fling,但v25及以下的的support庫中并不支持。
Nested Scrolling 加強版(v26):
新的api已經(jīng)修復(fù)了上述問題:在新的api中在每一個方法中增加了一個type參數(shù),type有兩個值:ViewCompat.TYPE_TOUCH 和 ViewCompat.TYPE_NON_TOUCH, 根據(jù) type 的值,我們可以對不同的行為做出不同的處理。
實際上我們大多數(shù)時候不需要關(guān)心這個type的值,按需處理滾動就好了。