CoordinatorLayout 学习(二) - RecyclerView和AppBarLayout的联动分析

  我们都知道,如果想要使用CoordinatorLayout实现折叠布局,只有靠AppBarLayout才会生效。但是我们不禁有一个疑问,就是为什么AppBarLayout能够与RecyclerView联动,它是怎么知道RecyclerView上滑还是下滑的呢?这是本文分析的一个重点。
  本文参考资料:

  1. 针对 CoordinatorLayout 及 Behavior 的一次细节较真
  2. Android 源码分析 - 嵌套滑动机制的实现原理

  由于联动机制是建立在嵌套滑动的基础上,所以在阅读本文之前,建议熟悉一下Android中嵌套滑动的原理,有兴趣的同学也可以参考我上面的文章。
  本文打算采用由浅入深的方式来介绍联动机制,分别包括如下内容:

  1. CoordinatorLayout的分析
  2. Behavior的分析

1. CoordinatorLayout的分析

  在这里,我们先分析一下CoordinatorLayout整体结构,包括三大流程,以及Behavior的相关调用。我们都知道,在CoordinatorLayout中,Behavior是作为一个插件角色存在的,所以我们有必要分析一下,CoordinatorLayout是怎么使用这个插件。熟悉插件的整个流程之后,后续我们在自定义Behavior时就非常容易了。

(1). CoordinatorLayout的三大流程

  CoordinatorLayout的measure过程相较于其他View来说,还是稍微有一点特殊性。CoordinatorLayout作为协调者布局,自然需要处理各个View的依赖关系,所有View的依赖关系形成了图的数据结构,因此每个View测量和布局都可能会受到其他View的影响,所以先测量哪些View,后测量哪些View,这里面需要有特殊的要求,不能通过简单的线性规则来进行。
  因此,CoordinatorLayout的measure过程先要对图进行拓补排序,得到一个线性的数列,然后才能进行下面的操作。我们先来看看CoordinatorLayoutonMeasure方法:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 1. 得到一个图mChildDag,其中存储的是View之间的依赖关系;
        // 同时,还得到一个拓补排序的数组。
        prepareChildren();
        ensurePreDrawListener();
        // 测量每个View
    }

  整个过程我们可以将他分析两步:

  1. 构造依赖关系图,通过拓补排序得到一个数组。
  2. 根据拓补排序得到的数组顺序,来测量每个View。

  在这个过程中,我们可以发现了Behavior的影子,我们来看看代码:

            final Behavior b = lp.getBehavior();
            if (b == null || !b.onMeasureChild(this, child, childWidthMeasureSpec, keylineWidthUsed,
                    childHeightMeasureSpec, 0)) {
                onMeasureChild(child, childWidthMeasureSpec, keylineWidthUsed,
                        childHeightMeasureSpec, 0);
            }

  从上面的代码中,我们可以发现,View会尝试将测量工作交付给它的Behavior,如果Behavior不测量,然后再调用onMeasureChild方法进行测量,这样做什么好处呢?有一个很大的特点就是Behavior的高扩展性,在一些特殊的交互下,这些都是必须的。
  这里我举一个例子,如图:


  上图的布局非常的简单,这里就直接贴代码:

<androidx.coordinatorlayout.widget.CoordinatorLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/coordinator"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:fitsSystemWindows="true">

        <View
                android:id="@+id/view"
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:background="#5FF"
                android:minHeight="50dp"
                app:layout_scrollFlags="scroll|enterAlways|enterAlwaysCollapsed"/>

        <View
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:background="#500"
                android:minHeight="50dp"/>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="@string/appbar_scrolling_view_behavior"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

  效果非常的明显,就是AppBarLayout第一个View会折叠,但是第二个View不会折叠,那么这个就影响到RecyclerView的测量了,正常来说RecyclerView的高度应该等于CoordinatorLayout高度减去第二个View的高度,因为第二个View始终在屏幕当中。同理,如果AppBarLayout只有一个View,同时这个View还能折叠,那么RecyclerView的高度又不一样了。像这种不固定的测量规则,交给每个View的Behavior是最好的。
  同理,布局阶段也是如此,首先会交给Behavior尝试着布局,然后CoordinatorLayout再布局,这里就不详细介绍了。

