NestScrollView嵌套RecyclerView实现淘宝首页滑动效果
作者:andr_gale
一.概述
本文主要实现淘宝首页嵌套滑动,中间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; }
到此这篇关于NestScrollView嵌套RecyclerView实现淘宝首页滑动效果的文章就介绍到这了,更多相关NestScrollView嵌套RecyclerView内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!