CoordinatorLayout 与 Behavior 的一己之见

前言

许多文章都是将CoordinatorLayoutAppbarLayoutCollapsingToolbarLayoutToolbar等放在一块儿介绍,容易误解为这几个布局必定要互相搭配,且仅仅适用于这些场景中。html

其实否则,其中最重要的是CoordinatorLayout,我把它称为协调布局。协调什么布局呢?天然是嵌套在其内部的 Child View。java

CoordinatorLayout充当了一个中间层的角色,一边接收其余组件的事件,一边将接收到的事件通知给内部的其余组件。android

Behavior就是CoordinatorLayout传递事件的媒介,Behavior 定义了 CoordinatorLayout直接子 View的行为规范,决定了当收到不一样事件时,应该作怎样的处理。segmentfault

总结来讲,Behavior代理如下四种事件,其大体传递流程以下图:数组

事件流好像很高深莫测的样子...,再简化一点的说法:CoordinatorLayout中的某个或某几个方法被其余类调用,以后CoordinatorLayout再调用Behavior中的某个或某几个方法(=。=好像更抽象了)。总之,让这四类事件如今脑子里有个印象就能够了。app

接着先介绍一下自定义Behavior的通用流程。为何是通用流程呢?由于上面提到了有四种事件流,根据不一样的事件流,是要重写不一样的方法的,会在下面一一说明。ide

自定义Behavior的通用流程

1. 重写构造方法函数

public class CustomBehavior extends CoordinatorLayout.Behavior {

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

必定要重写这个构造方法,由于当你在XML中设置该Behavior时,在 CoordinatorLayout 中会反射调用该方法,并生成该 Behavior 实例。布局

2. 绑定到Viewthis

绑定的方法有三种:

在 XML 文件中,设置任意 View 的属性

app:layout_behavior="你的Behavior的包路径和类名"

或者在代码中:

(CoordinatorLayout.LayoutParams)child.getLayoutParams().setBehavior();

再或者当你的View是自定义的View时。在你的自定义View类上添加@DefaultBehavior(你的Behavior.class)。

@DefaultBehavior(CustomBehavior.class)
public class CustomView extends View {}

3. 判断依赖对象

CoordinatorLayout 收到某个 view 的变化或者嵌套滑动事件时,CoordinatorLayout就会尝试把事件下发给Behavior,绑定了该 Behavior 的 view 就会对事件作出响应。

下面是这两个具备依赖的关系的view在Behavior方法中的形参名,方便读者分辨:被动变化,也就是绑定了Behavior的view称为child主动变化的view在「变化事件」中称为dependency;在「嵌套滑动事件」中称为target

由于可能会存在不少的Child View能够向CoordinatorLayout发出消息,也同时存在不少的Child View拥有着不一样的Behavior,那么在CoordinatorLayout将真正的事件传递进这个Behavior以前,确定须要一个方法,告知CoordinatorLayout这二者的依赖关系是否成立。若是关系成立,那么就把事件下发给你,若是关系不成立,那咱就到此over。

下面以「变化事件」的layoutDependsOn说几个例子,「嵌套滑动事件」就在onStartNestedScroll中作一样的判断。另外的两种「布局事件」「触摸事件」就没有这一步了。

a.根据id

@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
    return dependency.getId() == R.id.xxx;
}

b.根据类型

@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
    return dependency instanceof CustomView;
}

c.根据id的另外一种写法

<declare-styleable name="Follow">
    <attr name="target" format="reference"/>
</declare-styleable>

先自定义target这个属性。

<android.support.design.widget.CoordinatorLayout    
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:context=".MainActivity">

    <View
        android:id="@+id/first"
        android:layout_width="match_parent"
        android:layout_height="128dp"
        android:background="@android:color/holo_blue_light"/>

    <View
        android:id="@+id/second"
        android:layout_width="match_parent"
        android:layout_height="128dp"
        app:layout_behavior=".FollowBehavior"
        app:target="@id/first"
        android:background="@android:color/holo_green_light"/>

</android.support.design.widget.CoordinatorLayout>
public class FollowBehavior extends CoordinatorLayout.Behavior {
    private int targetId;

    public FollowBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Follow);
        for (int i = 0; i < a.getIndexCount(); i++) {
            int attr = a.getIndex(i);
            if(a.getIndex(i) == R.styleable.Follow_target){
                targetId = a.getResourceId(attr, -1);
            }
        }
        a.recycle();
    }

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {

        return true;
    }

    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
        return dependency.getId() == targetId;
    }
}