(2).事件的协调

  CoordinatorLayout被定义为协调者布局,自然要起到协调的作用,那么它在哪里就行协调的呢?最大的体现就是,将子View传递上来的嵌套滑动事件进行分发。我总结一下相关方法:

  1. 嵌套事件开始,会回调onStartNestedScroll方法。
  2. 嵌套滑动开始,会回调onNestedPreScroll方法。
  3. 嵌套滑动结束,会回调onNestedScroll方法。
  4. 嵌套滑动的Fling开始,会回调onNestedPreFling方法。
  5. 嵌套滑动的Fling结束,会回调onNestedFling方法。

  而CoordinatorLayout方法是怎么进行协调的呢?在每个方法的实现里面,都通过每个View的Behavior来分发,每个Behavior在根据实际情况判断是否消费,消费多少。
  我们在自定义Behavior时,还有一个问题存在。就是如果我们使用的自定义View,然后通过一个特殊的方法来滑动该View,在CoordinatorLayout里面将该View作为依赖的View都能随之移动,这种交互是怎么实现的呢?在这种情况下,我们根本不是嵌套滑动来响应的,而是通过一个OnPreDrawListener接口来实现的,这个接口在View执行onDraw方法之前被回调。同理,在这种情况下,只能实现联动,不能实现更多复杂的UI交互。

2.Behavior的分析

  分析Behavior时,我们先来看看它的基本结构,看看它有哪些方法,并且调用时机是什么。

方法名 作用或者调用时机
layoutDependsOn 判断两个是否存在依赖关系。
onDependentViewChanged 当一个View发生变化(包括位置变化等变化)时,依赖其的View的Behavior都会回调这个方法。
onDependentViewRemoved 当一个View被移除时,依赖其的View的Behavior都会回调这个方法。

  Behavior比较常用的方法就是如上的,其实还有嵌套滑动一些列的方法,这里就过多的解释。
  单纯的看基类自然不能深入理解这个类使用方式,我们来看看它的实现类,主要是从两个方面来分析:

  1. AppBarLayout的几个Behavior
  2. RecyclerView常用的ScrollingViewBehavior

(1). AppBarLayout的Behavior分析

  AppBarLayoutBehavior是一个复杂的继承关系,我们先来看看相关类图:


  整个继承关系如上类图,每个类都负责其中一部分的功能,我们来看看:

类名 作用
ViewOffsetBehavior ViewOffsetBehavior的内部,定义了两个方法,分别是setTopAndBottomOffsetsetLeftAndRightOffset,主要用来改变某个View的位置。
HeaderBehavior HeaderBehavior中,主要是实现了两个事件分发相关的方法。在这个类里面,主要处理AppBarLayout本身的事件,比如说,手指在AppBarLayout上面滑动。在这个类里面,有一个非常恶心的设计,就是如果在AppBarLayout上面Fling的话,会将所有的Fling吃掉,不会传递到RecyclerView上面去。我个人感觉,Google爸爸的这个设计有问题,待会详细解释一下。
BaseBeHavior BaseBehavior中,主要是实现了嵌套滑动的相关方法。

  AppBarLayoutBehavior整个结构差不多介绍清楚了,下面我来解释一下,为什么我觉得HeaderBehavior的设计有问题。

首先,我觉得不应该多出来HeaderBehavior这一层。HeaderBehavior主要作用是用来处理AppBarLayout的事件(传统事件),将事件处理放在HeaderBehavior里面有一个很大的缺陷,就是从此以后,AppBarLayout的子View不支持嵌套滑动,因为在AppBarLayout这一层就断了;其次,就是有一个很大的问题,Fling事件在HeaderBehavior里面全部消耗了,本来可以将未消耗的Fling事件传递给RecyclerView的,但是这样的设计却很难将未消耗的Fling传递出去。
我的建议是将这部分事件方法在AppBarLayout内部实现,其中既能保证嵌套滑动不断层,又能保证将未消耗的Fling事件传递到它的Parent中去。

  在这里,我重点分析HeaderBehaviorBaseBeHavior

(A). HeaderBehavior

  HeaderBehavior主要是对AppBarLayout的事件进行处理,这里我们主要看fling事件,看看这里为什么不能将fling事件传递给RecyclerView

      case MotionEvent.ACTION_UP:
        if (velocityTracker != null) {
          velocityTracker.addMovement(ev);
          velocityTracker.computeCurrentVelocity(1000);
          float yvel = velocityTracker.getYVelocity(activePointerId);
          fling(parent, child, -getScrollRangeForDragFling(child), 0, yvel);
        }

  核心关键点就在fling方法的第二个参数和第三个参数,分别表示fling的最小距离和最大距离。因为最大距离是0,所以一旦AppBarLayout滑出屏幕,fling就停止了。
  针对这个问题,有很多解决办法,本文先不做描述,后续我会专门的文章来解决这个问题。

