深入理解CoordinatorLayout(草稿)

什么是CoordinatorLayout

Google在 android.support.design 包中新增的 CoordinatorLayout 布局, 可以简单理解为一个升级版本的 FrameLayout .

基本用法例如:

<android.support.design.widget.CoordinatorLayout>

    <FrameLayout android:id="@+id/contentFrame" />

    <android.support.design.widget.FloatingActionButton
        app:layout_anchor="@id/contentFrame"
        app:layout_anchorGravity="bottom|right" />
</android.support.design.widget.CoordinatorLayout>

这里将其他的属性去掉了, 便于阅读. 可以看出这里的关键在于 layout_anchorlayout_anchorGravity 两个参数, 意思也很简单:

就是这个View以指定的 anchor 作为参照物来定位, anchorGravity 设置为 bottom|right 则表示将FloatingActionButton放置于参照物(FrameLayout)的右下角.

互相依赖的Child

目前看来其用法和 FrameLayout 挺像的, 但其实它要比这要强大很多.

这里首先提出一个概念, CoordinatorLayout 其实是将其下的所有子View都抽象成:

互相依赖(depends)的关系.

因此某个view可以基于另一个view来定位, 但这只是冰山一角, 这样抽象的好处更强大的地方在于:

每一个view的所有属性, 坐标, 样式, 状态等一切都可以依赖于另一个view, 因此使得parentView和所有childView之间都可以互相联动起来.

想象一下, 不仅仅是定位, 所有可以设置在View上面的属性都可以依赖于另一个view的变化而变化, 某一个View可以跟随另一个View一起滚动, 某一个View可以跟随另一个View的状态改变而改变, 是不是瞬间觉得很强大了.

实现滑动列表自动隐藏标题栏(沉浸式效果)

这里先举一个很常见的例子来说明如何让一个View跟随另一个View滑动而变化.

最著名的例子应该就是向下滑动列表的时候, 标题栏可以自动隐藏掉, 而向上滑动时标题栏又自动显示出来, 即大家所说的沉浸式阅读体验.

CoordinatorLayout 的 Behavior 是它更为强大的地方, 它使得CoordinatorLayou的若干个ChildView之间产生交互, 例如滑动某个子View, 另一个子View跟着滑动/隐藏.

这里需要 AppBarLayout 来配合实现该效果, 如下:

<android.support.design.widget.CoordinatorLayout>
    <android.support.design.widget.AppBarLayout>
        <android.support.v7.widget.Toolbar
            app:layout_scrollFlags="scroll|enterAlways" />
    </android.support.design.widget.AppBarLayout>

    <RecyclerView
            app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</android.support.design.widget.CoordinatorLayout>

其中使用 RecyclerViewAppBarLayout 作为 CoordinatorLayout 的子View. 因此三者的行为是可以影响的, 当 RecyclerView 向下滑动的时候, AppBarLayout 可以跟着向上挤出屏幕外, 使得列表可以全屏展示(即沉浸式), 而向下滑动的时候, 标题栏又跟着滑动回来.

layout_behavior 属性定义了这个View如何和其他View互相交互的行为, 其值填写的是一个class的名字(全称带包名), 例如这里例子的值其实为:

android.support.design.widget.AppBarLayout$ScrollingViewBehavior

这个值指定的类必须是 CoordinatorLayout.Behavior<V> 的子类, 我们也可以自定义一个该类继承于它, 以此来写自己想要的交互效果. 这个我们将在后面详情讲如何继承该类.

需要注意的是 layout_behavior 是CoordinatorLayout的layoutParams属性, 因此只有它的直接子View才能设置, 而在我们这个例子里, 可以看见RecyclerView是设置了该属性的, 但AppBarLayout是没有在xml里面设置的, 这很奇怪, 但其实是因为这个属性不仅仅可以在layout里面设置, 还可以在代码里通过注解来设置, AppBarLayout就是在java代码里设置的. 如:

@CoordinatorLayout.DefaultBehavior(AppBarLayout.Behavior.class)
public class AppBarLayout extends LinearLayout {
    // ...
}

这个 @CoordinatorLayout.DefaultBehavior(value) 注解等同于在xml设置 layout_behavior=value 属性.

另外关于 AppBarLayout 的用法则超出了本文范围, 这里只需要将其理解为CoordinatorLayout的子View, 并且设置了layout_behavior, 因此可以和CoordinatorLayout的其他view联动.

到此你对CoordinatorLayout.Behavior应该有了一个初步的认识, 下面我们通过自定义一个Behavior来加深了解.

