概述
本文主要分享使用NestedScrollView嵌套RecyclerView實(shí)現(xiàn)仿京東Tab吸頂效果曲管,先來看一下效果圖:
實(shí)現(xiàn)要點(diǎn)
- Tab控件如何吸頂
- 如何實(shí)現(xiàn)嵌套滾動养葵,即父view可以滾動的情況下子view也可以滾動
- 如何實(shí)現(xiàn)慣性滑動
Tab控件吸頂
先看一下布局結(jié)構(gòu):
<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作為內(nèi)容控件屡限,那將LinearLayout的高度設(shè)置為NestedScrollView的高度即可實(shí)現(xiàn)TabLayout吸頂效果宠默,本質(zhì)上是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);
}
如何實(shí)現(xiàn)嵌套滾動
嵌套滾動的兩個角色:NestedScrollingParent3與NestedScrollingChild3胜茧,由NestedScrollingChild3觸發(fā)嵌套滾動事件,這里采用NestedScrollView嵌套RecyclerView的實(shí)現(xiàn)方法骗炉,而NestedScrollView與RecyclerView分別實(shí)現(xiàn)了NestedScrollingParent3與NestedScrollingChild3
但需要注意照宝,當(dāng)使用NestedScrollView嵌套RecyclerView并將內(nèi)容控件的高度設(shè)置為NestedScrollView的高度后,會出現(xiàn)一個奇怪的現(xiàn)象如下:
可以發(fā)現(xiàn)句葵,在滑動RecyclerView時并沒有先讓NestedScrollView滾動到頂部后厕鹃,然后RecyclerView在滑動,那是什么原因造成的呢乍丈?先來看一下嵌套滾動的大致流程圖:
從流程圖可以發(fā)現(xiàn)剂碴,在NestedScrollingChild滾動前會調(diào)用dispatchNestedPreScroll方法詢問NestedScrollingParent是否要先滾動,而NestedScrollingParent會調(diào)用自身的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方法并沒有處理滑動事件,而是調(diào)用了dispatchNestedPreScroll方法將事件又傳遞給了NestedScrollingParent了请垛,由于NestedScrollView本身即實(shí)現(xiàn)了NestedScrollingParent又實(shí)現(xiàn)了NestedScrollingChild催训,所以導(dǎo)致無法先滾動到頂部的現(xiàn)象,那只需重新onNestedPreScroll方法并實(shí)現(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) {
//滾動到相應(yīng)的滑動距離
scrollBy(0, dy);
//記錄父控件消費(fèi)的滾動記錄瞳腌,防止子控件重復(fù)滾動
consumed[1] = dy;
}
}
如何實(shí)現(xiàn)慣性滑動
觀察京東的滾動效果,可以發(fā)現(xiàn)镜雨,當(dāng)快速滑動父控件松手后,會帶動子控件慣性向上滑動,那如何實(shí)現(xiàn)這張效果呢荚坞?
實(shí)現(xiàn)思路:
- 記錄父控件慣性滑動的速度
- 將慣性滑動的速度轉(zhuǎn)化成距離
- 計(jì)算子控件應(yīng)滑的距離 = 慣性距離 - 父控件已滑動距離
- 將子控件應(yīng)滑的距離轉(zhuǎn)化成速交給子控件進(jìn)行慣性滑動
記錄父控件慣性滑動的速度
@Override
public void fling(int velocityY) {
super.fling(velocityY);
if (velocityY <= 0) {
mVelocityY = 0;
} else {
mVelocityY = velocityY;
}
}
記錄父控件慣性滑動的速度
double distance = mFlingHelper.getSplineFlingDistance(mVelocityY);
計(jì)算子控件應(yīng)滑的距離 = 慣性距離 - 父控件已滑動距離
//設(shè)置滾動監(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();
}
//累計(jì)自身滾動的距離
mConsumedY += scrollY - oldScrollY;
}
});
將子控件應(yīng)滑的距離轉(zhuǎn)化成速交給子控件進(jìn)行慣性滑動
private void dispatchChildFling() {
if (mVelocityY != 0) {
//將慣性滑動速度轉(zhuǎn)化成距離
double distance = mFlingHelper.getSplineFlingDistance(mVelocityY);
//計(jì)算子控件應(yīng)該滑動的距離 = 慣性滑動距離 - 已滑距離
if (distance > mConsumedY) {
RecyclerView recyclerView = getChildRecyclerView(mContentView);
if (recyclerView != null) {
//將剩余滑動距離轉(zhuǎn)化成速度交給子控件進(jìn)行慣性滑動
int velocityY = mFlingHelper.getVelocityByDistance(distance - mConsumedY);
recyclerView.fling(0, velocityY);
}
}
}
mConsumedY = 0;
mVelocityY = 0;
}
NestedScrollLayout核心類實(shí)現(xiàn)
public class NestedScrollLayout extends NestedScrollView {
ViewGroup mHeadView;//頂部控件
ViewGroup mContentView;//內(nèi)容控件
int mVelocityY;//慣性滾動速度
FlingHelper mFlingHelper;//處理慣性滑動速度與距離的轉(zhuǎn)化
int mConsumedY;//記錄自身已經(jīng)滾動的距離
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());
//設(shè)置滾動監(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();
}
//累計(jì)自身滾動的距離
mConsumedY += scrollY - oldScrollY;
}
});
}
//將慣性滑動剩余的距離分發(fā)給子控件,繼續(xù)慣性滑動
private void dispatchChildFling() {
if (mVelocityY != 0) {
//將慣性滑動速度轉(zhuǎn)化成距離
double distance = mFlingHelper.getSplineFlingDistance(mVelocityY);
//計(jì)算子控件應(yīng)該滑動的距離 = 慣性滑動距離 - 已滑距離
if (distance > mConsumedY) {
RecyclerView recyclerView = getChildRecyclerView(mContentView);
if (recyclerView != null) {
//將剩余滑動距離轉(zhuǎn)化成速度交給子控件進(jìn)行慣性滑動
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);
//第一個要點(diǎn):頂部懸浮效果
//解決方式:將內(nèi)容布局的高度設(shè)置為NestedScrollView的高度诡挂,即滑到頂了碎浇,自然就固定在頂部了
ViewGroup.LayoutParams layoutParams = mContentView.getLayoutParams();
layoutParams.height = getMeasuredHeight();
mContentView.setLayoutParams(layoutParams);
}
/**
* 嵌套滑動的兩個角色:NestedScrollingParent3和NestedScrollingChild3,是由NestedScrollingChild3觸發(fā)嵌套滑動璃俗,由NestedScrollingParent3觸發(fā)不算嵌套滑動
* 小結(jié):子控件觸發(fā)dispatchNestedPreScroll時會先調(diào)用支持嵌套滾動父控件的onNestedPreScroll讓父控件先滾動奴璃,再執(zhí)行
* 自身的dispatchNestedScroll進(jìn)行滾動
*/
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
//super.onNestedPreScroll(target, dx, dy, consumed, type);
/*
第二個要點(diǎn):先讓NestedScrollingParent3滑動到頂部后,NestedScrollingChild3才可以滑動
解決方法:由于NestedScrollView即實(shí)現(xiàn)了NestedScrollingParent3又實(shí)現(xiàn)了NestedScrollingChild3城豁,
所以super.onNestedPreScroll(target, dx, dy, consumed, type)內(nèi)部實(shí)現(xiàn)又會去調(diào)用父控件
的onNestedPreScroll方法苟穆,就會出現(xiàn)NestedScrollView無法滑動到頂部的想象,所以此處
注釋掉super.onNestedPreScroll(target, dx, dy, consumed, type)唱星,實(shí)現(xiàn)滑動邏輯
*/
//向上滾動并且滾動的距離小于頭部控件的高度雳旅,則此時父控件先滾動并記錄消費(fèi)的滾動距離
boolean hideTop = dy > 0 && getScrollY() < mHeadView.getMeasuredHeight();
if (hideTop) {
//滾動到相應(yīng)的滑動距離
scrollBy(0, dy);
//記錄父控件消費(fèi)的滾動記錄,防止子控件重復(fù)滾動
consumed[1] = dy;
}
}
/**
* 要點(diǎn)三:慣性滑動间聊,父控件在滑動完成后攒盈,在通知子控件滑動,此時不是嵌套滾動
* 解決方法:1.記錄慣性滑動的速度
* 2.將速度轉(zhuǎn)化成距離
* 3.計(jì)算子控件應(yīng)該滑動的距離 = 慣性滑動距離 - 已滑距離
* 4.將剩余滑動距離轉(zhuǎn)化成速度交給子控件進(jìn)行慣性滑動
*/
@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;
}
}
完整代碼實(shí)現(xiàn)
百度鏈接
密碼:r6mi