问题分析
嵌套滑动一直是Android中比较棘手的问题,根本原因是Android的事件分发机制导致的:当子控件消费了事件, 那么父控件就不会再有机会处理这个事件了。 所以一旦内部的滑动控件消费了滑动操作, 外部的滑动控件就再也没机会响应这个滑动操作了。
如何解决?
不过这个问题终于在LOLLIPOP(SDK21)之后终于有了官方的解决方法,就是嵌套滑动机制。嵌套滑动的基本原理是在子控件接收到滑动一段距离的请求时,先询问父控件是否要滑动,如果需要滑动就通知子控件它消耗了一部分滑动距离,子控件就只处理剩下的滑动距离,然后子控件滑动完毕后再把剩余的滑动距离传给父控件。
关于兼容
SDK21之后,嵌套滑动的相关逻辑作为普通方法直接写进了最新的View和ViewGroup类中;
SDK21之前,官方在android.support.v4兼容包中提供了两个接口NestedScrollingChild和NestedScrollingParent,还有两个辅助类NestedScrollingChildHelper和NestedScrollingParentHelper来帮助控件实现嵌套滑动。简单来说就是,在接口方法内对应调用辅助类的方法就可以兼容嵌套滑动了。
所以为了兼容低版本, 处理嵌套滑动更常用到的是后者调用接口方法的方式。
相关方法
NestedScrollingChild
startNestedScroll : 起始方法,主要作用是找到接收滑动距离信息的外控件。
dispatchNestedPreScroll : 在内控件处理滑动前把滑动信息分发给外控件。
dispatchNestedScroll : 在内控件处理完滑动后把剩下的滑动距离信息分发给外控件。
stopNestedScroll : 结束方法, 主要作用就是清空嵌套滑动的相关状态。
setNestedScrollingEnabled和isNestedScrollingEnabled : 一对get&set方法,用来判断控件是否支持嵌套滑动。
dispatchNestedPreFling和dispatchNestedFling : 跟Scroll的对应方法作用类似,不过分发的不是滑动信息而是Fling信息。本文主要关注滑动的处理, 所以后续不分析这两个方法。
从上面方法可以看出,内控件是嵌套滑动的发起者.。
NestedScrollingParent
onStartNestedScroll : 对应startNestedScroll,内控件通过调用外控件的这个方法来确定外控件是否接收滑动信息。
onNestedScrollAccepted : 当外控件确定接收滑动信息后该方法被回调,可以让外控件针对嵌套滑动做一些前期工作。
onNestedPreScroll : 关键方法,接收内控件处理滑动前的滑动距离信息,在这里外控件可以优先响应滑动操作,消耗部分或者全部滑动距离。
onNestedScroll : 关键方法,接收内控件处理完滑动后的滑动距离信息,在这里外控件可以选择是否处理剩余的滑动距离。
onStopNestedScroll : 对应stopNestedScroll,用来做一些收尾工作。
getNestedScrollAxes : 返回嵌套滑动的方向,区分横向滑动和竖向滑动。
onNestedPreFling和onNestedFling : 同上略。
从上面方法可以看出,外控件的大部分方法都是被内控件的对应方法回调的。内控件是发起者,外控件是回调者。
通过CoordinatorLayout看嵌套机制
注意:下文所指的CoordinatorLayout(父控件)、RecyclerView(内控件)以及ImageView(子控件)均为Android 自定义CoordinatorLayout.Behavior 实现悬浮控件动画中控件。
CoordinatorLayout是android support design推出的新布局,主要作为视图根布局,用于协调子控件之间的交互。
这里将通过CoordinatorLayout、RecyclerView以及一个CoordinatorLayout的直接子控件ImageView实现的动画效果(Android 自定义CoordinatorLayout.Behavior 实现悬浮控件动画),对CoordinatorLayout进行源码分析的同时,探索嵌套滑动机制的实现原理。
上面已经说了嵌套滑动是从startNestedScroll开始,所以在RecyclerView找出调用这个方法的地方。
public boolean onTouchEvent(MotionEvent e) {...switch (action) {
case MotionEvent.ACTION_DOWN: {...
startNestedScroll(nestedScrollAxis);
}break;...
}...return true;
}
因为ACTION_DOWN是滑动操作的开始事件,所以当接收到这个事件的时候尝试找对应的父控件。只有找到了父控件才有后续的嵌套滑动的逻辑发生。
接着我们看startNestedScroll是如何找对应的父控件的,因为RecyclerView#startNestedScroll调用了辅助方法的startNestedScroll, 所以下面直接贴NestedScrollingChildHelper#startNestedScroll。
publicbooleanstartNestedScroll(int axes) {if (hasNestedScrollingParent()) {// Already in progressreturntrue;
}//是否支持嵌套滑动if (isNestedScrollingEnabled()) {
ViewParent p = mView.getParent();
View child = mView;//遍历寻找父控件while (p !=null) {//调用外控件的onStartNestedScroll方法来确定外控件是否接收滑动信息if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
mNestedScrollingParent = p;//外控件确定接收滑动信息后onNestedScrollAccepted被回调
ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);returntrue;
}if (pinstanceof View) {
child = (View) p;
}
p = p.getParent();
}
}returnfalse;
}
遍历父控件,调用父控件的onStartNestedScroll,返回true表示找到了对应的父控件,找到父控件后马上调用onNestedScrollAccepted。那么问题来了,CoordinatorLayout作为父控件,它的onStartNestedScroll方法什么时候会返回true?
@OverridepublicbooleanonStartNestedScroll(View child, View target,int nestedScrollAxes) {boolean handled =false;finalint childCount = getChildCount();for (int i =0; i < childCount; i++) {final View view = getChildAt(i);if (view.getVisibility() == View.GONE) {// If it's GONE, don't dispatchcontinue;
}final LayoutParams lp = (LayoutParams) view.getLayoutParams();final Behavior viewBehavior = lp.getBehavior();//如果子控件的Behavior不为空,则触发子控件Behavior的onStartNestedScroll方法if (viewBehavior !=null) {finalboolean accepted = viewBehavior.onStartNestedScroll(this, view, child, target,
nestedScrollAxes);
handled |= accepted;
lp.acceptNestedScroll(accepted);
}else {
lp.acceptNestedScroll(false);
}
}return handled;
}
以上是CoordinatorLayout#onStartNestedScroll方法的源码。可以看到,只有当子控件Behavior的onStartNestedScroll方法返回为true时,CoordinatorLayout#onStartNestedScroll才会返回true。那么问题又来了,Behavior又是什么鬼?知之为知之,不知官网知:
可以看到Behavior 是针对 CoordinatorLayout 中 child 的交互插件。记住这个词:插件。插件也就代表如果一个 child 需要某种交互,它就需要加载对应的 Behavior,否则它就是不具备这种交互能力的。而 Behavior 本身是一个抽象类,它的实现类都是为了能够让用户作用在一个 View 上进行拖拽、滑动、快速滑动等手势。如果自己要定制某个交互动作,就需要自己实现一个 Behavior。再来看Behavior源码:
publicstaticabstractclass Behavior<V extends View> {publicBehavior() { }publicBehavior(Context context, AttributeSet attrs) {}public booleanlayoutDependsOn(CoordinatorLayout parent, V child, View dependency) {returnfalse; }public booleanonDependentViewChanged(CoordinatorLayout parent, V child, View dependency) {returnfalse; }publicvoidonDependentViewRemoved(CoordinatorLayout parent, V child, View dependency) {}public booleanonStartNestedScroll(CoordinatorLayout coordinatorLayout,
V child, View directTargetChild, View target,int nestedScrollAxes) {returnfalse;
}publicvoidonNestedScrollAccepted(CoordinatorLayout coordinatorLayout, V child,
View directTargetChild, View target,int nestedScrollAxes) {// Do nothing
}publicvoidonStopNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target) {// Do nothing
}publicvoidonNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target,int dxConsumed,int dyConsumed,int dxUnconsumed,int dyUnconsumed) {// Do nothing
}publicvoidonNestedPreScroll(CoordinatorLayout coordinatorLayout, V child, View target,int dx,int dy,int[] consumed) {// Do nothing
}public booleanonNestedFling(CoordinatorLayout coordinatorLayout, V child, View target,float velocityX,float velocityY, boolean consumed) {returnfalse;
}public booleanonNestedPreFling(CoordinatorLayout coordinatorLayout, V child, View target,float velocityX,float velocityY) {returnfalse;
}
}
Behavior 其实是 CoordinatorLayout 中的一个静态内部类,并且是个泛型,接受任何 View 类型。
一般我们自定义一个 Behavior,目的有两个。
一是根据某些依赖的 View 的位置进行相应的操作(本文主要分析嵌套滑动的处理,所以View之间的依赖关系不再具体分析)。
相关方法:
layoutDependsOn
onDependentViewChanged
onDependentViewRemoved
另外一个就是响应 CoordinatorLayout 中某些组件的滑动事件。
相关方法:
onStartNestedScroll
onNestedScrollAccepted
onStopNestedScroll
onNestedScroll
onNestedPreScroll
onNestedFling
onNestedPreFling
有木有很眼熟的感觉?没错,和开始提到的NestedScrollingParent相关方法名字一模一样。所以这里就解决了刚才的疑问。当CoordinatorLayout子控件Behavior的onStartNestedScroll方法返回为true时,CoordinatorLayout的onStartNestedScroll方法才返回true。至于子控件Behavior的onStartNestedScroll方法返回true还是false,就要看你如何实现嵌套滑动的逻辑了。在Android 自定义CoordinatorLayout.Behavior 实现悬浮控件动画中,我对ImageView的Behavior#onStartNestedScroll方法返回值的定义是,只要竖直方向滑动就返回true。
再回到刚刚的研究中,这时候调用了父控件的onStartNestedScroll方法返回true,内控件RecyclerView找到父控件CoordinatorLayout后马上调用CoordinatorLayout#onNestedScrollAccepted方法,其源码为:
publicvoidonNestedScrollAccepted(View child, View target,int nestedScrollAxes) {
mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, nestedScrollAxes);
mNestedScrollingDirectChild = child;
mNestedScrollingTarget = target;finalint childCount = getChildCount();for (int i =0; i < childCount; i++) {final View view = getChildAt(i);final LayoutParams lp = (LayoutParams) view.getLayoutParams();if (!lp.isNestedScrollAccepted()) {continue;
}final Behavior viewBehavior = lp.getBehavior();if (viewBehavior !=null) {
viewBehavior.onNestedScrollAccepted(this, view, child, target, nestedScrollAxes);
}
}
}
这次就简单了,和onStartNestedScroll方法一个尿性,还是调用子控件Behavior#onNestedScrollAccepted呗,这里就不再过多分析,只需知道该方法是做一些前期的准备工作,可有可无。
找到了父控件后ACTION_DOWN事件就没嵌套滑动的事了,要滑动肯定会在onTouchEvent中处理ACTION_MOVE事件,接着我们看RecyclerView的ACTION_MOVE事件具体是怎样处理的。
case MotionEvent.ACTION_MOVE: {...
final int x = (int) (e.getX(index) +0.5f);
final int y = (int) (e.getY(index) +0.5f);
int dx = mLastTouchX - x;
int dy = mLastTouchY - y;
// 让外控件先处理滑动距离if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {
dx -= mScrollConsumed[0];
dy -= mScrollConsumed[1];
vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
// Updated the nested offsets
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
}...if (mScrollState == SCROLL_STATE_DRAGGING) {
mLastTouchX = x - mScrollOffset[0];
mLastTouchY = y - mScrollOffset[1];if (scrollByInternal(
canScrollHorizontally ? dx :0,
canScrollVertically ? dy :0,
vtev)) {
getParent().requestDisallowInterceptTouchEvent(true);
}if (mGapWorker != null && (dx !=0 || dy !=0)) {
mGapWorker.postFromTraversal(this, dx, dy);
}
}
}break;
这部分是RecyclerView能够处理嵌套滑动的关键代码了,其他能够嵌套滑动的控件也应该在ACTION_MOVE中类似地处理滑动距离。
首先计算出本次滑动距离dy,得到滑动距离deltaY后, 先把它传给dispatchNestedPreScroll,然后在结果返回true的时候,dy 会减去mScrollConsumed[1],接着看dispatchNestedPreScroll干了什么。(由于本文实现的效果为上下嵌套滑动,所以关于x轴的横向滑动不再过多分析)
publicbooleandispatchNestedPreScroll(int dx,int dy,int[] consumed,int[] offsetInWindow) {if (isNestedScrollingEnabled() && mNestedScrollingParent !=null) {if (dx !=0 || dy !=0) {
...
consumed[0] =0;
consumed[1] =0;
ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);if (offsetInWindow !=null) {
mView.getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}return consumed[0] !=0 || consumed[1] !=0;
}elseif (offsetInWindow !=null) {
offsetInWindow[0] =0;
offsetInWindow[1] =0;
}
}returnfalse;
}
因为dispatchNestedPreScroll的工作就是把滑动距离在内控件处理前分发给父控件,所以这里的关键代码也很简单,就是直接把相关的参数传给父控件的onNestedPreScroll,然后只要父控件消耗了滑动距离(不论横向还是竖向),就会返回true。而CoordinatorLayout#onNestedPreScroll和之前方法一样,最终调用的是子控件Behavior的onNestedPreScroll方法。所以,CoordinatorLayout消不消耗RecyclerView的滑动距离,完全取决于ImageView的Behavior#onNestedPreScroll方法中的具体实现逻辑。如果CoordinatorLayout想在RecyclerView之前消耗滑动距离,仅需要在ImageView的Behavior#onNestedPreScroll方法中把消耗的值放到数组中即可。
好了, 现在父控件已经比内控件先处理了滑动距离了,如果父控件没有完全消耗掉所有滑动距离,这时该内控件处理剩下的滑动距离了。在RecyclerView中通过RecyclerView#scrollByInternal来进行滑动,并且滑动结束后通过比对滑动前后的dy值得到了内控件消耗的滑动距离,然后得到剩下的滑动距离,最后传给dispatchNestedScroll。
dispatchNestedScroll的逻辑跟dispatchNestedPreScroll几乎一样,区别是RecyclerView调用了父控件CoordinatorLayout的onNestedScroll,CoordinatorLayout的onNestedScroll调用了子控件ImageView的Behavior的onNestedScroll方法。因为到这里已经是处理滑动距离最后的机会了, 所以onNestedScroll不会再影响RecyclerView的处理逻辑了.
到这里ACTION_MOVE事件就分析完毕了。
最后就是stopNestedScroll了,代码就不贴了,调用这个方法基本是新的滑动操作开始前,或者滑动操作结束/取消,代码逻辑就是进行一些变量的重置工作和调用onStopNestedScroll,而onStopNestedScroll也类似。
总结
- 如果要支持嵌套滑动,内控件和父控件要支持对应的方法,为了兼容低版本一般通过实现NestedScrollingChild和NestedScrollingParent接口以及使用NestedScrollingChildHelper和NestedScrollingParent辅助类。
- Behavior是用于CoordinatorLayout的直接子控件来协调自身CoordinatorLayout以及和其他子控件的交互。
- 具体嵌套滑动逻辑主要是在子控件Behavior的onNestedPreScroll和onNestedScroll方法中。
- 父控件通过子控件的Behavior给数组赋值来把消耗的滑动距离传递给内控件(可消耗也可不消耗)。
参考文章:
一点见解: Android嵌套滑动和NestedScrollView
针对 CoordinatorLayout 及 Behavior 的一次细节较真