Android 嵌套滑动及CoordinatorLayout源码分析

原创 2018年04月16日 18:06:36

问题分析

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

    public boolean startNestedScroll(int axes) {
        if (hasNestedScrollingParent()) {
            // Already in progress
            return true;
        }
        //是否支持嵌套滑动
        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);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }

遍历父控件,调用父控件的onStartNestedScroll,返回true表示找到了对应的父控件,找到父控件后马上调用onNestedScrollAccepted。那么问题来了,CoordinatorLayout作为父控件,它的onStartNestedScroll方法什么时候会返回true?

    @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        boolean handled = false;

        final int 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 dispatch
                continue;
            }
            final LayoutParams lp = (LayoutParams) view.getLayoutParams();
            final Behavior viewBehavior = lp.getBehavior();
            //如果子控件的Behavior不为空,则触发子控件Behavior的onStartNestedScroll方法
            if (viewBehavior != null) {
                final boolean 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源码:

public static abstract class Behavior<V extends View> {

    public Behavior() { }

    public Behavior(Context context, AttributeSet attrs) {}


    public boolean layoutDependsOn(CoordinatorLayout parent, V child, View dependency) { return false; }

    public boolean onDependentViewChanged(CoordinatorLayout parent, V child, View dependency) {  return false; }

    public void onDependentViewRemoved(CoordinatorLayout parent, V child, View dependency) {}


    public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout,
            V child, View directTargetChild, View target, int nestedScrollAxes) {
        return false;
    }

    public void onNestedScrollAccepted(CoordinatorLayout coordinatorLayout, V child,
            View directTargetChild, View target, int nestedScrollAxes) {
        // Do nothing
    }

    public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target) {
        // Do nothing
    }

    public void onNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target,
            int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
        // Do nothing
    }

    public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, V child, View target,
            int dx, int dy, int[] consumed) {
        // Do nothing
    }

    public boolean onNestedFling(CoordinatorLayout coordinatorLayout, V child, View target,
            float velocityX, float velocityY, boolean consumed) {
        return false;
    }

    public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, V child, View target,
            float velocityX, float velocityY) {
        return false;
    }
}

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方法,其源码为:

    public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes) {
        mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, nestedScrollAxes);
        mNestedScrollingDirectChild = child;
        mNestedScrollingTarget = target;

        final int 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轴的横向滑动不再过多分析)

    public boolean dispatchNestedPreScroll(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;
            } else if (offsetInWindow != null) {
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }

因为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 的一次细节较真

Android源码分析视频

-
  • 1970年01月01日 08:00

CoordinatorLayout中的嵌套滑动和Behavior

下图是CoordinatorLayout布局中很常见的一种效果,很多人应该都见过,当我们用手指滑动RecyclerView的时候,不单止RecyclerView会上下滑动,顶部的Toolbar也会随着...
  • weixin_37077539
  • weixin_37077539
  • 2017-01-14 22:18:56
  • 2109

CoordinatorLayout与NestedScrollView嵌套RecyclerView使用中的坑

做一个详情页面的时候,遇到了上述使用方式的坑,上滑的时候RecyclerView上滑了,但是并没有与头部也就是AppBarLayout产生联动,头部没有上拉了。 这个问题的解决方法是调用Recycl...
  • qq_21265915
  • qq_21265915
  • 2017-03-09 21:28:12
  • 3988

CoordinatorLayout与NestedScrollView嵌套RecyclerView滑动问题

上滑的时候RecyclerView上滑了,但是并没有与头部也就是AppBarLayout产生联动,头部没有上拉了。 这个问题的解决方法是调用RecyclerView中的setNestedScroll...
  • cy123cy456cy
  • cy123cy456cy
  • 2017-08-15 16:21:28
  • 731

5CoordinatorLayout与AppBarLayout--嵌套滑动

5CoordinatorLayout与AppBarLayout–嵌套滑动上文我们说了AppBarLayout的简单滑动,本篇主要介绍CoordinatorLayout下的嵌套滑动相关知识,本文对此做介...
  • litefish
  • litefish
  • 2016-09-19 20:37:38
  • 2652

CoordinatorLayout+ViewPager+SwipeRefreshLayout滑动事件冲突的处理

只是一个搬运工 链接 参考 代码(自己加的注释,好多不懂,勿喷) public class NestedScrollSwipeRefreshLayout extends Swi...
  • baidu_26796327
  • baidu_26796327
  • 2016-03-05 15:27:37
  • 4427

ViewPager与CoordinatorLayout一起使用的一个Bug

本文记录一个关于ViewPager与CoordinatorLayout一起使用的Bug,目前虽然有解决问题的方法,但是原因依然没有找到。最初的布局是正常的项目最初的布局树是这样的:Coordinato...
  • maosidiaoxian
  • maosidiaoxian
  • 2017-09-21 14:27:06
  • 1937

一句代码解决CoordinatorLayout+AppBarLayout+NestedScrollView滑动不流畅的问题

最近项目用到了CoordinatorLayout+AppBarLayout可以变化的状态栏的效果,就在网上查找资料来实现。 但是加入多条数据之后就能看到问题,整个界面滑动效果很差。向上滑动没有惯性,...
  • a_yue10
  • a_yue10
  • 2017-11-28 13:16:12
  • 1271

android 多重滑动 CoordinatorLayout使用

一、CoordinatorLayout有什么作用 CoordinatorLayout作为“super-powered FrameLayout”基本实现两个功能:  1、作为顶层布局  2、调度协调子...
  • Android_yyx
  • Android_yyx
  • 2017-04-19 10:29:41
  • 133

关于CoordinatorLayout和ListView滑动冲突的解决

最近项目中使用到了CoordinatorLayout这种布局方式,搭配RecycleView,实现起来比较简单,而且不用自己处理滑动事件,但是改为了ListView后发生了滑动冲突. 所以想到了以下解...
  • u014602775
  • u014602775
  • 2017-07-06 14:55:25
  • 2474
收藏助手
不良信息举报
您举报文章:Android 嵌套滑动及CoordinatorLayout源码分析
举报原因:
原因补充:

(最多只允许输入30个字)