自定义CoordinatorLayout.Behavior

这里我们要实现的效果是让一个浮动按钮在列表向下滑动的时候自动隐藏, 而向下滑动的时候自动显示回来.

首先我们来看布局的结构:

<android.support.design.widget.CoordinatorLayout>
    <RecyclerView android:id="@+id/list" />

     <android.support.design.widget.FloatingActionButton
        app:layout_anchor="@id/list"
        app:layout_anchorGravity="bottom|right|end"
        app:layout_behavior="com.test.FloatingActionButtonAutoHideBehavider" />

</android.support.design.widget.CoordinatorLayout>

我们有一个 RecyclerView 列表可以上下滑动, 另外有一个 FloatingActionButton 浮动按钮.

然后我们来看浮动按钮上面定义的 layout_behavior 类, 这个自定义的 Behavior 类继承自 CoordinatorLayout.Behavior<V extends View> .

其中的泛型 <V> 为: 目标View的类型, 即需要设置 app:layout_behavior 的View类型. 如果在某个View上设置了某个Behavior, 但该Behavior的泛型却不是该View的类型, 是会报错的.

public class FloatingActionButtonAutoHideBehavider extends CoordinatorLayout.Behavior<FloatingActionButton> {

    public FloatingActionButtonAutoHideBehavider(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

}

这里需要注意的是在继承Behavior类的时候必须实现 Behavior(Context context, AttributeSet attrs) 这个构造器, 否则会报错. 因为 CoordinatorLayout 里面是通过反射来实例化 Behavior 对象的, 而反射的时候使用了这个带两个参数的构造器来实例化的.

然后我们来重载两个方法来实现想要的效果:

  @Override
    public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout,
                                       FloatingActionButton child, View directTargetChild,
                                       View target, int nestedScrollAxes) {
        return nestedScrollAxes == ViewCompat.SCROLL_AXIS_VERTICAL;
    }

这里表示我们只相应Y轴的嵌套滑动( nestedScroll ), 这里要讲清楚这个方法就必须先讲清楚 support.v4 包里面新加的嵌套滑动概念, 而这个概念可以讲一天, 因此这里只是简单讲讲和 CoordinatorLayout 有关的内容.

首先我们来看v4包的里面嵌套滑动, 以及支持嵌套滑动的几个类, 因为要想让一个View跟着另一个View滑动而变化, 那么这个可以滑动的View就必须实现了 NestedScrollingChild 接口, 这样CoordinatorLayout才能收到这个子类的滑动事件.

我们只需要知道CoordinatorLayout是实现了 NestedScrollingParent 接口的, 而要监听其子View的滑动事件, 那么该子View就必须实现了 NestedScrollingChild 接口, 具体实现了该接口子类如下:

  1. android.support.v4.view.NestedScrollingParent
    • CoordinatorLayout
  2. android.support.v4.view.NestedScrollingChild
    • HorizontalGridView
    • NestedScrollView
    • RecyclerView
    • SwipeRefreshLayout
    • VerticalGridView

由此可见, ListViewScrollView 是不支持的, 只能换成 RecyclerViewNestedScrollView 才行.

关于嵌套滑动的概念理解到这里就够了, 更多内容可以查询相关资料.

接下来继续看另一个重载的方法:

 @Override
    public void onNestedScroll(CoordinatorLayout coordinatorLayout, FloatingActionButton child, View target, int dxConsumed, int dyConsumed,
                               int dxUnconsumed, int dyUnconsumed) {
        if (dyConsumed > 0) {
            // 手势从下向上滑动(列表往下滚动), 隐藏
            CoordinatorLayout.LayoutParams layoutParams =
                    (CoordinatorLayout.LayoutParams) child.getLayoutParams();
            int fab_bottomMargin = layoutParams.bottomMargin;
            setAnimateTranslationY(child, child.getHeight() + fab_bottomMargin);
        } else if (dyConsumed < 0) {
            // 手势从上向下滑动(列表往上滚动), 显示
            setAnimateTranslationY(child, 0);
        }
    }

    private void setAnimateTranslationY(View view, int y) {
        view.animate().translationY(y).setInterpolator(new LinearInterpolator()).start();
    }

这里监听 RecyclerView 的滑动, 当它向下滑动的时候, 隐藏掉浮动按钮, 反之显示.