四种不一样的事件流

1. 触摸事件

TouchEvent 最主要的方法就是两个:

public boolean onInterceptTouchEvent(MotionEvent ev)
public boolean onTouchEvent(MotionEvent ev)

CoordinatorLayoutonInterceptTouchEventonTouchEvent 方法中,会尝试调用其 Child View 拥有的 Behavior 中的同名方法。

public boolean onInterceptTouchEvent(CoordinatorLayout parent, View child, MotionEvent ev)
public boolean onTouchEvent(CoordinatorLayout parent, View child, MotionEvent ev)

若是 Behavior 对触摸事件进行了拦截,就不会再分发到 Child View 自身拥有的触摸事件中。这就意味着:在不知道具体View的状况下,就能够重写它的触摸事件。

然而有一点咱们须要注意到的是:onTouch事件是CoordinatorLayout分发下来的,因此这里的onTouchEvent并非咱们控件本身的onTouch事件,也就是说,你假如手指不在咱们的控件上滑动,也会触发onTouchEvent。

须要在onTouchEvent方法中的MotionEvent.ACTION_DOWN下添加:

ox = ev.getX();
oy = ev.getY();
if (oy < child.getTop() || oy > child.getBottom() || ox < child.getLeft() || ox > child.getRight()) { 
    return true;
}

对手势的位置进行过滤,不是咱们控件范围内的,舍弃掉。

2. 布局事件

视图布局无非就是这两个方法:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
protected void onLayout(boolean changed, int l, int t, int r, int b)

CoordinatorLayoutonMeasure onLayout 方法中,也会尝试调用其 Child View 拥有的 Behavior 中对应的方法,分别是:

public boolean onMeasureChild(CoordinatorLayout parent, V child, int parentWidthMeasureSpec, int widthUsed,
                                int parentHeightMeasureSpec, int heightUsed)
public boolean onLayoutChild(CoordinatorLayout parent, V child, int layoutDirection)

一样地,CoordinatorLayout 会优先处理 Behavior 中所重写的布局事件。

3. 变化事件

这个变化是指 View 的位置、尺寸发生了变化。在 CoordinatorLayoutonDraw 方法中,会遍历所有的 Child View 尝试寻找是否有相互关联的对象。

肯定是否关联的方式有两种:

1. Behavior中定义

经过 BehaviorlayoutDependsOn 方法来判断是否有依赖关系,若是有就继续调用 onDependentViewChanged。FloatActionButton 能够在 Snackbar 弹出时主动上移就经过该方式实现。

/**
 * 判断是dependency是不是当前behavior须要的对象
 * @param parent CoordinatorLayout
 * @param child 该Behavior对应的那个View
 * @param dependency dependency 要检查的View(child是否要依赖这个dependency)
 * @return true 依赖, false 不依赖
 */
@Override
public boolean layoutDependsOn(CoordinatorLayout parent, Button child, View dependency) {
    return false;
}

/**
 * 当改变dependency的尺寸或者位置时被调用
 * @param parent CoordinatorLayout
 * @param child  该Behavior对应的那个View
 * @param dependency child依赖dependency
 * @return true 处理了, false 没处理
 */
@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, Button child, View dependency) {
    return false;
}

/**
 * 在layoutDependsOn返回true的基础上以后,通知dependency被移除了
 * @param parent CoordinatorLayout
 * @param child 该Behavior对应的那个View
 * @param dependency child依赖dependency
 */
@Override
public void onDependentViewRemoved(CoordinatorLayout parent, Button child, View dependency) {
    
}

2. XML中设置属性

经过 XML 中设置的 layout_anchor,关联设置了 layout_anchor 的 Child View 与 layout_anchor对应的目标 dependency View。随后调用 offsetChildToAnchor(child, layoutDirection);,其实就是调整二者的位置,让它们能够一块儿变化。FloatActionButton 能够跟随 Toolbar 上下移动就是该方式实现。

app:layout_anchor="@id/dependencyView.id"

4. 嵌套滑动事件

实现NestedScrollingChild

若是一个View想向外界传递滑动事件,即通知 NestedScrollingParent,就必须实现此接口。

而 Child 与 Parent 的具体交互逻辑,NestedScrollingChildHelper 辅助类基本已经帮咱们封装好了,因此咱们只须要调用对应的方法便可。

