文章目錄
之前寫過一篇嵌套滑動–NestedScroll-項目實例(淘寶首頁缺陷),及CoordinatorLayout 和 AppbarLayout 聯動原理,比較了淘寶和京東首頁的滑動效果,分析了效果呈現差別的原因,給出了大致的解決方案。
當時沒有給出demo,只有代碼片段,可能導致閱讀起來不很清晰,所以這篇就專門再來詳細分析相關知識,給出通用的嵌套滑動的解決方案,且附上GitHub的Demo。
本文相關代碼Demo Github地址,有幫助的話Star一波吧。
一、問題及解決方案
先來看一張圖:
這是京東的首頁,忽略頂部和頂部,大致理解視圖結構就是:最外層爲多佈局的RecyclerView,最後一個item是tabLayout+ViewPager,ViewPager的每個fragment內也是RecyclerView。這是電商App首頁常用的佈局方式。
再來看下滑動起來的效果圖:
可見,在向上滑動頁面時,當tabLayout滑動到頂部時,外層RecyclerView停止滑動,此時tabLayout即爲吸頂狀態,接着會 滑動ViewPager中的內層RecyclerView。向下滑動時,如果tabLayout是吸頂狀態,那麼會先滑動內層RecyclerView,然後再滑外層RecyclerView。
那麼,如果我們 直接 按上述佈局結構來實現,會是京東這種效果嗎?答案是否定的,效果如下?
可見,在tabLayout是吸頂狀態,無法繼續滑動內層RecyclerView(擡起手指繼續滑也不行)。 (點擊查看相關代碼)
那麼該咋辦呢?根據滑動衝突的相關知識,我們知道一定是外層RecyclerView攔截了觸摸事件,內層RecyclerView無法獲取事件,就無法滑動了。那麼是否可以在tabLayout吸頂時,外層不要攔截事件,從而內層RecyclerView獲取事件進而滑動呢?
這是可行的,但是在tabLayout滑動到頂部後,必須擡起手指,重新滑動,內層RecyclerView才能繼續滑動。 這是爲啥呢?開頭提到的博客中有說明:
從view事件分發機制 我們知道,當parent View攔截事件後,那同一事件序列的事件會直接都給parent處理,子view不會接受事件了。所以按照正常處理滑動衝突的思路處理–當tab沒到頂部時,parent攔截事件,tab到頂部時 parent就不攔截事件,但是由於手指沒擡起來,所以這一事件序列還是繼續給parent,不會到內部RecyclerView,所以商品流就不會滑動了。
解決方案只能是嵌套滑動佈局了。代碼如下:
<?xml version="1.0" encoding="utf-8"?>
<com.hfy.demo01.module.home.touchevent.view.NestedScrollingParent2LayoutImpl3 xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/nested_scrolling_parent2_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_parent"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.hfy.demo01.module.home.toucheve
看到我們把外層RecyclerView的根佈局換成了NestedScrollingParent2LayoutImpl3,運行後發現確實解決了上述問題,滑動效果同京東一致。
那NestedScrollingParent2LayoutImpl3這是啥呢?NestedScrollingParent2LayoutImpl3是繼承NestedScrollingParent2的LinearLayout,用於處理上述嵌套滑動帶來的問題。(點擊查看NestedScrollingParent2LayoutImpl3的實現)
效果如下:
如果不關心原理及實現,到這了就結束了,因爲NestedScrollingParent2LayoutImpl3就可以解決以上問題。
二、NestedScrollingParent2LayoutImpl3的實現原理
2.1 先來回顧下嵌套滑動機制。
如果還不瞭解嵌套滑動以及NestedScrollingParent2,建議先閱讀此篇博客自定義View事件之進階篇(一)-NestedScrolling(嵌套滑動)機制,再接着往下閱讀。
NestedScrolling(嵌套滑動)機制,簡單說來就是:產生嵌套滑動的子view,在滑動前,先詢問 嵌套滑動對應的父view 是否優先處理 事件、以及消費多少事件,然後把消費後剩餘的部分 繼續給到 子view。 可以理解爲一個事件序列分發兩次。產生嵌套滑動的子view要實現接口NestedScrollingChild2、父view要實現接口NestedScrollingParent2。
常用的RecyclerView就是實現了NestedScrollingChild2,而NestedScrollView則是既實現了NestedScrollingChild2又實現了NestedScrollingParent2。
通常我們要自行手動處理的就是RecyclerView作爲嵌套滑動子view的情況。NestedScrollView一般直接作爲根佈局用來解決嵌套滑動。
2.2 再來看看NestedScrollView嵌套RecyclerView
關於NestedScrollView嵌套RecyclerView的情況,即頭部和列表可以一起滑動。如下圖:
參考這篇實名反對《阿里巴巴Android開發手冊》中NestedScrollView嵌套RecyclerView的用法。從此篇文章分析結論得知,NestedScrollView嵌套RecyclerView雖然可以實現效果,但是RecyclerView會瞬間加載所有item,RecyclerView失去的view回收的特性。 作者最後建議使用RecyclerView多佈局。
但其實在真實應用中,可能 頭部 和 列表 的數據來自不同的接口,當列表的數據請求失敗時要展示缺省圖,但頭部還是會展示。這時頭部和列表 分開實現 是比較好的選擇。
這裏給出解決方案:
<?xml version="1.0" encoding="utf-8"?>
<com.hfy.demo01.module.home.touchevent.view.NestedScrollingParent2LayoutImpl2 xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/tv_head"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="@color/colorAccent"
android:gravity="center"
android:padding="15dp"
android:text="我是頭部。 最外層是NestedScrollingParent2LayoutImpl2"
android:textColor="#fff"
android:textSize="20dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/design_default_color_primary" />
</com.hfy.demo01.module.home.touchevent.view.NestedScrollingParent2LayoutImpl2>
NestedScrollingParent2LayoutImpl2同樣是實現了NestedScrollingParent2。(點擊查看NestedScrollingParent2LayoutImpl2的實現)
效果如下,可見滑動流暢,臨界處不用擡起手指重新滑,且查看日誌不是一次加載完item。
先看下NestedScrollingParent2LayoutImpl2的實現,要簡單一些,接着再看NestedScrollingParent2LayoutImpl3實現原理,整體思路是一致的。
/**
* 處理 header + recyclerView
* Description:NestedScrolling2機制下的嵌套滑動,實現NestedScrollingParent2接口下,處理fling效果的區別
*
*/
public class NestedScrollingParent2LayoutImpl2 extends NestedScrollingParent2Layout implements NestedScrollingParent2 {
private View mTopView;
private View mRecylerVIew;
private int mTopViewHeight;
public NestedScrollingParent2LayoutImpl2(Context context) {
this(context, null);
}
public NestedScrollingParent2LayoutImpl2(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public NestedScrollingParent2LayoutImpl2(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setOrientation(VERTICAL);
}
@Override
public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}
/**
* 在嵌套滑動的子View未滑動之前,判斷父view是否優先與子view處理(也就是父view可以先消耗,然後給子view消耗)
*
* @param target 具體嵌套滑動的那個子類
* @param dx 水平方向嵌套滑動的子View想要變化的距離
* @param dy 垂直方向嵌套滑動的子View想要變化的距離 dy<0向下滑動 dy>0 向上滑動
* @param consumed 這個參數要我們在實現這個函數的時候指定,回頭告訴子View當前父View消耗的距離
* consumed[0] 水平消耗的距離,consumed[1] 垂直消耗的距離 好讓子view做出相應的調整
* @param type 滑動類型,ViewCompat.TYPE_NON_TOUCH fling效果,ViewCompat.TYPE_TOUCH 手勢滑動
*/
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
//這裏不管手勢滾動還是fling都處理
boolean hideTop = dy > 0 && getScrollY() < mTopViewHeight;
boolean showTop = dy < 0 && getScrollY() >= 0 && !target.canScrollVertically(-1);
if (hideTop || showTop) {
scrollBy(0, dy);
consumed[1] = dy;
}
}
@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
//當子控件處理完後,交給父控件進行處理。
if (dyUnconsumed < 0) {
//表示已經向下滑動到頭
scrollBy(0, dyUnconsumed);
}
}
@Override
public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) {
return false;
}
@Override
public boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed) {
return false;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//這裏修改mRecylerVIew的高度爲屏幕高度,否則底部會出現空白。(因爲scrollTo方法是滑動子view,就把mRecylerVIew滑上去了)
ViewGroup.LayoutParams layoutParams = mRecylerVIew.getLayoutParams();
layoutParams.height = getMeasuredHeight();
mRecylerVIew.setLayoutParams(layoutParams);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mTopView = findViewById(R.id.tv_head);
mRecylerVIew = findViewById(R.id.recyclerView);
if (!(mRecylerVIew instanceof RecyclerView)) {
throw new RuntimeException("id RecyclerView should be RecyclerView!");
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mTopViewHeight = mTopView.getMeasuredHeight();
}
@Override
public void scrollTo(int x, int y) {
if (y < 0) {
y = 0;
}
if (y > mTopViewHeight) {
y = mTopViewHeight;
}
super.scrollTo(x, y);
}
}
主要就是再onNestedPreScroll中對臨界處做了處理:滑動RecyclerView時先滑動根佈局,使得頭部隱藏或顯示,然後再交給RecyclerView滑動。
2.3 NestedScrollingParent2LayoutImpl3的實現原理
代碼如下
/**
* 處理RecyclerView 套viewPager, viewPager內的fragment中 也有RecyclerView,處理外層、內層 RecyclerView的嵌套滑動問題
* 類似淘寶、京東首頁
*
*/
public class NestedScrollingParent2LayoutImpl3 extends NestedScrollingParent2Layout {
private final String TAG = this.getClass().getSimpleName();
private RecyclerView mParentRecyclerView;
private RecyclerView mChildRecyclerView;
private View mLastItemView;
public NestedScrollingParent2LayoutImpl3(Context context) {
super(context);
}
public NestedScrollingParent2LayoutImpl3(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public NestedScrollingParent2LayoutImpl3(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setOrientation(VERTICAL);
}
/**
* 有嵌套滑動到來了,判斷父view是否接受嵌套滑動
*
* @param child 嵌套滑動對應的父類的子類(因爲嵌套滑動對於的父View不一定是一級就能找到的,可能挑了兩級父View的父View,child的輩分>=target)
* @param target 具體嵌套滑動的那個子類
* @param nestedScrollAxes 支持嵌套滾動軸。水平方向,垂直方向,或者不指定
* @param type 滑動類型,ViewCompat.TYPE_NON_TOUCH fling 效果ViewCompat.TYPE_TOUCH 手勢滑動
*/
@Override
public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int nestedScrollAxes, int type) {
//自己處理邏輯
//這裏處理是接受 豎向的 嵌套滑動
return nestedScrollAxes == ViewCompat.SCROLL_AXIS_VERTICAL;
}
/**
* 在嵌套滑動的子View未滑動之前,判斷父view是否優先與子view處理(也就是父view可以先消耗,然後給子view消耗)
*
* @param target 具體嵌套滑動的那個子類,就是手指滑的那個 產生嵌套滑動的view
* @param dx 水平方向嵌套滑動的子View想要變化的距離
* @param dy 垂直方向嵌套滑動的子View想要變化的距離 dy<0向下滑動 dy>0 向上滑動
* @param consumed 這個參數要我們在實現這個函數的時候指定,回頭告訴子View當前父View消耗的距離
* consumed[0] 水平消耗的距離,consumed[1] 垂直消耗的距離 好讓子view做出相應的調整
* @param type 滑動類型,ViewCompat.TYPE_NON_TOUCH fling 效果ViewCompat.TYPE_TOUCH 手勢滑動
*/
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
//自己處理邏輯
if (mLastItemView == null) {
return;
}
int lastItemTop = mLastItemView.getTop();
if (target == mParentRecyclerView) {
handleParentRecyclerViewScroll(lastItemTop, dy, consumed);
} else if (target == mChildRecyclerView) {
handleChildRecyclerViewScroll(lastItemTop, dy, consumed);
}
}
/**
* 滑動外層RecyclerView時,的處理
*
* @param lastItemTop tab到屏幕頂部的距離,是0就代表到頂了
* @param dy 目標滑動距離, dy>0 代表向上滑
* @param consumed
*/
private void handleParentRecyclerViewScroll(int lastItemTop, int dy, int[] consumed) {
//tab上邊沒到頂
if (lastItemTop != 0) {
if (dy > 0) {
//向上滑
if (lastItemTop > dy) {
//tab的top>想要滑動的dy,就讓外部RecyclerView自行處理
} else {
//tab的top<=想要滑動的dy,先滑外部RecyclerView,滑距離爲lastItemTop,剛好到頂;剩下的就滑內層了。
consumed[1] = dy;
mParentRecyclerView.scrollBy(0, lastItemTop);
mChildRecyclerView.scrollBy(0, dy - lastItemTop);
}
} else {
//向下滑,就讓外部RecyclerView自行處理
}
} else {
//tab上邊到頂了
if (dy > 0){
//向上,內層直接消費掉
mChildRecyclerView.scrollBy(0, dy);
consumed[1] = dy;
}else {
int childScrolledY = mChildRecyclerView.computeVerticalScrollOffset();
if (childScrolledY > Math.abs(dy)) {
//內層已滾動的距離,大於想要滾動的距離,內層直接消費掉
mChildRecyclerView.scrollBy(0, dy);
consumed[1] = dy;
}else {
//內層已滾動的距離,小於想要滾動的距離,那麼內層消費一部分,到頂後,剩的還給外層自行滑動
mChildRecyclerView.scrollBy(0, -(Math.abs(dy)-childScrolledY));
consumed[1] = -(Math.abs(dy)-childScrolledY);
}
}
}
}
/**
* 滑動內層RecyclerView時,的處理
*
* @param lastItemTop tab到屏幕頂部的距離,是0就代表到頂了
* @param dy
* @param consumed
*/
private void handleChildRecyclerViewScroll(int lastItemTop, int dy, int[] consumed) {
//tab上邊沒到頂
if (lastItemTop != 0) {
if (dy > 0) {
//向上滑
if (lastItemTop > dy) {
//tab的top>想要滑動的dy,外層直接消耗掉
mParentRecyclerView.scrollBy(0, dy);
consumed[1] = dy;
} else {
//tab的top<=想要滑動的dy,先滑外層,消耗距離爲lastItemTop,剛好到頂;剩下的就滑內層了。
mParentRecyclerView.scrollBy(0, lastItemTop);
consumed[1] = dy - lastItemTop;
}
} else {
//向下滑,外層直接消耗
mParentRecyclerView.scrollBy(0, dy);
consumed[1] = dy;
}
}else {
//tab上邊到頂了
if (dy > 0){
//向上,內層自行處理
}else {
int childScrolledY = mChildRecyclerView.computeVerticalScrollOffset();
if (childScrolledY > Math.abs(dy)) {
//內層已滾動的距離,大於想要滾動的距離,內層自行處理
}else {
//內層已滾動的距離,小於想要滾動的距離,那麼內層消費一部分,到頂後,剩的外層滑動
mChildRecyclerView.scrollBy(0, -childScrolledY);
mParentRecyclerView.scrollBy(0, -(Math.abs(dy)-childScrolledY));
consumed[1] = dy;
}
}
}
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
//直接獲取外層RecyclerView
mParentRecyclerView = getRecyclerView(this);
Log.i(TAG, "onFinishInflate: mParentRecyclerView=" + mParentRecyclerView);
//關於內層RecyclerView:此時還獲取不到ViewPager內fragment的RecyclerView,需要在加載ViewPager後 fragment可見時 傳入
}
private RecyclerView getRecyclerView(ViewGroup viewGroup) {
int childCount = viewGroup.getChildCount();
for (int i = 0; i < childCount; i++) {
View childAt = getChildAt(i);
if (childAt instanceof RecyclerView) {
if (mParentRecyclerView == null) {
return (RecyclerView) childAt;
}
}
}
return null;
}
/**
* 傳入內部RecyclerView
*
* @param childRecyclerView
*/
public void setChildRecyclerView(RecyclerView childRecyclerView) {
mChildRecyclerView = childRecyclerView;
}
/**
* 外層RecyclerView的最後一個item,即:tab + viewPager
* 用於判斷 滑動 臨界位置
*
* @param lastItemView
*/
public void setLastItem(View lastItemView) {
mLastItemView = lastItemView;
}
}
NestedScrollingParent2LayoutImpl3 繼承自 NestedScrollingParent2Layout。NestedScrollingParent2Layout是繼承自 LinearLayout implements 並實現了NestedScrollingParent2,主要處理了通用的方法實現。
/**
* Description: 通用 滑動嵌套處理佈局,用於處理含有{@link androidx.recyclerview.widget.RecyclerView}的嵌套套滑動
*/
public class NestedScrollingParent2Layout extends LinearLayout implements NestedScrollingParent2 {
private NestedScrollingParentHelper mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);
public NestedScrollingParent2Layout(Context context) {
super(context);
}
public NestedScrollingParent2Layout(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public NestedScrollingParent2Layout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
/**
* 有嵌套滑動到來了,判斷父view是否接受嵌套滑動
*
* @param child 嵌套滑動對應的父類的子類(因爲嵌套滑動對於的父View不一定是一級就能找到的,可能挑了兩級父View的父View,child的輩分>=target)
* @param target 具體嵌套滑動的那個子類
* @param nestedScrollAxes 支持嵌套滾動軸。水平方向,垂直方向,或者不指定
* @param type 滑動類型,ViewCompat.TYPE_NON_TOUCH fling 效果ViewCompat.TYPE_TOUCH 手勢滑動
*/
@Override
public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int nestedScrollAxes, int type) {
//自己處理邏輯
return true;
}
/**
* 當父view接受嵌套滑動,當onStartNestedScroll方法返回true該方法會調用
*
* @param type 滑動類型,ViewCompat.TYPE_NON_TOUCH fling 效果ViewCompat.TYPE_TOUCH 手勢滑動
*/
@Override
public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type);
}
/**
* 在嵌套滑動的子View未滑動之前,判斷父view是否優先與子view處理(也就是父view可以先消耗,然後給子view消耗)
*
* @param target 具體嵌套滑動的那個子類
* @param dx 水平方向嵌套滑動的子View想要變化的距離
* @param dy 垂直方向嵌套滑動的子View想要變化的距離 dy<0向下滑動 dy>0 向上滑動
* @param consumed 這個參數要我們在實現這個函數的時候指定,回頭告訴子View當前父View消耗的距離
* consumed[0] 水平消耗的距離,consumed[1] 垂直消耗的距離 好讓子view做出相應的調整
* @param type 滑動類型,ViewCompat.TYPE_NON_TOUCH fling 效果ViewCompat.TYPE_TOUCH 手勢滑動
*/
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
//自己處理邏輯
}
/**
* 嵌套滑動的子View在滑動之後,判斷父view是否繼續處理(也就是父消耗一定距離後,子再消耗,最後判斷父消耗不)
*
* @param target 具體嵌套滑動的那個子類
* @param dxConsumed 水平方向嵌套滑動的子View滑動的距離(消耗的距離)
* @param dyConsumed 垂直方向嵌套滑動的子View滑動的距離(消耗的距離)
* @param dxUnconsumed 水平方向嵌套滑動的子View未滑動的距離(未消耗的距離)
* @param dyUnconsumed 垂直方向嵌套滑動的子View未滑動的距離(未消耗的距離)
* @param type 滑動類型,ViewCompat.TYPE_NON_TOUCH fling 效果ViewCompat.TYPE_TOUCH 手勢滑動
*/
@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
//自己處理邏輯
}
/**
* 嵌套滑動結束
*
* @param type 滑動類型,ViewCompat.TYPE_NON_TOUCH fling 效果ViewCompat.TYPE_TOUCH 手勢滑動
*/
@Override
public void onStopNestedScroll(@NonNull View child, int type) {
mNestedScrollingParentHelper.onStopNestedScroll(child, type);
}
@Override
public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) {
//自己判斷是否處理
return false;
}
@Override
public boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed) {
//自己處理邏輯
return false;
}
@Override
public int getNestedScrollAxes() {
return mNestedScrollingParentHelper.getNestedScrollAxes();
}
}
實現原理主要在onNestedPreScroll方法,即嵌套滑動的子view滑動前,詢問對應的父view是否優先處理,以及處理多少。
所以無論滑動外城RecyclerView還是內層RecyclerView,都會詢問NestedScrollingParent2LayoutImpl3,即都會走到onNestedPreScroll方法。然後根據tabLayout的位置以及滑動的方向,決定是滑動外層RecyclerView還是滑內層,以及滑動多少。相當於一個事假序列分發了兩次,避免了常規事件分發 父view攔截後子view無法處理的問題。
onNestedPreScroll中的具體處理,請看代碼,有詳細註釋。要結合滑動實際情況去理解,便於遇到其他情況也能同樣處理。
這裏列出已經實現的處理三種嵌套滑動的方案:
- NestedScrollingParent2LayoutImpl1:處理 header + tab + viewPager + recyclerView
- NestedScrollingParent2LayoutImpl2: 處理 header + recyclerView
- NestedScrollingParent2LayoutImpl3:處理RecyclerView 套viewPager, viewPager內的fragment中 也有RecyclerView,處理外層、內層 RecyclerView的嵌套滑動問題,類似淘寶、京東首頁。
Demo Github地址,有幫助的話Star一波吧。
歡迎關注