其中 onNestedScroll 有几个参数, 依次是:

  1. CoordinatorLayout, 即parentView,
  2. child, 即Behavior对应的这个ChildView, 这里是 FloatingActionButton
  3. target, 即触发嵌套滑动的子View, 这里是 RecyclerView
  4. dxConsumed, 横向滑动距离
  5. dyConsumed, 纵向滑动距离

其实后面的几个参数都是和 NestedScrollingParent 接口下的 public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) 一模一样的, 感兴趣的可以深入了解一下嵌套滑动. (感觉又给自己挖了一个坑...)

会用了就够了吗?

到此为止, 你应该对 CoordinatorLayout 有一定理解了, 简单的用应该是没有问题了, 但一个新的控件放在我们面前的时候, 我们不应该仅仅停留在学会怎么用一个控件, 而应该深入到一个控件的源码中去, 学习一个好的控件是怎么写出来的, 这样即可以加深对于该控件的理解, 也可以在自己写自定义控件, 甚至设计别的模块的时候都有很大帮助, 这就是android开源的好处, 你不仅可以用它的代码, 还可以学它的代码.

而android后面新加的控件在设计上都非常值得学习, 解耦得非常漂亮.

阅读源码, 深入了解

android.support.design.widget 包里面还有一些自带的Behavior, 可以通过阅读它们来学习更多关于 CoordinatorLayout 的概念, 如下:

  • CoordinatorLayout.Behavior<V>
    • BottomSheetBehavior
    • SwipeDismissBehavior
      • Snackbar.Behavior
    • ViewOffsetBehavior
      • HeaderScrollingViewBehavior
      • HeaderBehavior
    • FloatingActionButton.Behavior

CoordinatorLayout的事件分发

我们从 Behavior 的代码开始读, 会首先看到下面两个方法:

public boolean onInterceptTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev) {
    return false;
}

public boolean onTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev) {
    return false;
}

这两个方法很熟悉了, 一个是 ViewGroup 的方法, 一个是 View 的方法, 概念上也是一样的, 并且 CoordinatorLayout 也是在自己的 onInterceptTouchEvent 方法里面去调用其子view的Behavior的 onInterceptTouchEvent 方法, onTouchEvent 同理.

为什么要提供这两个方法, Behavior 和 ViewGroup 的 onInterceptTouchEvent 有区别吗?

这里先暂不表, 首先要赞一下这个地方的设计.

我相信有经验的同学一定使用过 onInterceptTouchEvent 方法, 一般是需要阻拦滑动事件的时候, 继承某个 ViewGroup , 然后重载这个方法, 返回 true 来实现拦截事件.

其实这种设计是很蛋疼的, 为了改变事件的分发, 必须去继承. 而 CoordinatorLayout 使用了一种更解耦的设计. 它可以改变事件的分发, 而不需去继承某个View, 而只是给某个View指定一个 Behavior 即可.

那么我们看看CoordinatorLayout是怎么实现事件的分发的.

在讲CoordinatorLayout之前, 我们必须老生重弹一下, 讲一个 ViewGroup/View 的事件分派, 事件分派是有两个过程的:

捕获过程: 从根元素到子元素的依次调用 onInterceptTouchEvent , 看有没人要拦截事件, 如果有人拦截了立即进入冒泡过程, 否则一直传递到最末尾的元素再进入冒泡过程.
冒泡过程: 从底层往上冒泡, 依次调用 onTouchEvent , 如果有人消耗了事件, 则不再继续向上传递, 否则一直传递到根元素.

而默认的 ViewGroup 是不会再捕获任何事件的, 因此在处理手势事件冲突的时候, 我们需要继承外层的 ViewGroup 来在捕获过程的时候拦截事件, 让事件不再向子元素传递.

CoordinatorLayout 则不需要这样, 它在自己的 onInterceptTouchEvent 方法里面去遍历所有的子View, 调用它们的 Behavior.onInterceptTouchEvent() 方法, 如果有一个子View拦截了该事件, 则事件进入冒泡过程.

而这样的设计可以使得处理例如手势的逻辑可以完全从具体的某个View解耦出来, 例如你想要在某个View上实现向右滑动消失的手势操作, 那么你就必须继承这个View, 然后重载其的事件分发回调方法. 但在 CoordinatorLayout 里面, 你只需要写:

public class MySwipeDismissBehavior extends SwipeDismissBehavior<View>  {

    public MySwipeDismissBehavior(Context context, AttributeSet attrs) {
        super();
    }

}

其中 SwipeDismissBehaviorandroid.support.design.widget 自带的一个手势识别行为类.