NestedScrollingChild接口的通常实现:

public class CustomNestedScrollingChildView extends View implements NestedScrollingChild {

    private NestedScrollingChildHelper mChildHelper = new NestedScrollingChildHelper(this);

    /**
     * 设置当前View可否滑动
     * @param enabled
     */
    @Override
    public void setNestedScrollingEnabled(boolean enabled) {
        mChildHelper.setNestedScrollingEnabled(enabled);
    }

    /**
     * 判断当前View可否滑动
     * @return
     */
    @Override
    public boolean isNestedScrollingEnabled() {
        return mChildHelper.isNestedScrollingEnabled();
    }

    /**
     * 启动嵌套滑动事件流
     * 1. 寻找能够接收 NestedScroll 事件的 parent view,即实现了 NestedScrollingParent 接口的 ViewGroup
     * 2. 通知该 parent view,如今我要把滑动的参数传递给你
     * @param axes
     * @return
     */
    @Override
    public boolean startNestedScroll(int axes) {
        return mChildHelper.startNestedScroll(axes);
    }

    /**
     * 中止嵌套滑动事件流
     */
    @Override
    public void stopNestedScroll() {
        mChildHelper.stopNestedScroll();
    }

    /**
     * 是否存在接收 NestedScroll 事件的 parent view
     * @return
     */
    @Override
    public boolean hasNestedScrollingParent() {
        return mChildHelper.hasNestedScrollingParent();
    }

    /**
     * 在滑动以后,向父view汇报滚动状况,包括child view消费的部分和child view没有消费的部分。
     * @param dxConsumed x方向已消费的滑动距离
     * @param dyConsumed y方向已消费的滑动距离
     * @param dxUnconsumed x方向未消费的滑动距离
     * @param dyUnconsumed y方向未消费的滑动距离
     * @param offsetInWindow 若是parent view滑动致使child view的窗口发生了变化(child View的位置发生了变化)
     *                       该参数返回x(offsetInWindow[0]) y(offsetInWindow[1])方向的变化
     *                       若是你记录了手指最后的位置,须要根据参数offsetInWindow计算偏移量,
     *                       才能保证下一次的touch事件的计算是正确的。
     * @return 若是parent view接受了它的滚动参数,进行了部分消费,则这个函数返回true,不然为false。
     */
    @Override
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
                                        int dyUnconsumed, int[] offsetInWindow) {
        return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                offsetInWindow);
    }

    /**
     * 在滑动以前,先问一下 parent view 是否须要滑动,
     * 即child view的onInterceptTouchEvent或onTouchEvent方法中调用。
     * 1. 若是parent view滑动了必定距离,你须要从新计算一下parent view滑动后剩下给你的滑动距离剩余量,
     *      而后本身进行剩余的滑动。
     * 2. 该方法的第三第四个参数返回parent view消费掉的滑动距离和child view的窗口偏移量,
     *      若是你记录了手指最后的位置,须要根据第四个参数offsetInWindow计算偏移量,
     *      才能保证下一次的touch事件的计算是正确的。
     * @param dx x方向的滑动距离
     * @param dy y方向的滑动距离
     * @param consumed 若是不是null, 则告诉child view如今parent view滑动的状况,
     *                 consumed[0]parent view告诉child view水平方向滑动的距离(dx)
     *                 consumed[1]parent view告诉child view垂直方向滑动的距离(dy)
     * @param offsetInWindow 可选 length=2 的数组,
     *                       若是parent view滑动致使child View的窗口发生了变化(子View的位置发生了变化)
     *                       该参数返回x(offsetInWindow[0]) y(offsetInWindow[1])方向的变化
     *                       若是你记录了手指最后的位置,须要根据参数offsetInWindow计算偏移量,
     *                       才能保证下一次的touch事件的计算是正确的。
     * @return 若是parent view对滑动距离进行了部分消费,则这个函数返回true,不然为false。
     */
    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
        return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
    }

    /**
     * 在嵌套滑动的child view快速滑动以后再调用该函数向parent view汇报快速滑动状况。
     * @param velocityX 水平方向的速度
     * @param velocityY 垂直方向的速度
     * @param consumed true 表示child view快速滑动了, false 表示child view没有快速滑动
     * @return true 表示parent view快速滑动了, false 表示parent view没有快速滑动
     */
    @Override
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
        return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
    }

    /**
     * 在嵌套滑动的child view快速滑动以前告诉parent view快速滑动的状况。
     * @param velocityX 水平方向的速度
     * @param velocityY 垂直方向的速度
     * @return true 表示parent view快速滑动了, false 表示parent view没有快速滑动
     */
    @Override
    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
        return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
    }
}

