前言
在上篇文章中,我们介绍了NestedScrolling(嵌套滑动)机制,介绍了子控件与父控件嵌套滑动的处理。现在我们来了解谷歌大大为我们提供的另一个控件的交互布局CoordainatorLayout
。CoordainatorLayout对于Android开发老司机来说肯定不会陌生,作为控制内部一个或多个的子控件协同交互的容器,开发者可以通过设置Behavior去控制多个控件的协同交互效果,测量尺寸、布局位置及触摸响应。作为谷歌推出的明星组件,分析CoordainatorLayout的文章已是数不胜数。而分析整个CoordainatorLayout原理的相关资料在网上很少,因此本文会把重点放在分析其内部原理上。
通过阅读该文,你能了解如下知识点:
- CoordainatorLayout中Behavior中的基础使用
- CoordainatorLayout中多个控件协同交互的原理
- CoordainatorLayout中Behavior的实例化过程
- Behavior实现嵌套滑动的原理与过程
- Behavior自定义布局的时机与过程
- Behavior自定义测量的时机与过程
该博客中涉及到的示例,在NestedScrollingDemo项目中都有实现,大家可以按需自取。
CoordainatorLayout简介
熟悉CoordinatorLayout的小伙伴,肯定知道CoordinatorLayout主要实现以下四个功能:
- 处理子控件的依赖下的交互
- 处理子控件的嵌套滑动
- 处理子控件的测量与布局
- 处理子控件的事件拦截与响应。
而上述四个功能,都依托于CoordainatorLayout中提供的一个叫做Behavior
的“插件”。Behavior内部也提供了相应方法来对应这四个不同的功能,具体如下所示:
在下面的文章中不会介绍Behavior嵌套滑动相关方法的作用,如果需要了解这些方法的作用,建议参看自定义View事件之进阶篇(一)-NestedScrolling(嵌套滑动)机制文章下的方法介绍。
那现在我们就一起来看看,谷歌是怎么围绕Behavior对上述四个功能进行设计的把。
子控件依赖下的交互设计
对于子控件的依赖交互,谷歌是这样设计的:
当CoordainatorLayout中子控件(childView1)的位置、大小等发生改变的时候,那么在CoordainatorLayout内部会通知所有依赖childView1的控件,并调用对应声明的Behavior,告知其依赖的childView1发生改变。那么如何判断依赖,接受到通知后如何处理。这些都交由Behavior来处理。
子控件的嵌套滑动的设计
对于子控件的嵌套滑动,谷歌是这样设计的:
CoordinatorLayout实现了NestedScrollingParent2接口。那么当事件(scroll或fling)产生后,内部实现了NestedScrollingChild接口的子控件会将事件分发给CoordinatorLayout,CoordinatorLayout又会将事件传递给所有的Behavior。接着在Behavior中实现子控件的嵌套滑动。那么再结合上文提到的Behavior中嵌套滑动的相关方法,我们可以得到如下流程:
观察谷歌的设计,我们可以发现,相对于NestedScrolling机制(参与角色只有子控件和父控件),CoordainatorLayout中的交互角色更为丰富,在CoordainatorLayout下的子控件可以与多个兄弟控件进行交互。
子控件的测量、布局、事件的设计
看了谷歌对子控件的嵌套滑动设计,我们再来看看子控件的测量、布局、事件的设计:
因为CoordainatorLayout主要负责的是子控件之间的交互,内部控件的测量与布局,就简单的类似FrameLayout处理方式就好了。在特殊的情况下,如子控件需要处理宽高和布局的时候,那么交由Behavior内部的onMeasureChild
与onLayoutChild
方法来进行处理。同理对于事件的拦截与处理,如果子控件需要拦截并消耗事件,那么交由给Behavior内部的onInterceptTouchEvent
与onTouchEvent
方法进行处理。
可能有的小伙伴会想,为什么会将这四种功能对于的方法将这些功能都交由Behavior实现。其实原因非常简单,因为将所有功能都对应在Behavior中,那么对于子控件来说,这种插件化的方式就非常解耦了,我们的子控件无需将效果写死在自身中,我们只需要对应不同的Behavior,就可以实现不同的效果了。如下所示:
CoordainatorLayout下的多个子控件的依赖交互
了解了CoordainatorLayout中四种功能的设计后,现在我们通过一个例子来讲解CoordainatorLayout下多个子控件的交互。在讲解具体的例子之前,我们先回顾一下Behavior中对子控件依赖交互提供的方法。如下所示:
1 | public boolean layoutDependsOn(CoordinatorLayout parent, V child, View dependency) { return false; } |
layoutDependsOn方法介绍:
确定一个控件(childView1)依赖另外一个控件(childView2)的时候,是通过layoutDependsOn
这个方法。其中child是依赖对象(childView1),而dependency是被依赖对象(childView2),该方法的返回值是判断是否依赖对应view。如果返回true。那么表示依赖。反之不依赖。一般情况下,在我们自定义Behavior时,我们需要重写该方法。当layoutDependsOn
方法返回true时,后面的onDependentViewChanged
与onDependentViewRemoved
方法才会调用。
onDependentViewChanged方法介绍:
当一个控件(childView1)所依赖的另一个控件(childView2)位置、大小发生改变的时候,该方法会调用。其中该方法的返回值,是由childView1来决定的,如果childView1在接受到childView2的改变通知后,childView1的位置或大小发生改变,那么就返回true,反之返回false。
onDependentViewRemoved方法介绍:
当一个控件(childView1)所依赖的另一个控件(childView2)被删除的时候,该方法会调用。
Demo展示
下面我们就看一种简单的例子,来讲解在使用CoordainatorLayout下各个兄弟控件之间的依赖产生的交互效果。
在上述Demo中,我们创建了一个随手势滑动的DependedView
,并设定了另外两个依赖DependedView的TextView的Behavior,BrotherChameleonBehavior(变色小弟)与BrotherFollowBehavior(跟随小弟)。具体代码如下所示:
1 | public class DependedView extends View { |
DependedView逻辑非常简单,就是重写了onTouchEvent,监听滑动,并设置DependedView的位置。我们继续查看另外两个TextView的Behavior。
BrotherChameleonBehavior(变色小弟)代码如下所示:
在CoordainatorLayout中要实现子控件的依赖交互,我们需要继承CoordinatorLayout.Behavior。实现layoutDependsOn、onDependentViewChanged、onDependentViewRemoved这三个方法,因为我们例子中不设计关于依赖控件的删除,故没有重写onDependentViewRemoved方法。
1 | public class BrotherChameleonBehavior extends CoordinatorLayout.Behavior<View> { |
BrotherFollowBehavior(跟随小弟)代码如下所示:
1 | public class BrotherFollowBehavior extends CoordinatorLayout.Behavior<View> { |
比较重要的布局文件怎么能忘了呐,对应的布局如下:
1 |
|
原理讲解
大家肯定会很好奇,为什么简简单单的设置了两个Behavior,DependedView位置发生改变的时候就能通知依赖的两个TextView呢?这要从DependedView的onTouchEvent方法说起。在onTouchEvent方法中,我们根据手势修改了DependedView的位置,我们都知道当子控件位置、大小发生改变的时候,会导致父控件重绘。也就是会调用onDraw
方法。而CoordainatorLayout在onAttachedToWindow
中使用了ViewTreeObserver
,并设置了绘制前监听器OnPreDrawListener
。如下所示:
1 |
|
熟悉ViewTreeObserver的小伙伴一定清楚,该类主要是监测整个View树的变化(这里的变化指View树的状态变化,或者内部的View可见性变化等),我们继续跟踪OnPreDrawListener,查看CoordainatorLayou在绘制前做了什么。
1 | class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener { |
我们发现其内部调用了onChildViewsChanged(EVENT_PRE_DRAW);
方法。我们继续查看该方法。
1 | final void onChildViewsChanged(@DispatchChangeEvent final int type) { |
观察代码,我们发现程序中使用了一个名为mDependencySortedChildren
的集合,通过遍历该集合,我们可以获取集合中控件的LayoutParam
,得到LayoutParam后,我们可以继续获取相应的Behavior
。并调用其layoutDependsOn
方法找到所依赖的控件,如果找到了当前控件所依赖的另一控件,那么就调用Behavior中的onDependentViewChanged
方法。
看到这里,多个控件依赖交互的原理已经非常清楚了,在CoordainatorLayout下,控件A发生位置、大小改变时,会导致CoordainatorLayout重绘。而CoordainatorLayout又设置了绘制前的监听。在该监听中,会遍历mDependencySortedChildren
集合,找到依赖A控件的其他控件。并通知其他控件A控件发生了改变。当其他控件收到该通知后。就可以做自己想做的效果啦。
关于mDependencySortedChildren
中存储的到底是什么数据还没有介绍,现在我们就来看看这个集合中是存储了什么东西。查看源码,我们发现mDependencySortedChildren
中的元素是在onMeasure方法中的prepareChildren()
中进行添加的,
1 |
|
我们继续跟踪prepareChildren()方法。代码如下所示:
1 | private void prepareChildren() { |
在prepareChildren方法中,会遍历内部所有的子控件,并将子控件添加到mChildDag
集合中,mChildDag
的数据结构一种叫图的数据结构。通过这种数据结构,我们可以快速的找到具有依赖关系控件。当将子控件的依赖关系处理完毕后。方法最后会将mChildDag
集合中全部的数据添加到mDependencySortedChildren集合中去,这样我们的mDependencySortedChildren
就有相应数据啦。
Behavior的实例化
现在我们来讲解下一个知识点,在上述文章中,我们描述了CoordainatorLayout中子控件的依赖交互原理,以及Behavior依赖相关方法的调用时机,我们并没有讲解Behavior是何时被实例化的。下面我们就来看看Behavior是如何被实例化的。
查看oordainatorLayout源码,我们发现在CoordainatorLayout中自定义了布局参数LayoutParams
。并且在LayoutParms类中声明了Behavior
成员变量。如下所示:
1 | public static class LayoutParams extends MarginLayoutParams { |
CoordainatorLayout还重写了generateLayoutParams
方法。
1 |
|
熟悉自定义View的小伙伴一定熟悉generateLayoutParams
方法。当我们自定义ViewGroup时,如果希望我们的子控件需要一些特殊的布局参数或一些特殊的属性时,我们一般会自定义LayoutParams
。比如Relativelayout中LayoutParms中包含LEFT_OF(对应xml布局中的toLeftOf)
,RIGHT_OF(对应xml布局中的toRightOf)
属性。当程序解析xml的时,会根据子控件声明的属性,生成对应父控件下的LayoutParam
,通过该LayoutParam
,我们就能获取我们想要的参数啦。而子控件Layoutparam的生成,必然会走到父控件的LayoutParams的构造函数
。查看CoordainatorLayout下LayoutParams的构造函数:
1 | LayoutParams(Context context, AttributeSet attrs) { |
观察代码,我们可以发现,子控件的布局参数实例化时,会通过AttributeSet(xml中声明的标签)
来判断是否声明了layout_behavior
,如果声明了,就调用parseBehavior
方法来实例化Behavior对象。具体代码如下所示:
1 | static Behavior parseBehavior(Context context, AttributeSet attrs, String name) { |
parseBehavior方法其实很简单,就是根据相应的Behavior全限定名称,通过反射调用其构造函数(自定义Behavior的时候,一定要写构造函数),并实例化其对象。当然实例化Behavior的方法不止一种,Google还为我们提供了注解的方法设置Behavior。例如AppBarLayout中的设置:
1 | .DefaultBehavior(AppBarLayout.Behavior.class) |
当然使用注解的方式,其原理也是通过反射调用相应Behavior构造函数,并实例化对象。只是需要通过合适的时间解析注解罢了,因为篇幅的限制,这里不再讲解注解实例化Behavior的原理与时机了,有兴趣的小伙伴可以自行研究。
Behavior实现嵌套滑动的原理与过程
在上文CoordinatorLayout简介中,我们简单介绍了CoordinatorLayout嵌套滑动事件的传递过程与Behavior嵌套滑动的相关方法,现在我们就来了解嵌套滑动从CoordinatorLayout到Behavior的整个传递流程。如下所示:
单从上图,来理解整个传递过程比较困难。我们需要抽丝剥茧,逐个击破。下面我们就一步步来分析吧。
CoordainatorLayout的事件传递过程
Behavior的嵌套滑动其实都是围绕CoordainatorLayout的的onInterceptTouchEvent
与onTouchEvent
方法展开的。那我们先从onInterceptTouchEvent方法讲起,具体代码如下所示:
1 | public boolean onInterceptTouchEvent(MotionEvent ev) { |
在CoordainatorLayout的的onInterceptTouchEvent
方法中,内部其实是调用了performIntercept
来处理是否拦截事件,我们继续查看performIntercept方法。具体代码如下所示:
1 | private boolean performIntercept(MotionEvent ev, final int type) { |
整个方法代码的逻辑并不是很难,主要分为两个步骤:
- 获取内部的控件集合(topmostChildList),并按照z轴进行排序
- 循环遍历topmostChildList,获取控件的Behavior,并调用Behavior的onInterceptTouchEvent方法判断是否拦截事件,如果拦截事件,则事件又会交给CoordinatorLayout的
onTouchEvent
方法处理。
这里我们先不考虑Behavior拦截事件,一般情况下,Behavior的
onInterceptTouchEvent
方法基本都是返回false。特殊情况下Behavior事件拦截处理,大家可以在理解本文章所有的知识点后,结合官方提供的BottomSheetBehavior
、SwipeDismissBehavior
等进行深入的研究,这里因为篇幅的限制就不再深入的探讨了。
那么假设现在所有的子控件中的Behavior.onInterceptTouchEvent返回为false
,那么CoordinatorLayout就不会拦截事件,根据事件传递机制,事件就传递到了子控件中去了。如果我们的子控件实现是了NestedScrollingChild接口(如RecyclerView或NestedScrollView),并且在onTouchEvent方法调用了相关嵌套滑动API,那么再根据嵌套滑动机制,会调用实现了NestedScrollingParent2接口的父控件的相应方法。又因为CoordinatorLayout实现了NestedScrollingParent2接口。那么就又回到了我们最开始的介绍的嵌套滑动机制了。
这里的理解非常重要!!!!!非常重要!!!!非常重要!!!如果没有理解,建议多读几遍。
既然最终会调用CoordinatorLayout的嵌套滑动方法。那我们来介绍CoordinatorLayout下比较有代表性的嵌套滑动方法,那么先来看onStartNestedScroll方法。具体代码如下:
1 | public boolean onStartNestedScroll(View child, View target, int axes, int type) { |
在该方法中,我们会发现会获取所有的内部的控件,并调用对应Behavior的onStartNestedScroll
方法,需要注意的是,如果当前Behavior接受嵌套滑动事件(accepted = true),那么就会调用lp.setNestedScrollAccepted(type, accepted)
,这段代码非常重要,会影响Behavior后续的嵌套方法的执行。我们接着看CoordinatorLayout下的onNestedScrollAccepted
方法。代码如下所示:
1 |
|
同样在onNestedScrollAccepted方法中,也会调用所有控件的Behavior的onNestedScrollAccepted方法,需要注意的是,在该方法中增加了if (!lp.isNestedScrollAccepted(type))
的判断,也就是说只有Behavior的onStartNestedScroll
方法返回true的时候,该方法才会执行。接下来继续查看onNestedScroll方法。具体代码如下所示:
1 |
|
同样的,在onNestedScroll方法中,也会判断当前控件对应Behavior是否接受嵌套滑动事件,如果接受就调用对应方法。在代码的最后一行,我们会发现又调用了onChildViewsChanged(EVENT_NESTED_SCROLL)
。该行代码在CoordinatorLayout下多出嵌套滑动方法中都会调用,我们先看onNestedPreScroll方法。然后再来介绍onChildViewsChanged(EVENT_NESTED_SCROLL)
方法调用下的逻辑处理。onNestedPreScroll代码如下所示:
1 |
|
同样的在该方法中,也是调用子控件的Behavior对应的方法,并最后调用了onChildViewsChanged(EVENT_NESTED_SCROLL)
。该方法与其他方法的最大的不同就是,用int[] mTempIntPair = new int[2]
记录了控件在X轴与Y轴的距离,比较并获取内部子控件中最大
的消耗距离后,最后将最大的消耗距离,通过int[]consumed
数组在传回NestedScrollingChild。
在CoordinatorLayout下的比较重要的嵌套滑动方法基本上讲解完毕了。余下的onNestedPreFling
与onNestedFling
方法都大同小异,这里就不再讲解了,现在讲解一下当onChildViewsChanged(EVENT_NESTED_SCROLL)
方法调用下的逻辑处理。代码如下所示:
1 | final void onChildViewsChanged(@DispatchChangeEvent final int type) { |
整个方法分为一下几个步骤:
- 获取控件的Behavior,调用其
layoutDependsOn
方法判断是否依赖,找到依赖该控件的其他控件。 - 随后调用控件的LayoutParams的
getChangedAfterNestedScroll()
方法,检查当前控件的嵌套滑动的标志位,如果为true,表示已经嵌套滑动过了,那么就跳过。如果该标志位为false
,那程序继续往下走。 - 如果找到依赖控件其嵌套滑动标志位也为
false
,那么接下来会调用依赖控件的Behavior的onDependentViewChanged
方法,通知其他控件依赖的控件位置、大小发生了改变。 - 通知完毕后,如果其他的控件位置、大小发生了改变,那么需要在
onDependentViewChanged
方法中返回为true(handlet=true)
,如果type==EVENT_NESTED_SCROLL
那么需要调用ChangedAfterNestedScroll
,设置当前控件已经嵌套滑动的标志位为true
。
整个流程并不是很复杂,但是我向下大家会有一个疑问,就是为什么type==EVENT_NESTED_SCROLL
时,需要设置控件的嵌套滑动标志位呢?为什么当该标志位为true的时候,就需要跳过循环呢?其实这两个问题并不难,我们看下图:
根据上图,我们来回顾一下整个机制的嵌套滑动过程。
- 当CoordinatorLayout中子控件的Behvior默认不拦截事件,且内部有NestedScrollingChild控件的时候。最终会调用到某个控件的Behavior的嵌套相关方法,这里以A控件为例。
- 在A控件部分相关嵌套方法中,会调用
onChildViewsChanged(EVENT_NESTED_SCROLL)
。在该方法中又会通知其他依赖A控件的其他控件。并调用onDependentViewChanged方法(上图中,蓝色与红色部分)。 - 因为A控件在执行部分嵌套滑动方法后,会导致父控件重绘,所以又会回到本文最初讲解的
onPreDraw
方法,在该方法中,又会调用onChildViewsChanged(EVENT_PRE_DRAW)
(上图中黄色部分)。
根据当前整体流程,我们可以推断出,如果不通过设置控件的嵌套滑动标志位的话,那么其他依赖A控件的Behavior就会调用两次onDependentViewChanged
,如果说其他控件都在该方法中发生了位置、或大小的改变。那么整个过程就会出现问题!!!!!。所以说我们需要一个标志位来区分绘制与嵌套滑动。
当然这个嵌套滑动的标志位,是与Behavior的onDependentViewChanged
方法的返回值有关,所以在平时的开发中,我们一定要注意。如果我们当我们对目标控件的位置、大小造成了改变之后,我们一定要将该方法的返回值返回为true
。
Behavior的布局
还有最后两个知识点了,大家加油啊~~~
我们都知道CoordinatorLayout中被谷歌称为超级FrameLayout,其中的原因不仅因为其布局方式与测量方式与FrameLayout非常相似以外,最主要的原因是CoordinatorLayout可以将滑动事件、布局、测量交给子控件中的Behavior。现在我们就来看看CoordinatorLayout下的布局实现。查看其onLayout
方法。
1 |
|
从代码中我们可以看出,在对子 View 进行遍历的时候,CoordinatorLayout有主动向子控件的Behavior传递布局的要求,如果Behavior调用onLayoutChild
了方法自主布局了子控件,则以它的结果为准,否则将调用onLayoutChild
方法亲自布局。这里就不对CoordinatorLayout下的onLayoutChild
方法进行过多的描述了,大家知道这个方法类似于FrameLayout的布局就行了。
Behavior的布局时机
其实肯定会有小伙伴会疑惑,什么样的情况下,我们需要设置自主布局呢?(也就是behavior.onLayoutChild()方法返回true)。在上文中我们说过了CoordinatorLayout布局方式是类似于FrameLayout的。在FrameLayout的布局中是只支持Gravity来设置布局的。如果我们需要自主的摆放控件中的位置,那么我们就需要重写Behavior的onLayoutChild
方法。并设置该方法返回结果为true。
Behavior的测量
最后一个知识点了!!!!!Behavior的测量。依然还是通过CoordinatorLayout传递过来的。我们查看CoordinatorLayout的onMeasure
方法。代码如下所示:
1 |
|
上面的代码中,我还是省略了一些不重要的代码。观察上述代码,我们发现该方法与CoordinatorLayout的布局逻辑非常相似,也是对子控件进行遍历,并调那个用子控件的Behavior的onMeasureChild
方法,判断是否自主测量,如果为true,那么则以子控件的测量为准。当子控件测量完毕后。会通过widthUsed
和 heightUsed
这两个变量来保存CoordinatorLayout中子控件最大的尺寸。这两个变量的值,最终将会影响CoordinatorLayout的宽高。
Behavior的测量时机
还是相似的问题,在什么样的情况下,我们需要重写BehavioronMeasureChild
方法来自主测量控件呢?当你的控件需要重新设置位置的时候你要考虑是否需要重写该方法。什么意思呢?看下图所示:
在上图中我们定义了两个控件A与B,我们假设这两个控件处于这三个条件下:
- A、B控件都在CoordinatorLayout下,且A、B控件位置关系为控件A在B控件的下方。
- A控件的高度为
match_parent
或者wrap_content
。 - A、B控件的嵌套滑动关系为:B控件先处理嵌套滑动事件,当控件B向上滑动至隐藏后,控件A才能开始滑动。
那么根据上述条件,在滚动的过程中,我们会发现一个问题,就是当我们的控件A逐渐滑动到顶部时,我们会发现屏幕下方会出现一个空白区域,那原因是什么呢?其实很简单,当控件A高度为match_parent`或者`wrap_content
时,根据View的测量规则,控件A实际的高度就是整个控件剩余的高度(屏幕高度-控件B的高度),所以当控件B滚出屏幕后,那么就会出现一段空白。
那么为了使控件A在滑动过程中始终填充整个屏幕,我们需要在CoordinatorLayout测量该控件的高度之前,让控件自主的去测量高度,那么这个时候,Behavior的onMeasureChild
方法就派上用场了。我们可以重写该方法并设定当前控件A的高度为整个屏幕的高度。当然如何通过Behavior的onMeasureChild重新设定控件的高度是我们后续文章将讲解的知识,大家如果有兴趣的话,可以关注后续文章。
总结
看到这里的小伙伴真的非常值得鼓励。点赞!!!!!关于CoordinatorLayout的整个下的Behavior确实理解起来需要花费不少的时间。我本人从理解到写这篇博客零零散散也花费了两周多的时间。虽然说这块知识点比较偏门。但是还是希望能帮助到有需要的小伙伴们。能有幸帮助到大家,我也非常开心了。