然后将其作为 Behavior 设置在某个View上即可实现该手势的效果:

<View
    app:layout_behavior="com.test.MySwipeDismissBehavior" />

由此可以看出来, 当出现了另一个View也需要识别该手势的时候, 只需要设置同样的 Behavior 即可, 代码复用率极高. 而当这个View想要换一个手势的时候, 也只需要替换一个新的 Behavior 即可. 可以看出来这样的设计将具体的View和事件的分发逻辑彻底解耦, 并且实现了 "组合优于继承" 的思想. 有很多时候 is-ahas-a 是都说得通的, 比如这个例子里可以理解成: "一个可以识别侧滑手势的View", 也可以理解成 "一个有识别侧滑手势功能的View", 但大部分时候使用 has-a 一定会优于 is-a , 除非它们有非常强的继承关系, 才应该用 is-a , 否则都应该优先使用组合来代替继承关系.

CoordinatorLayout子View的依赖关系

在最开始我们说过 CoordinatorLayout 之所以这么强大, 就是因为它将其所有的childView都抽象成 互相依赖 的关系.

CoordinatorLayout.LayoutParams 中定义了一个View是否依赖( dependsOn ) 另一个View:

boolean dependsOn(CoordinatorLayout parent, View child, View dependency) {
    return dependency == mAnchorDirectChild
            || (mBehavior != null && mBehavior.layoutDependsOn(parent, child, dependency));
}

由此我们可以看出, 如果一个View在layout的时候将一个view作为了定位的参考物( 之前提到过的 app:layout_anchor 属性, 那么这个View则视为依赖于参照物的View.

而另一种定义依赖关系的方法, 就是在 Behavior 中如下方法:

boolean layoutDependsOn(CoordinatorLayout parent, V child, View dependency);

其中child参数表示自己, dependency参数表示对照的View, 如果返回true则表示childView依赖于dependencyView.

而在CoordinatorLayout中, 它会遍历所有的子View, 将每个子View和其他的子View都使用 layoutDependsOn 来比较一下, 确保所有互相依赖的子View都可以联动起来.

例如我们之前提过的 AppBarLayout 里面的 AppBarLayout$ScrollingViewBehavior 就重载了该方法:

@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
    // We depend on any AppBarLayouts
    return dependency instanceof AppBarLayout;
}

因此将这个 Behavior 设置到任何View上面, 则该View在布局上则依赖于 AppBarLayout , 因此在布局的会将View放置到 AppBarLayout 的下面, 例如 AppBarLayout 高度为48dp, 那么这个View的offsetTopAndBottom就会被自动设置为48dp, 使其刚好布局到 AppBarLayout 的下面, 并且让这个View在AppBarLayout的高度变化的时候自动跟随, 因此可以实现自动伸缩的顶部栏, 以及 parallax effect 的头图效果.

Behavior 的另一个方式是和 layoutDependsOn 方法相关联的, 如果 layoutDependsOn 方法返回true则会调用这个方法:

boolean onDependentViewChanged(CoordinatorLayout parent, V child, View dependency);

当它依赖的View发现变化的时候, 则会回调这个方法, 那么使其可以和依赖的变化而变化.

例如 FloatingActionButton.Behavior 定义的:

        @Override
        public boolean onDependentViewChanged(CoordinatorLayout parent, FloatingActionButton child,
                View dependency) {
            if (dependency instanceof Snackbar.SnackbarLayout) {
                updateFabTranslationForSnackbar(parent, child, dependency);
            } else if (dependency instanceof AppBarLayout) {
                // If we're depending on an AppBarLayout we will show/hide it automatically
                // if the FAB is anchored to the AppBarLayout
                updateFabVisibility(parent, (AppBarLayout) dependency, child);
            }
            return false;
        }

大概想要实现的效果就是:

  1. 如果这个浮动按钮依赖于某个 Snackbar 的时候, 而这个 Snackbar 显示的出来, 因为可能和FAB都是在底部出现, 而出现互相遮盖, 因此可以在 SnackBar 显示出来的时候, 自动将FAB按钮向上移动一点, 使其不会遮盖住, 而在 SnackBar 自动隐藏的时候, 浮动按钮也还原到原来的位置.

  2. 如果这个浮动按钮定位的时候依赖于 AppBarLayout , 那么当其变化的时候自动显示或隐藏浮动按钮.

到此大家应该就可以理解, 之前我说过的CoordinatorLayout的中心思想就是关于抽象了子View之间的依赖关系.

推荐阅读更多精彩内容