好久没有写博客了,感觉自己的手变得生疏了,今天来记录一下自己对Android里面的嵌套滚动的理解。
本文参考资料:
1.NestedScrollingParent, NestedScrollingChild 详解
2.针对 CoordinatorLayout 及 Behavior 的一次细节较真
1.什么是嵌套滑动?
在这里,楼主先贴出一个Demo图片,来直观的展示一下,什么是嵌套滑动。

我们发现,当我们向下滑动时,首先是外部的布局向下滑动,然后才是RecyclerView滑动,向上滑动也是如此。这就是嵌套滑动的效果。
我们认真的想一想,如果使用传统的事件分发机制来实现这个功能,应该怎么实现?是使用传统的事件分发机制来实现,还是不是很难的。但是又没有更加优秀的方法来实现这种效果呢?当然有咯,不然今天说什么。这个答案就是嵌套滑动机制。
可能有些老哥没有听过嵌套滑动机制,其实不是很难,楼主觉得比传统的事件分发机制简单的多。其中我们需要注意一点就是,传统的事件分发是从上向下分发,而嵌套滑动事件是从下到上,也就是说,当一个View会产生了一个嵌套滑动的事件,首先会报告给他的父View,询问他的父View是否处理这个事件,如果处理的话,那么子View就不处理(实际上存在父View只处理处理部分滑动距离的情况)。这里解释的比较简单,待会会详细的解释这些细节。
嵌套滑动机制,主要的用到的接口和类有:NestedScrollingChild
,NestedScrollingParent
,NestedScrollingParentHelper
,NestedScrollingChildHelper
。
这里先对这4个类做一个统一的解释:
类名 | 解释 |
---|---|
NestedScrollingChild | 如果一个View想要能够产生嵌套滑动事件,这个View必须实现NestedScrollChild接口,从Android 5.0开始,View实现了这个接口,不需要我们手动实现 |
NestedScrollingParent | 这个接口通常用来被ViewGroup来实现,表示能够接收从子View发送过来的嵌套滑动事件 |
NestedScrollingChildHelper | 这个类通常在实现NestedScrollChild接口的View里面使用,他通常用来负责将子View产生的嵌套滑动事件报告给父View。也就是说,如果一个子View想要将产生的嵌套滑动事件交给父View,这个过程不需要我们来实现,而是交给NestedScrollingChildHelper来帮助我们处理 |
NestedScrollingParentHelper | 这个类跟NestedScrollingChildHelper差不多,也是帮助来传递事件的,不过这个类通常用在实现NestedScrollingParent接口的View。如果一个父View不想处理一个事件,通过NestedScrollingParentHelper类帮助我们传递就行了 |
本文不对嵌套滑动的基本使用进行展开,只对其基本原理进行解释。
2. 子View事件的产生和传递
如果想要了解嵌套滑动机制的原理,必须得知道,一个嵌套事件是怎么产生的,是怎么传递到父View里面的。这些都必须知道NestedScrollingChild的工作原理。
(1).NestedScrollingChild的接口
在了解NestedScrollingChild的工作原理,我们先来看看NestedScrollChild接口里面的方法,然后在结合RecyclerView的源码来分析时事件是怎么传递到父View里面的。
public interface NestedScrollingChild { /** * 设置当前View是否能够产生嵌套滑动的事件 * @param enabled true表示能够产生嵌套滑动的事件,反之则不能 */ void setNestedScrollingEnabled(boolean enabled); /** * 判断当前View是否能够产生嵌套滑动的事件 * @return */ boolean isNestedScrollingEnabled(); /** * 当嵌套事件开始产生时会调用这个方法,这个方法通常是在ACTION_DOWN里面被调用 * @param axes axes表示方向,如果(nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0 表示当前滑动方向是垂直方向 * ,水平方向也是如此 * @return 返回true表示有父View能够处理传递传递上去的嵌套滑动事件,实际上这个这个方法里面调用NestedScrollingParent的onStartNestedScroll * 方法来判断是否有父View能够处理,这个在后面源码分析时,我们具体讲解 */ boolean startNestedScroll(@ViewCompat.ScrollAxis int axes); /** * 这个方法表示本次嵌套滑动的行为结束了,通常在ACTION_UP或者ACTION_CANCEL里面调用 */ void stopNestedScroll(); /** * 判断是否能够处理嵌套滑动的父View * @return true表示有,反之则没有 */ boolean hasNestedScrollingParent(); /** * 本方法在产生嵌套滑动的View已经滑动完成之后调用,该方法的作用是将剩余没有消耗的距离继续分发到父View里面去 * @param dxConsumed 表示该View在x轴上消耗的距离 * @param dyConsumed 表示该View在y轴上消耗的距离 * @param dxUnconsumed 表示该View在x轴上未消耗的距离 * @param dyUnconsumed 表示该View在y轴未消耗的距离 * @param offsetInWindow 表示该该View在屏幕上滑动的距离,包括x轴上的距离和y轴上的距离 * @return true表示父View消耗这部分的未消耗的距离,反之表示父View不消耗 */ boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow); /** * 这个方法在方法调用之前调用,也就是调用这个方法时,滑动距离产生了,但是该View还未滑动。 * 这个方法的作用是将滑动的距离报给父View,看看父View是否优先消耗这个这部分距离 * @param dx x轴上产生的距离 * @param dy y轴上产生的距离 * @param consumed index为0的值表示父View在x轴消耗的的距离,index为1的值表示父View在y轴上消耗的距离 * @param offsetInWindow 该View在屏幕滑动的距离 * @return true表示父View有消耗距离,false表示父View不消耗 */ boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow); /** * 如果父View不对fling事件做任何处理,那么子View会调用这个方法,这个方法的作用是报告父View,子View此时在fling * 然而具体是否在fling,还要consumed为true还是false,在这方法里面会调用NestedScrollingParent的onNestedFling * @param velocityX x轴上的速度 * @param velocityY y轴的速度 * @param consumed true表示子View对这个fling事件有所行动,false表示没有行动 * @return */ boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed); /** * 在子View对fling有所行动之前,会调用这个方法。这个方法的作用是,用来询问父View是否对fling事件有所行动 * @param velocityX * @param velocityY * @return */ boolean dispatchNestedPreFling(float velocityX, float velocityY);}
我相信,可能很多老哥看了每个方法的注释还是一头雾水。哎,能力所致!!现在我在对整个做一个小小的总结。
整个事件传递过程中,首先能保证传统的事件能够到达该View,当一个事件序列开始时,首先会调用startNestedScroll方法来告诉父View,马上就要开始一个滑动事件了,请问爸爸需要处理,如果处理的话,会返回true,不处理返回fasle。跟传统的事件传递一样,如果不处理的话,那么该事件序列的其他事件都不会传递到父View里面。
然后就是调用dispatchNestedPreScroll方法,这个方法调用时,子View还未真正滑动,所以这个方法的作用是子View告诉它的爸爸,此时滑动的距离已经产生,爸爸你看看能消耗多少,然后父View会根据情况消耗自己所需的距离,如果此时距离还未消耗完,剩下的距离子View来消耗,子View滑动完毕之后,会调用dispatchNestedScroll方法来告诉父View,爸爸,我已经滑动完毕,你看看你有什么要求没?这个过程里面可能有子View未消耗完的距离。
其次就是fling事件产生,过程跟上面也是一样,也是先调用dispatchNestedPreFling方法来询问父View是否有所行动,然后调用dispatchNestedFling告诉父View,子View已经fling完毕。
最后就是调用stopNestedScroll表示本次事件序列结束。
整个过程中,我们会发现子View开始一个动作时,会询问父View是否有所表示,结束一个动作时,也会告诉父View,自己的动作结束了,父View是否有所指示。
(2).RcyclerView的嵌套滑动机机制
简单的了解NestedScrollingView的工作流程,我们结合RecyclerView的源码分析一下事件传递的原理。由于本文只分析嵌套滑动的原理,所以RecyclerView其他的知识这个不讲解,实际上我也不懂!
我感觉我们以前真的是小看了RecyclerView,没想到他在背后帮我们做了这么事情,以后有机会一定好好看看RecyclerView的代码。现在来看看RecyclerView在嵌套滑动的实现。
先来看看RecyclerView对ACTION_DOWN事件的处理:
case MotionEvent.ACTION_DOWN: { mScrollPointerId = e.getPointerId(0); mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f); mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f); int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE; if (canScrollHorizontally) { nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL; } if (canScrollVertically) { nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL; } startNestedScroll(nestedScrollAxis, TYPE_TOUCH); } break;
在ACTION_DOWN里面,首先是对nestedScrollAxis变量进行处理话。在前面提及到过,nestedScrollAxis表示滑动的方向,如果nestedScrollAxis & ViewCompat. SCROLL_AXIS_VERTICAL != 0
,表示在垂直方向有滑动。初始化nestedScrollAxis变量之后,就会调用startNestedScroll方法来告诉父View滑动事件已经开始,你是否需要有所行动。这里就可以体现嵌套滑动的事件是从下到上传递的。
我们再来看看RecyclerView是怎么将一个事件传递到父View的。
@Override public boolean startNestedScroll(int axes, int type) { return getScrollingChildHelper().startNestedScroll(axes, type); } private NestedScrollingChildHelper getScrollingChildHelper() { if (mScrollingChildHelper == null) { mScrollingChildHelper = new NestedScrollingChildHelper(this); } return mScrollingChildHelper; }
到这里,我们知道了,事件是依靠NestedScrollingChildHelper类帮助我们传递的。我们再来看NestedScrollingChildHelper是怎么帮我们传递的
if (hasNestedScrollingParent(type)) { // Already in progress return true; } if (isNestedScrollingEnabled()) { ViewParent p = mView.getParent(); View child = mView; while (p != null) { if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) { setNestedScrollingParentForType(type, p); ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type); return true; } if (p instanceof View) { child = (View) p; } p = p.getParent(); } } return false; }
整个方法比较简单,首先经过hasNestedScrollingParent
方法来判断是否有父View能够处理该事件序列,这个的处理表示意思是,父View必须实现NestedScrollingParent接口,其次在onStartNestedScroll方法里面返回true。我们发现如果当View的父View不能够处理,那就会递归上去找,直到找到一个为止。
同时,我们发现,NestedScrollingChildHelper
有依靠ViewParentCompat
类来帮助我们传递事件,实际上ViewParentCompat
里面也是帮我们调用父View的onStartNestedScroll方法,这里做的目的是为了兼容不同版本的系统。在前面已经说过,从Android 5.0开始,View实现了NestScrollingChild
接口,而5.0以下,需要我们自己来实现了。这里不对ViewParentCompat怎么进行系统兼容的实现进行讨论,待会再来讨论。
在这里,对startNestedScroll方法的工作流程做一个简单的梳理。首先RecyclerView的ACTION_DOWN事件来到,RecyclerView的会调用startNestedScroll方法,在startNestedScroll方法里面,把具体的执行代理给NestedScrollingChildHelper
的startNestedScroll方法,在NestedScrollingChildHelper
的startNestedScroll方法里面,会不断的往上找能够处理该事件的父View,找到的话会调用父View的onStartNestedScroll方法。
在整个事件传递过程中,我们还需要注意的一点就是:isNestedScrollingEnabled()
方法,只要保证isNestedScrollingEnabled方法返回为true才能保证事件能够顺利往上的传递。这个方法的返回值取决于我们是否设置了setNestedScrollingEnabled方法。
当一个ACTION_DOWN结束之后,通常来说,接下来就是ACTION_MOVE,会涉及到View的滑动的情况。让我们来看看滑动事件是怎么传递过来的,实现先贴出代码:
case MotionEvent.ACTION_MOVE: { ······ if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) { dx -= mScrollConsumed[0]; dy -= mScrollConsumed[1]; vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]); // Updated the nested offsets mNestedOffsets[0] += mScrollOffset[0]; mNestedOffsets[1] += mScrollOffset[1]; } ······ } break;
这里减省了很多无关的代码,只看dispatchNestedPreScroll方法。需要注意的是,此时RecyclerView还未滑动,因为RecyclerView真正滑动操作是在scrollByInternal方法里面进行的,所以dispatchNestedPreScroll只是用来表示此时滑动距离已经产生,询问父View是否要消耗距离。其中mScrollConsumed
变量里面存储的就是父View消耗的距离。
我们来看看子View是怎么将产生的滑动距离传递到父View里面的,这个还是结合NestedScrollingChildHelper
来看,因为子View的dispatchNestedPreScroll方法最终会调用到NestedScrollingChildHelper
的dispatchNestedPreScroll方法里面来。
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) { if (mTempNestedScrollConsumed == null) { mTempNestedScrollConsumed = new int[2]; } consumed = mTempNestedScrollConsumed; } 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; }
整个事件传递能够顺利进行的前提还是isNestedScrollingEnabled返回为true。整个方法的执行比较简单,在这里面会调用会调用父View的onNestedPreScroll方法来询问父View是否消耗距离,其中父View消耗的距离保存在consumed数组,然后根据父View消耗的距离来计算,此时子View还有多少能够消耗,具体计算就是差值计算,比较简单。最后这个方法的返回值true表示父View消耗了距离,包括全部消耗和部分消耗两种情况。
整个dispatchNestedPreScroll方法过程还是比较简单的。我们再来看看当RecyclerView消耗了父View未消耗的那部分距离之后,会发生什么。
当RecyclerView滑动完毕之后,会调用dispatchNestedScroll方法来通知父View,自己已经滑动完毕了。具体来看看代码:
boolean scrollByInternal(int x, int y, MotionEvent ev) { ······ if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset, TYPE_TOUCH)) { // Update the last touch co-ords, taking any scroll offset into account mLastTouchX -= mScrollOffset[0]; mLastTouchY -= mScrollOffset[1]; if (ev != null) { ev.offsetLocation(mScrollOffset[0], mScrollOffset[1]); } mNestedOffsets[0] += mScrollOffset[0]; mNestedOffsets[1] += mScrollOffset[1]; } ······ }
整个过程还是简单,事件传递通过NestedScrollingChildHelper
来进行的,这里就不在进行分析了。
剩下的fling事件,stop事件,这些都与上面类似,这里就不在多说了。
(3). ViewParentCompat
在分析事件是如何传递到父View的时候,我们发现ViewParentCompat在这个过程中扮演着重要的角色,前面只是说了使用ViewParentCompat是为了系统的兼容。让我们来看看ViewParentCompat是如何来保证系统的兼容性的。这里就拿ViewParentCompat的startNestedScroll方法来进行分析,其他方法也是如此。
public static boolean onStartNestedScroll(ViewParent parent, View child, View target, int nestedScrollAxes, int type) { if (parent instanceof NestedScrollingParent2) { // First try the NestedScrollingParent2 API return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target, nestedScrollAxes, type); } else if (type == ViewCompat.TYPE_TOUCH) { // Else if the type is the default (touch), try the NestedScrollingParent API return IMPL.onStartNestedScroll(parent, child, target, nestedScrollAxes); } return false; }
我们看到的首先判断当前View是否实现了NestedScrollingParent2
接口,如果实现的话了,直接回调到NestedScrollingParent2
的onStartNestedScroll方法。之前我们说过NestedScrollingParent
接口,而这个NestedScrollingParent2
是什么东西?我们来看看NestedScrollingParent2的声明:
public interface NestedScrollingParent2 extends NestedScrollingParent { ······}
我们发现NestedScrollingParent2
接口继承了NestedScrollingParent
接口,相比于NestedScrollingParent
接口,NestedScrollingParent2
重载了NestedScrollingParent
接口的几个方法,其他的就没有什么区别了。
我们还是来看看这部分的含义吧:
else if (type == ViewCompat.TYPE_TOUCH) { // Else if the type is the default (touch), try the NestedScrollingParent API return IMPL.onStartNestedScroll(parent, child, target, nestedScrollAxes); } ······ static final ViewParentCompatBaseImpl IMPL; static { if (Build.VERSION.SDK_INT >= 21) { IMPL = new ViewParentCompatApi21Impl(); } else if (Build.VERSION.SDK_INT >= 19) { IMPL = new ViewParentCompatApi19Impl(); } else { IMPL = new ViewParentCompatBaseImpl(); } }
其中ViewParentCompatApi21Impl
和ViewParentCompatApi19Impl
都继承于ViewParentCompatBaseImpl
,所以我们来看看ViewParentCompatBaseImpl
的onStartNestedScroll
方法。
public boolean onStartNestedScroll(ViewParent parent, View child, View target, int nestedScrollAxes) { if (parent instanceof NestedScrollingParent) { return ((NestedScrollingParent) parent).onStartNestedScroll(child, target, nestedScrollAxes); } return false; }
是不是瞬间来了一句卧了个槽?这么简单?就判断了一下是否实现了NestedScrollingParent
接口。从这里得知,如果想要一个父View能够接受到子View传递过来的事件,实现NestedScrollingParent
接口是必要的!
最后,我们发现其实ViewParentCompat根本不是很神秘,其实就是在里面创建不同的对象来支持不同版本的系统。
3. 父View事件的接收和消耗
讲解了子View产生和传递事件之后,可能对这个嵌套滑动还是一脸懵逼。不要着急,当我们将整个机制梳理通,就柳暗花明了。
在系统中,没有特定ViewGroup用来接收和消耗子View传递的事件。因此,只能自己动手了。
public class NestedScrollLinearLayout extends LinearLayout implements NestedScrollingParent { private static final int OFFSET = 200; private NestedScrollingParentHelper mNestedScrollingParentHelper; public NestedScrollLinearLayout(Context context) { super(context); } public NestedScrollLinearLayout(Context context, @Nullable AttributeSet attrs) { super(context, attrs); } public NestedScrollLinearLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; } @Override public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { //向下 if (dy < 0) { if (getTranslationY() >= 0) { consumed[0] = 0; consumed[1] = (int) Math.max(getTranslationY() - OFFSET, dy); setTranslationY(getTranslationY() - consumed[1]); } } else { if (getTranslationY() <= OFFSET) { consumed[0] = 0; consumed[1] = (int) Math.min(dy, getTranslationY()); setTranslationY(getTranslationY() - consumed[1]); } } } @Override public void onNestedScrollAccepted(View child, View target, int axes) { getNestedScrollingParentHelper().onNestedScrollAccepted(child, target, axes); } @Override public void onStopNestedScroll(View child) { getNestedScrollingParentHelper().onStopNestedScroll(child); } @Override public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) { } @Override public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { return false; } @Override public boolean onNestedPreFling(View target, float velocityX, float velocityY) { return false; } private NestedScrollingParentHelper getNestedScrollingParentHelper() { if (mNestedScrollingParentHelper == null) { mNestedScrollingParentHelper = new NestedScrollingParentHelper(this); } return mNestedScrollingParentHelper; }}
如上的代码就是实现了上面Demo图片中的效果。在整个实现过程中,我们发现,我们只对onStartNestedScroll方法和onNestedPreScroll方法做了我们自己的实现,其他的要么空着,要么就是通过NestedScrollingParentHelper来帮助我们来实现。整个过程比较清晰和明了。
不过,这其中,我们需要注意的是,每个方法的含义和调用的时机。onStartNestedScroll
方法对应子View的startNestedScroll
方法,当子View调用startNestedScroll
方法会回调父View的onStartNestedScroll
方法。其他方法也是类似的,不过需要注意的是,通常子View的方法都是以dispatch开头的,父View的方法都是以on开头的。
对于NestedScrollingParnet这一块,感觉没有需要注意的,因为这部分需要咱们自己实现,而实现这部分的功能,需要了解子View的是怎么将事件传递到父View。
5. 总结
最后来对Android里面的嵌套滑动做一个简单的总结。
1.跟传统的事件分发不同,嵌套滑动是由子View传递给父View,是从下到上的,传统事件的分发是从上到下的。
2.如果一个View想要传递嵌套滑动的事件,有两个前提:实现NestedScrollingChild接口;setNestedScrollingEnabled方法设置为true。如果一个ViewGroup想要接收和消耗子View传递过来的事件,必须实现NestedScrollingParent接口。
原著是一个有趣的人,若有侵权,请通知删除
还没有人抢沙发呢~