Android 嵌套滑动及CoordinatorLayout源码分析

2022-07-28 14:27:50

问题分析

嵌套滑动一直是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也类似。

总结

  1. 如果要支持嵌套滑动,内控件和父控件要支持对应的方法,为了兼容低版本一般通过实现NestedScrollingChild和NestedScrollingParent接口以及使用NestedScrollingChildHelper和NestedScrollingParent辅助类。
  2. Behavior是用于CoordinatorLayout的直接子控件来协调自身CoordinatorLayout以及和其他子控件的交互。
  3. 具体嵌套滑动逻辑主要是在子控件Behavior的onNestedPreScroll和onNestedScroll方法中。
  4. 父控件通过子控件的Behavior给数组赋值来把消耗的滑动距离传递给内控件(可消耗也可不消耗)。

参考文章:
一点见解: Android嵌套滑动和NestedScrollView
针对 CoordinatorLayout 及 Behavior 的一次细节较真

  • 作者:little762
  • 原文链接:https://blog.csdn.net/little762/article/details/79964177
    更新时间:2022-07-28 14:27:50