文章目录
以前写过一篇嵌套滑动–NestedScroll-项目实例(淘宝首页缺陷),及CoordinatorLayout 和 AppbarLayout 联动原理,比较了淘宝和京东首页的滑动效果,分析了效果呈现差异的缘由,给出了大体的解决方案。
当时没有给出demo,只有代码片断,可能致使阅读起来不很清晰,因此这篇就专门再来详细分析相关知识,给出通用的嵌套滑动的解决方案,且附上GitHub的Demo。
html
本文相关代码Demo Github地址,有帮助的话Star一波吧。java
1、问题及解决方案
先来看一张图:
这是京东的首页,忽略顶部和顶部,大体理解视图结构就是:最外层为多布局的RecyclerView,最后一个item是tabLayout+ViewPager,ViewPager的每一个fragment内也是RecyclerView。这是电商App首页经常使用的布局方式。
android
再来看下滑动起来的效果图:
可见,在向上滑动页面时,当tabLayout滑动到顶部时,外层RecyclerView中止滑动,此时tabLayout即为吸顶状态,接着会 滑动ViewPager中的内层RecyclerView。向下滑动时,若是tabLayout是吸顶状态,那么会先滑动内层RecyclerView,而后再滑外层RecyclerView。
git
那么,若是咱们 直接 按上述布局结构来实现,会是京东这种效果吗?答案是否认的,效果以下?github
可见,在tabLayout是吸顶状态,没法继续滑动内层RecyclerView(抬起手指继续滑也不行)。 (点击查看相关代码)
markdown
那么该咋办呢?根据滑动冲突的相关知识,咱们知道必定是外层RecyclerView拦截了触摸事件,内层RecyclerView没法获取事件,就没法滑动了。那么是否能够在tabLayout吸顶时,外层不要拦截事件,从而内层RecyclerView获取事件进而滑动呢?app
这是可行的,可是在tabLayout滑动到顶部后,必须抬起手指,从新滑动,内层RecyclerView才能继续滑动。 这是为啥呢?开头提到的博客中有说明:ide
从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就能够解决以上问题。
2、NestedScrollingParent2LayoutImpl3的实现原理
2.1 先来回顾下嵌套滑动机制。
若是还不了解嵌套滑动以及NestedScrollingParent2,建议先阅读此篇博客自定义View事件之进阶篇(一)-NestedScrolling(嵌套滑动)机制,再接着往下阅读。
NestedScrolling(嵌套滑动)机制,简单说来就是:产生嵌套滑动的子view,在滑动前,先询问 嵌套滑动对应的父view 是否优先处理 事件、以及消费多少事件,而后把消费后剩余的部分 继续给到 子view。 能够理解为一个事件序列分发两次。产生嵌套滑动的子view要实现接口NestedScrollingChild二、父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一波吧。
欢迎关注