利用 Android 巢狀滑動機制輕鬆實現頂部佈局置頂
code小生,一個專注 Android 領域的技術平臺
作者:蟬翅的空響
連結:https://www.jianshu.com/p/5966d1b2d1ce
宣告:本文已獲蟬翅的空響
授權發表,轉發等請聯絡原作者授權
Google 在 LOLLIPOP(SDK21)後加入的巢狀滑動官方解決方案。
1、問題典型場景
通常是資訊流(比如社群、資訊、新聞)頁面或者商品詳情頁的互動設計。
如圖:
分解到程式碼就是一般三個控制元件:一個頭佈局,可能是吧banner;一個導航控制元件;下面一個內容的列表控制元件。要求頭佈局和導航佈局在內容佈局滑動了一定距離(一般是頭佈局的高度加上導航控制元件的高度)後,導航控制元件置頂,然後內容列表繼續滑動。
2、Android事件分發機制處理問題的痛點
傳統的Android事件分發是子控制元件消費了事件,那麼父控制元件就不能再處理這個事件了。也就是說一旦內部的滑動控制元件消費了滑動操作,外部的滑動控制元件就不能獲取到這個滑動動作也就無法做處理了。在我們上一個情景裡,滑動內容列表控制元件要求頭佈局和導航佈局作出響應就是要求他們的共同父佈局作出響應,顯然用傳統的事件分發處理是很困難的。
3、Android巢狀滑動機制基礎概念
巢狀滾動中的兩個介面,在上文中已經提到。NestedScrollingParent和NestedScrollingChild 介面中的方法如下:
NestedScrollingChild
startNestedScroll : 起始方法, 主要作用是找到接收滑動距離資訊的外控制元件.
dispatchNestedPreScroll : 在內控制元件處理滑動前把滑動資訊分發給外控制元件.
dispatchNestedScroll : 在內控制元件處理完滑動後把剩下的滑動距離資訊分發給外控制元件.
stopNestedScroll : 結束方法, 主要作用就是清空巢狀滑動的相關狀態
setNestedScrollingEnabled和isNestedScrollingEnabled : 一對get&set方法, 用來判斷控制元件是否支援巢狀滑動.
dispatchNestedPreFling和dispatchNestedFling : 跟Scroll的對應方法作用類似
NestedScrollingParent
onStartNestedScroll
: 對應startNestedScroll, 內控制元件通過呼叫外控制元件的這個方法來確定外控制元件是否接收滑動資訊.onNestedScrollAccepted
: 當外控制元件確定接收滑動資訊後該方法被回撥, 可以讓外控制元件針對巢狀滑動做一些前期工作.onNestedPreScroll
: 關鍵方法, 接收內控制元件處理滑動前的滑動距離資訊, 在這裡外控制元件可以優先響應滑動操作, 消耗部分或者全部滑動距離.onNestedScroll
: 關鍵方法, 接收內控制元件處理完滑動後的滑動距離資訊, 在這裡外控制元件可以選擇是否處理剩餘的滑動距離.onStopNestedScroll
: 對應stopNestedScroll, 用來做一些收尾工作.onNestedPreFling和onNestedFling
: 同上略
4、巢狀滑動關鍵類原始碼分析
子view接受到滾動事件後發起巢狀滾動,詢問父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。用於做消費計數。
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() 方法。
依次分析可以看出巢狀滾動執行的方法順序如下:
(子)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;
}
}
然後重寫這個方法就可以實現對應的滑動巢狀,也就是導航欄控制元件置頂,其實也就是預先知道了導航欄的高度,然後在下滑並且下滑距離大於最大可滑動距離,和上滑並且內容控制元件不可滑動的時候就全部滑動距離交給父控制元件也就是實現了NestedScrollParent介面的自己。
相當程式碼可以參考下我的github例項:
https://github.com/MicalLannister/RecyclerViewDemo/blob/master/app/src/main/java/com/cq/lannister/recyclerviewdome/widget/StickyNestedScrollLayout.java
參考:
Android NestedScrolling機制完全解析 帶你玩轉巢狀滑動
https://blog.csdn.net/lmj623565791/article/details/52204039
巢狀滾動設計和原始碼分析
https://juejin.im/post/5ac35b826fb9a028cb2dd341
分享技術我是認真的