好久没有写博客了,感觉自己的手变得生疏了,今天来记录一下自己对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接口。