(B). BaseBeHavior

  BaseBeHavior的作用是主要两个:

  1. 处理AppBarLayout的嵌套滑动。
  2. 负责AppBarLayout的测量和布局。

  这里专门分析嵌套滑动,不对测量和布局做分析,因为比较简单。在分析之前,我们先来看AppBarLayout几个方法:

方法 作用或者调用时机
getDownNestedPreScrollRange 计算AppBarLayout能在RecyclerView向下滑动之前,能提前向下滑动的距离。非常直观的感受是,一个View设置了SCROLL_FLAG_ENTER_ALWAYS时,当RecyclerView向下滑动时,该View首先向下滑动。该方法返回的值表示该View能向下滑动多少。
getUpNestedPreScrollRange 作用于getDownNestedPreScrollRange方法差不多,就是它表示向上能滑动的距离。
getDownNestedScrollRange 计算当RecyclerView滑动到顶部之后,AppBarLayout能向下滑动的距离。非常直观的感受是,一个View设置了SCROLL_FLAG_EXIT_UNTIL_COLLAPSED时,当RecyclerView滑动到顶部之后继续滑动时,此时该View会向下滑动。该方法返回的值表示该View能向下滑动多少。
getTotalScrollRange 该方法表示AppBarLayout能滑动的总距离,不区分方向。

  BaseBeHavior主要实现了嵌套滑动的onStartNestedScrollonNestedPreScrollonNestedScroll``onStopNestedScroll这几个方法。接下来,我们来一一分析。
  首先,我们来看看onStartNestedScroll方法:

    @Override
    public boolean onStartNestedScroll(
        CoordinatorLayout parent,
        T child,
        View directTargetChild,
        View target,
        int nestedScrollAxes,
        int type) {
      // Return true if we're nested scrolling vertically, and we either have lift on scroll enabled
      // or we can scroll the children.
      final boolean started =
          (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0
              && (child.isLiftOnScroll() || canScrollChildren(parent, child, directTargetChild));

      if (started && offsetAnimator != null) {
        // Cancel any offset animation
        offsetAnimator.cancel();
      }

      // A new nested scroll has started so clear out the previous ref
      lastNestedScrollingChildRef = null;

      // Track the last started type so we know if a fling is about to happen once scrolling ends
      lastStartedType = type;

      return started;
    }

  这个方法表示意思非常的简单,就是判断AppBarLayout是否需要处理嵌套滑动,其中判断条件分别是,滑动方向是垂直滑动,其次此时还有空间可以滑动。
  然后,我们再来看看onNestedPreScroll方法:

    @Override
    public void onNestedPreScroll(
        CoordinatorLayout coordinatorLayout,
        T child,
        View target,
        int dx,
        int dy,
        int[] consumed,
        int type) {
      if (dy != 0) {
        int min;
        int max;
        if (dy < 0) {
          // We're scrolling down
          min = -child.getTotalScrollRange();
          max = min + child.getDownNestedPreScrollRange();
        } else {
          // We're scrolling up
          min = -child.getUpNestedPreScrollRange();
          max = 0;
        }
        if (min != max) {
          consumed[1] = scroll(coordinatorLayout, child, dy, min, max);
        }
      }
      if (child.isLiftOnScroll()) {
        child.setLiftedState(child.shouldLift(target));
      }
    }

   onNestedPreScroll方法要分为两种情况:1. RecyclerView向下滑动;2.RecyclerVIew向上滑动。这两种情况根据不同的Flag,计算能够滑动的距离。
  再次,就是onNestedScroll方法:

    @Override
    public void onNestedScroll(
        CoordinatorLayout coordinatorLayout,
        T child,
        View target,
        int dxConsumed,
        int dyConsumed,
        int dxUnconsumed,
        int dyUnconsumed,
        int type,
        int[] consumed) {
      if (dyUnconsumed < 0) {
        // If the scrolling view is scrolling down but not consuming, it's probably be at
        // the top of it's content
        consumed[1] =
            scroll(coordinatorLayout, child, dyUnconsumed, -child.getDownNestedScrollRange(), 0);
      }
    }

  这个方法的调用,只需要考虑到一种情况---RecyclerView向上滑动滑动,并且滑到了顶部,此时设置了SCROLL_FLAG_EXIT_UNTIL_COLLAPSEDFlag的View该滑动了。
  最后就是onStopNestedScroll方法:

    @Override
    public void onStopNestedScroll(
        CoordinatorLayout coordinatorLayout, T abl, View target, int type) {
      // onStartNestedScroll for a fling will happen before onStopNestedScroll for the scroll. This
      // isn't necessarily guaranteed yet, but it should be in the future. We use this to our
      // advantage to check if a fling (ViewCompat.TYPE_NON_TOUCH) will start after the touch scroll
      // (ViewCompat.TYPE_TOUCH) ends
      if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) {
        // If we haven't been flung, or a fling is ending
        snapToChildIfNeeded(coordinatorLayout, abl);
        if (abl.isLiftOnScroll()) {
          abl.setLiftedState(abl.shouldLift(target));
        }
      }

      // Keep a reference to the previous nested scrolling child
      lastNestedScrollingChildRef = new WeakReference<>(target);
    }

  onStopNestedScroll方法主要是对设置FLAG_SNAP的View做动画。
  到这里,我们发现一个问题,那就是BaseBeHavior没有重写Fling相关方法,但是实际情况是AppBarLayout能成功响应RecyclerView的Fling事件,这个是怎么实现的呢?
  最初,我以为是BaseBehavior会监听RecyclerView的位置变化,通过onDependentViewChanged方法来响应Fling事件,结果发现BaseBehavior根本没有实现这个方法,那BaseBehavior方法是怎么实现的呢?
  这个问题需要从RecyclerViewViewFlinger找答案。对于不熟悉RecyclerView的同学来说,我来解释一下,ViewFlinger到底是什么。ViewFlinger主要是用来出来RecyclerView的Fling事件的。如果有同学对他感兴趣的话,可以参考我的文章:RecyclerView 源码分析(二) - RecyclerView的滑动机制。在ViewFlinger中有如下一段代码:

                if (dispatchNestedPreScroll(unconsumedX, unconsumedY, mReusableIntPair, null,
                        TYPE_NON_TOUCH)) {
                    unconsumedX -= mReusableIntPair[0];
                    unconsumedY -= mReusableIntPair[1];
                }

  从这段代码里面,我们可以发现,RecyclerView在Fling期间也会调用dispatchNestedPreScroll方法,从而调用到BaseBeHavioronNestedPreScroll方法,所以onNestedPreScroll方法会处理两部分的滑动距离,包括正常滑动和Fling滑动。

