时间:2022-08-28 09:28:36 | 栏目:Android代码 | 点击:次
本文主要实现淘宝首页嵌套滑动,中间tab吸顶效果,以及介绍NestScrollView嵌套RecyclerView处理滑动冲突的方法,淘宝首页的效果图如下:
首先我们通过一张图来分析下页面的布局结构:
先把最基础的页面搭出来,禁用Recycler滑动只需要重写onInterceptTouchEvent、onTouchEvent返回值都设为false即可:
<?xml version="1.0" encoding="utf-8"?> <ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".activiy.ViewPagerActivity" android:background="#f2f2f2"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <com.aykj.nestscrolldemo.widget.NoScrollRecyclerView android:id="@+id/top_recycler_view" android:layout_width="match_parent" android:layout_height="wrap_content"/> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <View android:layout_width="match_parent" android:layout_height="1px" android:background="#e0e0e0"/> <com.google.android.material.tabs.TabLayout android:id="@+id/tab_view" android:layout_width="match_parent" android:layout_height="wrap_content" /> <View android:layout_width="match_parent" android:layout_height="1px" android:background="#e0e0e0"/> <androidx.viewpager.widget.ViewPager android:id="@+id/view_pager" android:layout_width="match_parent" android:layout_height="match_parent"/> </LinearLayout> </LinearLayout> </ScrollView>
public class ViewPagerActivity extends AppCompatActivity { private List<String> topDatas = new ArrayList<>(); private List<String> tabTitles = new ArrayList<>(); ActivityViewPagerBinding viewBinding; private RecyclerAdapter topAdapter; private DividerItemDecoration divider; private TabFragmentAdapter pagerAdapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); viewBinding = ActivityViewPagerBinding.inflate(LayoutInflater.from(this)); setContentView(viewBinding.getRoot()); initDatas(); initView(); } private void initDatas() { topDatas.clear(); for(int i=0; i<5; i++) { topDatas.add("top item " + (i + 1)); } tabTitles.clear(); tabTitles.add("tab1"); tabTitles.add("tab2"); tabTitles.add("tab3"); } private void initView() { //init topRecycler divider = new DividerItemDecoration(this, LinearLayout.VERTICAL); divider.setDrawable(new ColorDrawable(Color.parseColor("#ffe0e0e0"))); viewBinding.topRecyclerView.setLayoutManager(new LinearLayoutManager(this)); viewBinding.topRecyclerView.addItemDecoration(divider); topAdapter = new RecyclerAdapter(this, topDatas); viewBinding.topRecyclerView.setAdapter(topAdapter); //initTabs with ViewPager pagerAdapter = new TabFragmentAdapter(getSupportFragmentManager(), tabTitles); viewBinding.viewPager.setAdapter(pagerAdapter); viewBinding.tabView.setupWithViewPager(viewBinding.viewPager); viewBinding.tabView.setTabMode(TabLayout.MODE_FIXED); } }
可以看到ViewPager没有正常显示出来,这个时候可以重写ViewPager的onMeasure,重新测量ViewPager的宽高。也可以换用ViewPager2
public class CustomViewPager extends ViewPager { ... @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //重写ViewPager的onMeasure int width = 0; int height = 0; for(int i=0; i<getChildCount(); i++) { View childView = getChildAt(0); measureChild(childView, widthMeasureSpec, heightMeasureSpec); width = Math.max(width, childView.getMeasuredWidth()); height = Math.max(height, childView.getMeasuredHeight()); } height += getPaddingTop() + getPaddingBottom(); heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); super.onMeasure(widthMeasureSpec, heightMeasureSpec); } }
从上面的效果图可以看到,ViewPager能正常显示出来了,但是在RecyclerView上滑动的时候发现,RecyclerView滑动完了之后,ScrollView才会滑动,并且ScrollView只滑动了一小段距离,这是因为首先ScrollView是不支持嵌套滑动的
ScrollView内部的第一个子View中所有子View的高度 = 顶部的RecyclerView高度 + TabLayout高度 + 底部RecyclerView中所有可见Item的高度
这个高度只比ScrollView的高度大一点点导致的。为了实现嵌套滑动需要使用NestedScrollView,接下来把ScrollView替换成NestedScrollView:
整个页面可以滑完,看起来就像是两个Scroll被合并成一个了,如果单单只是实现上面的界面效果,我们完全可以使用一个RecyclerView即可,但是Tab没有吸顶,这是因为:
ScrollView内部的第一个子View中所有子View的高度 = 顶部的RecyclerView高度 + TabLayout高度 + 底部RecyclerView中所有Item的高度
要实现Tab吸顶,只需要重写NestedScrollView的onMeasue方法,将TabLayout的高度和ViewPager的高度之和设置为NestedScrollView的高度:
public class StickyScrollLayout extends NestedScrollView { @Override protected void onFinishInflate() { super.onFinishInflate(); int count = getChildCount(); if(count == 1) { View firstChild = getChildAt(0); if(firstChild != null && firstChild instanceof ViewGroup) { int childCount = ((ViewGroup) firstChild).getChildCount(); if(childCount > 1) { topView = ((ViewGroup) firstChild).getChildAt(0); contentView = ((ViewGroup) firstChild).getChildAt(1); } } } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); if(contentView != null) { ViewGroup.LayoutParams contentLayoutParams = contentView.getLayoutParams(); contentLayoutParams.height = getMeasuredHeight(); contentView.setLayoutParams(contentLayoutParams); } } }
此时TabLayout可以吸顶了
从上图中可以看出,当我们在RecyclerView上向上滑动时,需要等RecyclerView滑动完,外部的NestedScrollView才开始滑动,而我们希望NestedScrollView中顶部的RecyclerView滑完之后,底部的RecyclerView才开始滑动,这是为什么呢?
查看NestedScrollView和RecyclerView的源码,可以知道NestedScrollView和RecyclerView分别实现了NestedScrollingParent3,NestedScrollingChild3接口,分别用来表示嵌套滑动的父View、嵌套滑动的子View,当我们的手指在RecyclerView上滑动时,滑动事件会从上往下分发至RecyclerView的onTouchEvent中,RecyclerView会依次响应ACTION_DOWN、ACTION_MOVE、ACTION_UP
RecyclerView在处理ACTION_DOWN时的关键代码如下:
public boolean onTouchEvent(MotionEvent e) { switch (action) { case MotionEvent.ACTION_DOWN: { if (canScrollHorizontally) { nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL; } if (canScrollVertically) { nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL; } startNestedScroll(nestedScrollAxis, TYPE_TOUCH); } break; } return true; }
当手指按下屏幕时会调用其作为NestedScrollingChild的实现方法startNestedScroll,在startNestedScroll的具体实现中,会一级一级的往上查找是否有NestedScrollingParent,如果有,会调用NestedScrollingParent的onStartNestedScroll方法通知它我即将要开始滑动了,然后NestedScrollingParent会调用onNestedScrollAccepted继续传递给上层的NestedScrollingParent,此处的NestedScrollingParent整好由NestedScrollView来充当,而NestedScrollView的上层已经找不到NestedScrollingParent了,时间传给NestedScrollView之后就中断了。
紧接着处理一系列的ACTION_MOVE:
public boolean onTouchEvent(MotionEvent e) { switch (action) { case MotionEvent.ACTION_MOVE: { if (dispatchNestedPreScroll( canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0, mReusableIntPair, mScrollOffset, TYPE_TOUCH )) { dx -= mReusableIntPair[0]; dy -= mReusableIntPair[1]; // Updated the nested offsets mNestedOffsets[0] += mScrollOffset[0]; mNestedOffsets[1] += mScrollOffset[1]; // Scroll has initiated, prevent parents from intercepting getParent().requestDisallowInterceptTouchEvent(true); } if (scrollByInternal( canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0, e)) { getParent().requestDisallowInterceptTouchEvent(true); } } break; } return true; }
RecyclerView接收到ACTION_MOVE后,首先会调用其作为NestedScrollingChild的实现方法dispatchNestedPreScroll,在dispatchNestedPreScroll的具体实现中,会一级一级的往上查找是否有NestedScrollingParent,如果有,会调用NestedScrollingParent的dispatchNestedPreScroll,紧接着调用NestedScrollView的onNestedPreScroll,来告诉NestedScrollView我即将要滑动 xxx 距离,你需不需要滑动,在NestedScrollView的onNestedPreScroll方法中并不会去响应滑动,又会把自己作为一个NestedScrollingChild,把事件继续往上传递,而在NestedScrollView的上层已经没有可以处理嵌套滑动的NestedScrollingParent了
@Override public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) { dispatchNestedPreScroll(dx, dy, consumed, null, type); }
具体的事件传递流程如下图:
因此我们可以重写NestedScrollView的onNestedPreScroll方法来使NestedScrollView滑动
public class StickyNestedScrollLayout extends NestedScrollView { @Override protected void onFinishInflate() { super.onFinishInflate(); int count = getChildCount(); if(count == 1) { View firstChild = getChildAt(0); if(firstChild != null && firstChild instanceof ViewGroup) { int childCount = ((ViewGroup) firstChild).getChildCount(); if(childCount > 1) { topView = ((ViewGroup) firstChild).getChildAt(0); contentView = ((ViewGroup) firstChild).getChildAt(1); } } } } @Override public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) { boolean topIsShow = getScrollY() >=0 && getScrollY() < topView.getHeight(); if(topIsShow) { scrollBy(0, dy); } else { super.onNestedPreScroll(target, dx, dy, consumed, type); } } }
此时NestedScrollView能滑动了,但是NestedScrollView滑动的同时,RecyclerView也会跟着滑动,这是为什么呢?
在RecyclerView的dispatchNestedPreScroll方法具体实现中,有这样一段代码
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, @NestedScrollType int type) { if (isNestedScrollingEnabled()) { consumed[0] = 0; consumed[1] = 0; ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type); //consumed[0]、consumed[1]的值仍为0 return consumed[0] != 0 || consumed[1] != 0;//返回false } } return false; }
再结合RecyclerView的ACTION_MOVE来看:
public boolean onTouchEvent(MotionEvent e) { switch (action) { case MotionEvent.ACTION_MOVE: { if (dispatchNestedPreScroll( canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0, mReusableIntPair, mScrollOffset, TYPE_TOUCH )) { //dispatchNestedPreScroll返回了false,此处的if语句不会执行,因此RecyclerView也会滑动 dx -= mReusableIntPair[0]; dy -= mReusableIntPair[1]; // Updated the nested offsets mNestedOffsets[0] += mScrollOffset[0]; mNestedOffsets[1] += mScrollOffset[1]; // Scroll has initiated, prevent parents from intercepting getParent().requestDisallowInterceptTouchEvent(true); } if (scrollByInternal( canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0, e)) { getParent().requestDisallowInterceptTouchEvent(true); } } break; } return true; }
因此,我们,在NestedScrollView的onNestedPreScroll方法中,处理完滑动后,通过consumed告诉RecyclerView我滑动了多少,这样
RecyclerView会重新设置dx、dy的值,因此RecyclerView就不会跟着滑动了
public class StickyNestedScrollLayout extends NestedScrollView { @Override protected void onFinishInflate() { super.onFinishInflate(); int count = getChildCount(); if(count == 1) { View firstChild = getChildAt(0); if(firstChild != null && firstChild instanceof ViewGroup) { int childCount = ((ViewGroup) firstChild).getChildCount(); if(childCount > 1) { topView = ((ViewGroup) firstChild).getChildAt(0); contentView = ((ViewGroup) firstChild).getChildAt(1); } } } } @Override public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) { boolean topIsShow = getScrollY() >=0 && getScrollY() < topView.getHeight(); if(topIsShow) { scrollBy(0, dy); //告诉RecyclerView,我滑动了多少距离 consumed[1] = dy; } else { super.onNestedPreScroll(target, dx, dy, consumed, type); } } }
实现思路:
记录父控件惯性滑动的速度判断NestedScrollView是否滚动到底部,若滚动到底部,判断子控件是否需要继续滚动滚动将惯性滑动的速度转化成距离,计算子控件应滑的距离 = 惯性距离 - 父控件已滑动距离,并将子控件应滑的距离转化成速度交给子控件进行惯性滑动
1.记录父控件惯性滑动的速度
public void fling(int velocityY) { super.fling(velocityY); if (velocityY <= 0) { mVelocityY = 0; } else { mVelocityY = velocityY; } }
2.判断NestedScrollView是否滚动到底部,若滚动到底部,判断子控件是否需要继续滚动
@Override protected void onScrollChanged(int scrollX, int scrollY, int oldScrollX, int oldScrollY) { super.onScrollChanged(scrollX, scrollY, oldScrollX, oldScrollY); /* * scrollY == 0 即还未滚动 * scrollY == getChildAt(0).getMeasuredHeight() - v.getMeasuredHeight()即滚动到底部了 */ //判断NestedScrollView是否滚动到底部,若滚动到底部,判断子控件是否需要继续滚动 if (scrollY == getChildAt(0).getMeasuredHeight() - this.getMeasuredHeight()) { dispatchChildFling(); } //累计自身滚动的距离 mConsumedY += scrollY - oldScrollY; }
3.将惯性滑动的速度转化成距离,计算子控件应滑的距离 = 惯性距离 - 父控件已滑动距离,并将子控件应滑的距离转化成速度交给子控件进行惯性滑动
private void dispatchChildFling() { if(mFlingHelper == null) { mFlingHelper = new FlingHelper(getContext()); } 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; } //递归获取子控件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; }