实现NestedScrollingParent

若是一个View Group想接收来自 NestedScrollingChild 的滑动事件,就须要实现该接口。

一样有一个NestedScrollingParentHelper 辅助类,帮咱们封装好了 parent view 与 child view之间的具体交互逻辑。

由 NestedScrollingChild 主动发出滑动事件传递给 NestedScrollingParent,NestedScrollingParent 作出响应。
之间的调用关系以下表所示:

Child View Parent View
startNestedScroll onStartNestedScroll、onNestedScrollAccepted
dispatchNestedPreScroll onNestedPreScroll
dispatchNestedScroll onNestedScroll
stopNestedScroll onStopNestedScroll
dispatchNestedFling onNestedFling
dispatchNestedPreFling onNestedPreFling

继承Behavior

在上面的说明中提到 Parent View 会消费一部分或所有的滑动距离,但其实大部分状况下,咱们的 Parent View 自身并不会消费滑动距离,都是传递给 Behavior,也就是拥有这个 Behavior 的 Child View 才是真正消费滑动距离的实例。

Behavior 拥有与 NestedScrollingParent? 接口彻底同名的方法。在每个 NestedScrollingParent? 的方法中都会调用 Behavior 中的同名方法。

有这么几个方法作下特别说明:

/**
 * 开始嵌套滑动的时候被调用
 * 1. 须要判断滑动的方向是不是咱们须要的。
 *      nestedScrollAxes == ViewCompat.SCROLL_AXIS_HORIZONTAL 表示是水平方向的滑动
 *      nestedScrollAxes == ViewCompat.SCROLL_AXIS_VERTICAL 表示是竖直方向的滑动
 * 2. 返回 true 表示继续接收后续的滑动事件,返回 false 表示再也不接收后续滑动事件
 */
@Override
public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout, View child,
                                   View directTargetChild, View target, int nestedScrollAxes) {
}

/**
 * 滑动中调用
 * 1. 正在上滑:dyConsumed > 0 && dyUnconsumed == 0
 * 2. 已经到顶部了还在上滑:dyConsumed == 0 && dyUnconsumed > 0
 * 3. 正在下滑:dyConsumed < 0 && dyUnconsumed == 0
 * 4. 已经打底部了还在下滑:dyConsumed == 0 && dyUnconsumed < 0
 */
@Override
public void onNestedScroll(CoordinatorLayout coordinatorLayout, View child, View target,
                           int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
}

/**
 * 快速滑动中调用
 */
@Override
public boolean onNestedFling(CoordinatorLayout coordinatorLayout, View child, View target,
                             float velocityX, float velocityY, boolean consumed) {
}

总结

总结一下这四种事件流,和各自须要实现的方法。

根据在自定义Behavior时是否须要判断依赖关系,把Behavior代理的四种状况分红两类:

事件来自外部父view:

  1. 布局事件:BehavioronMeasureChild+onLayoutChild

  2. 触摸事件:BehavioronInterceptTouchEvent+onTouchEvent

事件来自内部子view:

  1. view变化事件:BehaviorlayoutDependsOn+onDependentViewChanged+onDependentViewRemoved

  2. 嵌套滑动事件:BehavioronStartNestedScroll+onNestedScrollAccepted+onStopNestedScroll+onNestedScroll+onNestedPreScroll+onNestedFling+onNestedPreFling

后记

以前在Google、百度自定义Behavior造轮子的时候,刚开始看一篇,以为不过如此,就这么点东西。再看一篇,咦~实现怎么又不同了,再来一篇又不同了。

本文就是想起一个大纲的做用,轮子再怎么造,仍是这么些个方法。之后再看别人的轮子或者本身造轮子的时候,能够清晰一些。

扩展

sidhu眼中的CoordinatorLayout.Behavior(一)
sidhu眼中的CoordinatorLayout.Behavior(二)
sidhu眼中的CoordinatorLayout.Behavior(三)
Material Design系列,自定义Behavior支持全部View
CoordinatorLayout的使用如此简单

相关文章
相关标签/搜索