(2).RecyclerView的Behavior分析

  RecyclerViewBehavior继承结构与AppBarLayout的类似,我们来看看类图:


  这其中,HeaderScrollingViewBehaviorScrollingViewBehavior方法含义如下:

类名 作用
HeaderScrollingViewBehavior 重写了onMeasureChild方法和onLayoutChild方法,主要负责RecyclerView的测量和布局。
ScrollingViewBehavior 重写了layoutDependsOn方法和onDependentViewChanged方法。主要是负责RecyclerViewAppBarLayout联动。

  接下来,我们一一的来分析。

(A).HeaderScrollingViewBehavior

  在这里,我们重点关注HeaderScrollingViewBehavior测量时如何考虑到AppBarLayout的有效高度,具体代码如下:

        int height = availableHeight + getScrollRange(header);
        int headerHeight = header.getMeasuredHeight();
        if (shouldHeaderOverlapScrollingChild()) {
          child.setTranslationY(-headerHeight);
        } else {
          height -= headerHeight;
        }

  我们发现,在计算RecyclerView的高度时,还加上了AppBarLayout的可以滑动的距离。也就是说,当我们首次进入界面时,表面上看RecyclerView布满屏幕,其实还有一部分在屏幕呢。
  同样的,布局也是考虑到AppBarLayout的,这里就不分析了。

(B). ScrollingViewBehavior

  ScrollingViewBehavior主要负责RecyclerViewAppBarLayout的联动,关键代码在于onDependentViewChanged方法:

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
      offsetChildAsNeeded(child, dependency);
      updateLiftedStateIfNeeded(child, dependency);
      return false;
    }

  具体的实现这里就不分析了,非常的简单。

3. 总结

  到这里,本文的介绍结束了,这里做本文的内容做一个简单的总结。

  1. CoordinatorLayout在测量阶段,会生成一个View的依赖图,然后对这个依赖图进行拓补排序得到一个数组,测量和layout的顺序都依据一个数组的。
  2. CoordinatorLayout测量和布局View的工作首先会交给每个View的Behavior,如果不处理才自己处理。
  3. AppBarLayoutBehavior分为三层,分别是:ViewOffsetBehavior,方便改变View的位置;HeaderBehavior用来处理AppBarLayout自身的事件;BaseBeHavior用来处理嵌套滑动的事件。RecyclerViewBehavior也分为三层:第一层与AppBarLayout的一样;HeaderScrollingViewBehavior负责RecyclerView的测量和布局;ScrollingViewBehavior处理RecyclerViewAppBarLayout的联动。

  如果不出意外的话,下篇文章我将介绍怎么自定义Behavior和处理AppBarLayout的fling事件。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容