Google在LOLLIPOP(SDK21)后加入的嵌套滑動官方解決方案。
1、問題典型場景
通常是信息流(比如社區(qū)、資訊、新聞)頁面或者商品詳情頁的交互設計。
如圖:
分解到代碼就是一般三個控件:一個頭布局,可能是吧banner;一個導航控件;下面一個內容的列表控件。要求頭布局和導航布局在內容布局滑動了一定距離(一般是頭布局的高度加上導航控件的高度)后,導航控件置頂,然后內容列表繼續(xù)滑動。
2、Android事件分發(fā)機制處理問題的痛點
傳統(tǒng)的Android事件分發(fā)是子控件消費了事件,那么父控件就不能再處理這個事件了。也就是說一旦內部的滑動控件消費了滑動操作,外部的滑動控件就不能獲取到這個滑動動作也就無法做處理了。在我們上一個情景里,滑動內容列表控件要求頭布局和導航布局作出響應就是要求他們的共同父布局作出響應,顯然用傳統(tǒng)的事件分發(fā)處理是很困難的。
3、Android嵌套滑動機制基礎概念
嵌套滾動中的兩個接口,在上文中已經提到。NestedScrollingParent和NestedScrollingChild 接口中的方法如下:
NestedScrollingChild
- startNestedScroll : 起始方法, 主要作用是找到接收滑動距離信息的外控件.
- dispatchNestedPreScroll : 在內控件處理滑動前把滑動信息分發(fā)給外控件.
- dispatchNestedScroll : 在內控件處理完滑動后把剩下的滑動距離信息分發(fā)給外控件.
- stopNestedScroll : 結束方法, 主要作用就是清空嵌套滑動的相關狀態(tài)
- setNestedScrollingEnabled和isNestedScrollingEnabled : 一對get&set方法, 用來判斷控件是否支持嵌套滑動.
- dispatchNestedPreFling和dispatchNestedFling : 跟Scroll的對應方法作用類似
NestedScrollingParent
- onStartNestedScroll : 對應startNestedScroll, 內控件通過調用外控件的這個方法來確定外控件是否接收滑動信息.
- onNestedScrollAccepted : 當外控件確定接收滑動信息后該方法被回調, 可以讓外控件針對嵌套滑動做一些前期工作.
- onNestedPreScroll : 關鍵方法, 接收內控件處理滑動前的滑動距離信息, 在這里外控件可以優(yōu)先響應滑動操作, 消耗部分或者全部滑動距離.
- onNestedScroll : 關鍵方法, 接收內控件處理完滑動后的滑動距離信息, 在這里外控件可以選擇是否處理剩余的滑動距離.
- onStopNestedScroll : 對應stopNestedScroll, 用來做一些收尾工作.
- onNestedPreFling和onNestedFling : 同上略
4、嵌套滑動關鍵類源碼分析
子view接受到滾動事件后發(fā)起嵌套滾動,詢問父View是否要先滾動,父View處理了自己的滾動需求后,回到子View處理自己的滾動需求,假如父View消耗了一些滾動距離,子View只能獲取剩下的滾動距離做處理。子View處理了自己的滾動需求后又回到父View,剩下的滾動距離做處理。慣性fling的類似。
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;
}
接下來在RecyclerView的onTouchEvent的 MotionEvent.ACTION_MOVE里調用了dispatchNestedPreScroll和scrollByInternal
case MotionEvent.ACTION_MOVE: {
if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
dx -= mScrollConsumed[0];
dy -= mScrollConsumed[1];
vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
// Updated the nested offsets
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
}
if (mScrollState == SCROLL_STATE_DRAGGING) {
mLastTouchX = x - mScrollOffset[0];
mLastTouchY = y - mScrollOffset[1];
if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
vtev)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
}
} break;
dispatchNestedPreScroll中調了父View的onNestedPreScroll,并且傳入dy 和 consumed。用于做消費計數(shù)。
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow, @NestedScrollType int type) {
if (isNestedScrollingEnabled()) {
final ViewParent parent = getNestedScrollingParentForType(type);
if (parent == null) {
return false;
}
if (dx != 0 || dy != 0) {
??
consumed[0] = 0;
consumed[1] = 0;
ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);
??
return consumed[0] != 0 || consumed[1] != 0;
} else if (offsetInWindow != null) {
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
最終調用了父view的onNestedPreScroll()方法。
依次分析可以看出嵌套滾動執(zhí)行的方法順序如下:
(子)startNestedScroll → (父)onStartNestedScroll → (父)onNestedScrollAccepted→ (子)dispatchNestedPreScroll → (父)onNestedPreScroll→ (子)dispatchNestedScroll→ (父)onNestedScroll→ (子)dispatchNestedPreFling → (父)onNestedPreFling→ (子)dispatchNestedFling → (父)stopNestedScroll
5、嵌套滑動典型案例實踐
關鍵方法就兩個就可以完成效果,只是和僵硬,為了更好的用戶體驗,就需要加入手勢速度的滑動預判:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
mHeaderView.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
mMaxScrollHeight = mHeaderView.getMeasuredHeight() - mHeaderRetainHeight;
//設置主體的高度:代碼中設置match_parent
if (mBodyView.getLayoutParams().height < getMeasuredHeight() - mHeaderRetainHeight) {
mBodyView.getLayoutParams().height = getMeasuredHeight() - mHeaderRetainHeight;
}
setMeasuredDimension(getMeasuredWidth(), mBodyView.getLayoutParams().height + mHeaderView.getMeasuredHeight());
}
在onMeasure()中計算頭部布局和置頂布局高度,完成整個控件的測量,并記下頭部布局去掉置頂布局最大可滑動的距離值。
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
boolean hiddenTop = dy > 0 && getScrollY() < mMaxScrollHeight;
boolean showTop = dy < 0 && getScrollY() > 0 && !ViewCompat.canScrollVertically(target, -1);
if (hiddenTop || showTop) {
scrollBy(0, dy);
consumed[1] = dy;
}
}
然后重寫這個方法就可以實現(xiàn)對應的滑動嵌套,也就是導航欄控件置頂,其實也就是預先知道了導航欄的高度,然后在下滑并且下滑距離大于最大可滑動距離,和上滑并且內容控件不可滑動的時候就全部滑動距離交給父控件也就是實現(xiàn)了NestedScrollParent接口的自己。
最終的demo效果圖如下,就是github的示例項目運行:

相當代碼可以參考下我的github實例:
StickyNestedScrollLayout