概述
本文主要分享使用NestedScrollView嵌套RecyclerView實現(xiàn)仿京東Tab吸頂效果,先來看一下效果圖:

實現(xiàn)要點
- Tab控件如何吸頂
- 如何實現(xiàn)嵌套滾動,即父view可以滾動的情況下子view也可以滾動
- 如何實現(xiàn)慣性滑動
Tab控件吸頂
先看一下布局結構:
<com.fmt.conflictproject.view.NestedScrollLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.fmt.conflictproject.view.ConflictRecyclerView
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tablayout"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewpager_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</LinearLayout>
</com.fmt.conflictproject.view.NestedScrollLayout>
布局中使用了LinearLayout包裹TabLayout與ViewPager2作為內容控件,那將LinearLayout的高度設置為NestedScrollView的高度即可實現(xiàn)TabLayout吸頂效果,本質上是NestedScrollView滑到底了,所以TabLayout自然就吸頂了,代碼如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
ViewGroup.LayoutParams layoutParams = mContentView.getLayoutParams();
layoutParams.height = getMeasuredHeight();
mContentView.setLayoutParams(layoutParams);
}
如何實現(xiàn)嵌套滾動
嵌套滾動的兩個角色:NestedScrollingParent3與NestedScrollingChild3,由NestedScrollingChild3觸發(fā)嵌套滾動事件,這里采用NestedScrollView嵌套RecyclerView的實現(xiàn)方法,而NestedScrollView與RecyclerView分別實現(xiàn)了NestedScrollingParent3與NestedScrollingChild3
但需要注意,當使用NestedScrollView嵌套RecyclerView并將內容控件的高度設置為NestedScrollView的高度后,會出現(xiàn)一個奇怪的現(xiàn)象如下:
可以發(fā)現(xiàn),在滑動RecyclerView時并沒有先讓NestedScrollView滾動到頂部后,然后RecyclerView在滑動,那是什么原因造成的呢?先來看一下嵌套滾動的大致流程圖:

從流程圖可以發(fā)現(xiàn),在NestedScrollingChild滾動前會調用dispatchNestedPreScroll方法詢問NestedScrollingParent是否要先滾動,而NestedScrollingParent會調用自身的onNestedPreScroll方法處理事件,那追蹤NestedScrollView的onNestedPreScroll方法:
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
int type) {
dispatchNestedPreScroll(dx, dy, consumed, null, type);
}
@Override
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,
int type) {
return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
}
//該方法屬于NestedScrollingChildHelper
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) {
int startX = 0;
int startY = 0;
if (offsetInWindow != null) {
mView.getLocationInWindow(offsetInWindow);
startX = offsetInWindow[0];
startY = offsetInWindow[1];
}
if (consumed == null) {
consumed = getTempNestedScrollConsumed();
}
consumed[0] = 0;
consumed[1] = 0;
ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);
if (offsetInWindow != null) {
mView.getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
return consumed[0] != 0 || consumed[1] != 0;
} else if (offsetInWindow != null) {
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
由源碼可知,NestedScrollView的onNestedPreScroll方法并沒有處理滑動事件,而是調用了dispatchNestedPreScroll方法將事件又傳遞給了NestedScrollingParent了,由于NestedScrollView本身即實現(xiàn)了NestedScrollingParent又實現(xiàn)了NestedScrollingChild,所以導致無法先滾動到頂部的現(xiàn)象,那只需重新onNestedPreScroll方法并實現(xiàn)滾動到頂部的邏輯即可解決此問題,代碼如下:
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
//super.onNestedPreScroll(target, dx, dy, consumed, type);
boolean hideTop = dy > 0 && getScrollY() < mHeadView.getMeasuredHeight();
if (hideTop) {
//滾動到相應的滑動距離
scrollBy(0, dy);
//記錄父控件消費的滾動記錄,防止子控件重復滾動
consumed[1] = dy;
}
}
如何實現(xiàn)慣性滑動
觀察京東的滾動效果,可以發(fā)現(xiàn),當快速滑動父控件松手后,會帶動子控件慣性向上滑動,那如何實現(xiàn)這張效果呢?
實現(xiàn)思路:
- 記錄父控件慣性滑動的速度
- 將慣性滑動的速度轉化成距離
- 計算子控件應滑的距離 = 慣性距離 - 父控件已滑動距離
- 將子控件應滑的距離轉化成速交給子控件進行慣性滑動
記錄父控件慣性滑動的速度
@Override
public void fling(int velocityY) {
super.fling(velocityY);
if (velocityY <= 0) {
mVelocityY = 0;
} else {
mVelocityY = velocityY;
}
}
記錄父控件慣性滑動的速度
double distance = mFlingHelper.getSplineFlingDistance(mVelocityY);
計算子控件應滑的距離 = 慣性距離 - 父控件已滑動距離
//設置滾動監(jiān)聽事件
setOnScrollChangeListener(new View.OnScrollChangeListener() {
@Override
public void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
/*
* scrollY == 0 即還未滾動
* scrollY == getChildAt(0).getMeasuredHeight() - v.getMeasuredHeight()即滾動到頂部了
*/
//判斷NestedScrollView是否滾動到頂部,若滾動到頂部,判斷子控件是否需要繼續(xù)滾動滾動
if (scrollY == getChildAt(0).getMeasuredHeight() - v.getMeasuredHeight()) {
dispatchChildFling();
}
//累計自身滾動的距離
mConsumedY += scrollY - oldScrollY;
}
});
將子控件應滑的距離轉化成速交給子控件進行慣性滑動
private void dispatchChildFling() {
if (mVelocityY != 0) {
//將慣性滑動速度轉化成距離
double distance = mFlingHelper.getSplineFlingDistance(mVelocityY);
//計算子控件應該滑動的距離 = 慣性滑動距離 - 已滑距離
if (distance > mConsumedY) {
RecyclerView recyclerView = getChildRecyclerView(mContentView);
if (recyclerView != null) {
//將剩余滑動距離轉化成速度交給子控件進行慣性滑動
int velocityY = mFlingHelper.getVelocityByDistance(distance - mConsumedY);
recyclerView.fling(0, velocityY);
}
}
}
mConsumedY = 0;
mVelocityY = 0;
}
NestedScrollLayout核心類實現(xiàn)
public class NestedScrollLayout extends NestedScrollView {
ViewGroup mHeadView;//頂部控件
ViewGroup mContentView;//內容控件
int mVelocityY;//慣性滾動速度
FlingHelper mFlingHelper;//處理慣性滑動速度與距離的轉化
int mConsumedY;//記錄自身已經滾動的距離
public NestedScrollLayout(@NonNull Context context) {
this(context, null);
}
public NestedScrollLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public NestedScrollLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
mFlingHelper = new FlingHelper(getContext());
//設置滾動監(jiān)聽事件
setOnScrollChangeListener(new View.OnScrollChangeListener() {
@Override
public void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
/*
* scrollY == 0 即還未滾動
* scrollY == getChildAt(0).getMeasuredHeight() - v.getMeasuredHeight()即滾動到頂部了
*/
//判斷NestedScrollView是否滾動到頂部,若滾動到頂部,判斷子控件是否需要繼續(xù)滾動滾動
if (scrollY == getChildAt(0).getMeasuredHeight() - v.getMeasuredHeight()) {
dispatchChildFling();
}
//累計自身滾動的距離
mConsumedY += scrollY - oldScrollY;
}
});
}
//將慣性滑動剩余的距離分發(fā)給子控件,繼續(xù)慣性滑動
private void dispatchChildFling() {
if (mVelocityY != 0) {
//將慣性滑動速度轉化成距離
double distance = mFlingHelper.getSplineFlingDistance(mVelocityY);
//計算子控件應該滑動的距離 = 慣性滑動距離 - 已滑距離
if (distance > mConsumedY) {
RecyclerView recyclerView = getChildRecyclerView(mContentView);
if (recyclerView != null) {
//將剩余滑動距離轉化成速度交給子控件進行慣性滑動
int velocityY = mFlingHelper.getVelocityByDistance(distance - mConsumedY);
recyclerView.fling(0, velocityY);
}
}
}
mConsumedY = 0;
mVelocityY = 0;
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mHeadView = (ViewGroup) ((ViewGroup) getChildAt(0)).getChildAt(0);
mContentView = (ViewGroup) ((ViewGroup) getChildAt(0)).getChildAt(1);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//第一個要點:頂部懸浮效果
//解決方式:將內容布局的高度設置為NestedScrollView的高度,即滑到頂了,自然就固定在頂部了
ViewGroup.LayoutParams layoutParams = mContentView.getLayoutParams();
layoutParams.height = getMeasuredHeight();
mContentView.setLayoutParams(layoutParams);
}
/**
* 嵌套滑動的兩個角色:NestedScrollingParent3和NestedScrollingChild3,是由NestedScrollingChild3觸發(fā)嵌套滑動,由NestedScrollingParent3觸發(fā)不算嵌套滑動
* 小結:子控件觸發(fā)dispatchNestedPreScroll時會先調用支持嵌套滾動父控件的onNestedPreScroll讓父控件先滾動,再執(zhí)行
* 自身的dispatchNestedScroll進行滾動
*/
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
//super.onNestedPreScroll(target, dx, dy, consumed, type);
/*
第二個要點:先讓NestedScrollingParent3滑動到頂部后,NestedScrollingChild3才可以滑動
解決方法:由于NestedScrollView即實現(xiàn)了NestedScrollingParent3又實現(xiàn)了NestedScrollingChild3,
所以super.onNestedPreScroll(target, dx, dy, consumed, type)內部實現(xiàn)又會去調用父控件
的onNestedPreScroll方法,就會出現(xiàn)NestedScrollView無法滑動到頂部的想象,所以此處
注釋掉super.onNestedPreScroll(target, dx, dy, consumed, type),實現(xiàn)滑動邏輯
*/
//向上滾動并且滾動的距離小于頭部控件的高度,則此時父控件先滾動并記錄消費的滾動距離
boolean hideTop = dy > 0 && getScrollY() < mHeadView.getMeasuredHeight();
if (hideTop) {
//滾動到相應的滑動距離
scrollBy(0, dy);
//記錄父控件消費的滾動記錄,防止子控件重復滾動
consumed[1] = dy;
}
}
/**
* 要點三:慣性滑動,父控件在滑動完成后,在通知子控件滑動,此時不是嵌套滾動
* 解決方法:1.記錄慣性滑動的速度
* 2.將速度轉化成距離
* 3.計算子控件應該滑動的距離 = 慣性滑動距離 - 已滑距離
* 4.將剩余滑動距離轉化成速度交給子控件進行慣性滑動
*/
@Override
public void fling(int velocityY) {
super.fling(velocityY);
//3.1記錄慣性滾動的速度
if (velocityY <= 0) {
mVelocityY = 0;
} else {
mVelocityY = velocityY;
}
}
//遞歸獲取子控件RecyclerView
private RecyclerView getChildRecyclerView(ViewGroup viewGroup) {
for (int i = 0; i < viewGroup.getChildCount(); i++) {
View view = viewGroup.getChildAt(i);
if (view instanceof RecyclerView && Objects.requireNonNull(((RecyclerView) view).getLayoutManager()).canScrollVertically()) {
return (RecyclerView) view;
} else if (viewGroup.getChildAt(i) instanceof ViewGroup) {
RecyclerView childRecyclerView = getChildRecyclerView((ViewGroup) viewGroup.getChildAt(i));
if (childRecyclerView != null && Objects.requireNonNull((childRecyclerView).getLayoutManager()).canScrollVertically()) {
return childRecyclerView;
}
}
}
return null;
}
}
完整代碼實現(xiàn)
百度鏈接
密碼:r6mi