CoordinatorLayout 分析

CoordinatorLayout

一、实现滑动 RecyclerView 实现 FAB 以及 Toolbar 的显示和隐藏

(一)传统实现思路:

  1. 监听 RecyclerView 的滑动
  2. 根据滑动距离及状态执行显示和隐藏的动画

(二)CoordinatorLayout 方式

二、CoordinatorLayout

CoordinatorLayout 继承自 ViewGroup,通过协调并调度里面的子控件或者子布局来实现触摸 (一般指滑动) 产生一些相关的动画效果。可以通过设置 View 的 Behavior 属性来实现触摸的动画调度。

1. CoordinatorLayout 中使用 SnackBar

可以解决 SnackBar 出现时遮挡 FloatingActionButton 的情况,其 Behavior 实现类是 FloatingActionButton.Behavior

2. AppBarLayout

AppBarLayout 继承了 LinearLayout,并且是垂直方向,里面可以放多个 View, 在 CoordinatorLayout 中的 Scrolling View 滑动时,AppBarLayout 中的 View 可以实现多种隐藏、显示效果。

Scrolling 是指:RecyclerView、NestedScrollView 等实现了 NestedScrollChild 接口的类

CoordinatorLayout 中,使用 AppBarLayout 包裹 Toolbar,再为 Scrolling View 设置 app:layout_behavior 属性为 appbar_scrolling_view_behavior ,设置 Toolbar 的 layout_scrollFlags 属性的值为 scroll,就实现了 RecyclerView 滑动时 Toolbar 自动的显示隐藏的效果。为 layout_scrollFlags 参数设置不同的值就可以实现不同的效果。

  1. scroll: 里面所有的子控件想要滑出屏幕的时候都需要设置这个 Flag,里面没有设置这个 Flag 的 View 都将被固定在顶部,效果为:隐藏的时候,先整体向上滚动,直到 AppBarLayout 完全隐藏,再开始滚动 Scrolling View;显示的时候,直到 Scrolling View 顶部完全出现后,再开始滚动整体直到 AppBarLayout 完全显示。

  2. enterAlways ,快速返回,设置这个属性后,与 scroll 类似,只不过向下滚动先显示子控件到完全,再滚动 Scrolling View了,需要与 scroll 配合使用

  3. enterAlwaysCollapsed: 需要和 enterAlways 一起使用(scroll|enterAlways|enterAlwaysCollapsed),还需要为子控件设置 minHight 属性,和 enterAlways 不一样的是,不会显示子控件到完全再滚动 Scrolling View,而是先滚动 子控件到最小高度,再滚动 Scrolling View,最后再滚动 AppBarLayout 到完全显示。

  4. exitUnitilCollapsed: 定义了子控件 消失的规则。发生向上滚动事件时,子控件向上滚动退出直至最小高度(minHeight),然后 Scrolling View 开始滚动。也就是,子控件不会完全退出屏幕

  5. snap: 定义了是子控件滚动比例的一个吸附效果。也就是说,子控件不会存在局部显示的情况,滚动子控件的部分高度,当我们松开手指时,子控件要么向上全部滚出屏幕,要么向下全部滚进屏幕,有点类似 ViewPager 的左右滑动。而向上还是向下滑动取决于显示和隐藏部分的比例,显示的多就会向下全部显示,隐藏的多就会向上完全隐藏。

可以使用 CoordinatorLayout + View + Toolbar + TabLayout + ViewPager(内容可垂直滑动) 组合实现的 TabLayout 贴顶效果。

3. CollapsingToolbarLayout

可以实现 Toolbar 的折叠效果,使用 AppBarLayout 嵌套 CollapsingToolbarLayout,再使用 CollapsingToolbarLayout 嵌套 Toolbar,

注意:

  1. AppBarLayout 需要设置固定的高度,实现折叠效果时要大于 Toolbar 高度

  2. CollapsingToolbarLayout 设置 height 为占满父布局

  3. CollapsingToolbarLayout 为 AppBarLayout 的直接子控件,因为需要折叠 CollapsingToolbarLayout ,所以需要为 CollapsingToolbarLayout 设置 layout_scrollFlags 属性设置为可隐藏

  4. 在 CollapsingToolbarLayout 中添加其他 View 放在 Toolbar 上面,并且为这个 View 和 Toolbar 设置 layout_collapseMode 属性

    • parallax: 效果为视差模式,折叠的时候会有视差效果。可以搭配 layout_collapseParallaxMultiplier 属性,值的区间为 0 - 1,用来设置视差效果的明显程度,为 1 时候的表现为 CollapsingToolbarLayout 其余部分被折叠后再折叠 toolbar 也就是无视差效果,0 的时候为先折叠 toolbar 再折叠其余部分也就是视差效果最明显
    • none:没有任何效果,往上滑动的时候 Toolbar 会被首先退出去
    • pin:固定模式,toolbar 设置该模式,在滑动时会有一个融合效果,融合完成后 toolbar 会固定在顶端
  5. CollapsingToolbarLayout 可以设置 expandedTitleMargin 属性控制展开时的文字 margin,collapsedTitleTextAppearance 属性控制折叠时的文字样式等

  6. contentScrim 是一个颜色,内容部分的沉浸式效果,可以让 Toolbar 和其他 View 有一个渐变的过渡效果,statusBarScrim 是为状态栏设置颜色(5.0+ 才有效果)

  7. 还有其他很多属性设置不同的效果

4. Behavior (CoordinatorLayout.Behavior) 需配合 CoordinatorLayout 使用

四、Behavior + CoordinatorLayout

Behavior 可以看作一个桥梁或者监听者,实现包裹在 CoordinatorLayout 里面的所有子控件或者容器产生联动效果

自定义 Behavior 的两种效果,继承 CoordinatorLayout.Behavior

为观察者设置 Behavior,这样被观察者中 Behavior 监听的状态发生变化时,Behavior 中的对应方法会被回调。

1. 某个 View 需要监听另一个 View 的状态(比如:位置、大小、显示状态)

需要重写方法:layoutDependsOn,onDependentViewChanged

  1. layoutDependsOn

用来决定需要监听哪些控件或者容器的状态,参数 parent 是 CoordinatorLayout, child 指定了当前 behavior 的需要监其他 View 的观察者,dependency 是被观察的 View;返回值是 dependency 是否是 child 需要监听的 View (通过 id 或者 tag 等方式来判断) 以及是否是观察者需要监听的状态发生的改变

  1. onDependentViewChanged

当被监听的 View 发生改变时回调,可以在此方法里面做一些相应的联动动画等效果

例如 AppBarLayout 与 RecyclerView 的联动,就是 AppBarLayout 监听了 RecyclerView 的滑动。可以根据这个规则自定义更多的效果!!!

2. 某个 View 需要监听 CoordinatorLayout 里面所有控件的滑动状态 ( Google 专门提供的对滑动效果的处理,主要是计算了滑动的距离等属性 )

能被 CoordinatorLayout 捕获到的滑动状态的控件有:RecyclerView、NestedScrollView 等实现了 NestedScrollChild 接口的类

需要重写的方法:onStartNestedScroll, onNestedPreScroll, onNestedScroll、onNestedFling 等

onNestedFling 方法是 Fling 状态时回调,其中可以通过 NestedScrollView 的 fling 方法直接传入参数中计算好的速度值进行滑动

五、Behavior 机制的实现原理

分析 Behavior 主要是为了探索 CoordinatorLayout 是如何做到监听里面子控件的状态改变并执行 Behavior 中回调方法的过程。在这个过程中我们也可以将 Behavior 中的所有回调方法做一个梳理,明确每一个方法的作用,在自定义 Behavior 时也就更能最高效、稳定的实现我们想要的效果。

1. CoordinatorLayout 中的 LayoutParams 及 Behavior 的实例化

CoordinatorLayout 类中有一个内部类 LayoutParams 继承了 ViewGroup.MarginLayoutParams,LayoutParams 中保存了 CoordinatorLayout 中子 View 的布局信息。

在 LayoutInflater 的 inflate 方法中,对 ViewGroup 添加 View 时会调用 addView 方法其中为 Child 指定的 LayoutParams 由 generateLayoutParams 方法创建。CoordinatorLayout 重写了 generateLayoutParams 方法,会创建一个新的 LayoutParams 对象并返回。

// CoordinatorLayout
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new LayoutParams(getContext(), attrs);
}

在 LayoutParams 的创建过程中会解析 View 在 xml 中配置的属性,除了解析普通 View 的属性外还包括 AnchorId、Behavior 等,在 Behavior 的解析过程中,会根据配置的 Behavior 属性的值,先通过规律找到对应的 Behavior 类,然后通过反射创建指定的 Behavior 实例,并将这个实例保存在 LayoutParams 中。如若 Behavior 对象不为空,还会调用其 onAttachedToLayoutParams 方法。

2. Behavoir 的 onMeasureChild、onLayoutChild 方法

在 CoordinatorLayout 的 onMeasure 方法中,在对子 View 进行测量时,如果 View 绑定了 Behavior,会先调用该 Behavior 的 onMeasureChild 方法,由 Behavior 对当前 View 进行自定义的测量并返回是否测量完成,如果 Behavior 测量完成 CoordinatorLayout 将不会测量该 View。

CoordinatorLayout 的 onlayout 方法同 onMeasure 方法,会调用 Behavior 的 onLayoutChild 方法

3. Behavoir 的 onInterceptTouchEvent 和 onTouchEvent 方法

在 CoordinatorLayout 的 onInterceptTouchEvent 方法中,如果子 View 有 Behavior 就会调用该 Behavior 的 onInterceptTouchEvent 方法,也就是在 CoordinatorLayout 将事件分发到子 View 之前,先由 Behavoir 进行拦截判断,DOWN、UP、CANCLE 时 CoordinatorLayout 的 onInterceptTouchEvent 方法不会使用 Behavoir 的返回结果,其他事件时会使用 Behavoir 的返回结果作为自己的返回结果。如果 Behavoir 拦截事件,还会为 CoordinatorLayout 的 mBehaviorTouchView 属性赋值为拦截事件的 Behavoir 绑定的 View

这里需要注意,CoordinatorLayout 的 onInterceptTouchEvent 方法的返回值决定了 CoordinatorLayout 是否拦截当前事件,Behavoir 决定拦截也是作用在 CoordinatorLayout 上。

CoordinatorLayout 的 onTouchEvent 中会判断 mBehaviorTouchView 的值是否为空,不为空时会调用其绑定的 Behavior 的 onTouchEvent 方法,然后返回 Behavoir 的 onTouchEvent 方法的返回值和 CoordinatorLayout 的 super.onTouchEvent 的的返回值进行求或运算后的结果。如果 mBehaviorTouchView 为空则直接返回 CoordinatorLayout 的 super.onTouchEvent 的结果。

4. Behavior 的 layoutDependsOn 、onDependentViewRemoved、 onDependentViewChanged 方法

在 CoordinatorLayout 的 onMeasure 方法中,在对子 View 进行测量之前,调用了一个 prepareChildren 方法,其中通过两个 List 将 View 间的依赖进行整理


// CoordinatorLayout

// 保存了存在依赖的 View
private final List<View> mDependencySortedChildren = new ArrayList<>();

// DirectedAcyclicGraph 是一个其中无环的图结构
private final DirectedAcyclicGraph<View> mChildDag = new DirectedAcyclicGraph<>();

private void prepareChildren() {
    mDependencySortedChildren.clear();
    mChildDag.clear();

    for (int i = 0, count = getChildCount(); i < count; i++) {
        final View view = getChildAt(i);

        final LayoutParams lp = getResolvedLayoutParams(view);
        lp.findAnchorView(this, view);

        mChildDag.addNode(view);

        // 遍历 CoordinatorLayout 中的其他 View,判断当前 View 是否依赖遍历到的 View,如果依赖,则将遍历到的 View 加入 mChildDag 中
        for (int j = 0; j < count; j++) {
            if (j == i) {
                continue;
            }
            final View other = getChildAt(j);
            
            // dependsOn 方法中会调用 lp 中 Behavior 的 layoutDependsOn 来决定是否依赖 other
            if (lp.dependsOn(this, view, other)) {
                if (!mChildDag.contains(other)) {
                    // 如果依赖则将遍历到的 View 加入 mChildDag 中
                    mChildDag.addNode(other);
                }
                // 为图添加一条边
                mChildDag.addEdge(other, view);
            }
        }
    }

    // 将 mChildDag 图构造成集合然后添加到 mDependencySortedChildren 中
    mDependencySortedChildren.addAll(mChildDag.getSortedList());
    
    // We also need to reverse the result since we want the start of the list to contain
    // Views which have no dependencies, then dependent views after that
    Collections.reverse(mDependencySortedChildren);
}

prepareChildren 方法执行完毕后,mDependencySortedChildren 集合中就保存了有依赖的 View。prepareChildren 方法执行后,CoordinatorLayout 的 onMeasure 方法中会接着调用 ensurePreDrawListener 方法,该方法中会判断 View 间是否有依赖,如果有则会调用 addPreDrawListener() 方法,如果没有会调用 removePreDrawListener 方法。

// Coordinatorlayout
void addPreDrawListener() {
    if (mIsAttachedToWindow) {
        // Add the listener
        if (mOnPreDrawListener == null) {
            mOnPreDrawListener = new OnPreDrawListener();
        }
        final ViewTreeObserver vto = getViewTreeObserver();
        vto.addOnPreDrawListener(mOnPreDrawListener);
    }

    // Record that we need the listener regardless of whether or not we're attached.
    // We'll add the real listener when we become attached.
    mNeedsPreDrawListener = true;
}

/**
 * Remove the pre-draw listener if we're attached to a window and mark that we currently
 * do not need it when attached.
 */
void removePreDrawListener() {
    if (mIsAttachedToWindow) {
        if (mOnPreDrawListener != null) {
            final ViewTreeObserver vto = getViewTreeObserver();
            vto.removeOnPreDrawListener(mOnPreDrawListener);
        }
    }
    mNeedsPreDrawListener = false;
}

class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener {
    @Override
    public boolean onPreDraw() {
        onChildViewsChanged(EVENT_PRE_DRAW);
        return true;
    }
}

addPreDrawListener 方法中是将一个 OnPreDrawListener 对象注册到了 ViewTreeObserver 中,removePreDrawListener 则是将 OnPreDrawListener 对象从 ViewTreeObserver 中取消注册。ViewTreeObserver 是管理 View 树的观察者,View 发生变化时,ViewTreeObserver 会将变化分发到所有已经注册的 OnPreDrawListener 中。

如果 View 间有依赖,那么 View 状态变化时 CoordinatorLayout 的 onChildViewsChanged 就会被调用

// CoordinatorLayout
final void onChildViewsChanged(@DispatchChangeEvent final int type) {
    // ... 其他代码
    
    // 遍历 View 将事件分发下去
    for (int i = 0; i < childCount; i++) {
        final View child = mDependencySortedChildren.get(i);
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        if (type == EVENT_PRE_DRAW && child.getVisibility() == View.GONE) {
            // Do not try to update GONE child views in pre draw updates.
            continue;
        }

        // 先遍历依赖的 View 中是否有卯点依赖,如果有,则先将 View 的变化分发到通过卯点依赖的 View
        for (int j = 0; j < i; j++) {
            final View checkChild = mDependencySortedChildren.get(j);

            if (lp.mAnchorDirectChild == checkChild) {
                // 存在卯点依赖时,卯点发生状态改变时同时将对应的 View 状态修改
                offsetChildToAnchor(child, layoutDirection);
            }
        }
    
        // ... 其他代码
    
    
        // 遍历所有存在依赖的 View
        for (int j = i + 1; j < childCount; j++) {
            final View checkChild = mDependencySortedChildren.get(j);
            final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams();
            final Behavior b = checkLp.getBehavior();
    
            // layoutDependsOn 判断是否依赖
            if (b != null && b.layoutDependsOn(this, checkChild, child)) {
                if (type == EVENT_PRE_DRAW && checkLp.getChangedAfterNestedScroll()) {
                    // If this is from a pre-draw and we have already been changed
                    // from a nested scroll, skip the dispatch and reset the flag
                    checkLp.resetChangedAfterNestedScroll();
                    continue;
                }
    
                final boolean handled;
                switch (type) {
                    case EVENT_VIEW_REMOVED:
                        // View 的移除事件,则调用 Behavoir 的 onDependentViewRemoved 方法
                        b.onDependentViewRemoved(this, checkChild, child);
                        handled = true;
                        break;
                    default:
                        // 其他事件,调用 Behavoir 的 onDependentViewRemoved 方法
                        handled = b.onDependentViewChanged(this, checkChild, child);
                        break;
                }
    
                if (type == EVENT_NESTED_SCROLL) {
                    // If this is from a nested scroll, set the flag so that we may skip
                    // any resulting onPreDraw dispatch (if needed)
                    checkLp.setChangedAfterNestedScroll(handled);
                }
            }
        }
    }
    // ... 其他代码
}

通过代码分析知道了 CoordinatorLayout 中只要 View 间存在依赖,那么 View 变化时 onChildViewsChanged 方法就会被调用,该方法中会将卯点变化事件处理,卯点改变时对应的 View 状态也要改变。还会将事件改变分发到依赖的 Behavior 中,这样在 Behavior 中就可以处理啊依赖的 View 状态的变化事件了。

5. Behavior 的嵌套滑动系列方法

CoordinatorLayout 实现了 NestedScrollingParent 接口,所以当其中的子 View 存在实现了 NestedScrollingChild 接口的类时,子 View 的滑动事件都会分发到 CoordinatorLayout 中。这部分嵌套滑动机制有专门文章讲解。

我们就以 onNestedScroll 方法为例来分析滑动事件分发到 CoordinatorLayout 后的处理。


@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
        int dxUnconsumed, int dyUnconsumed) {
    onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
            ViewCompat.TYPE_TOUCH);
}

@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
        int dxUnconsumed, int dyUnconsumed, int type) {
    final int childCount = getChildCount();
    boolean accepted = false;

    // 遍历子 View 分发滑动事件
    for (int i = 0; i < childCount; i++) {
        final View view = getChildAt(i);
        if (view.getVisibility() == GONE) {
            // If the child is GONE, skip...
            continue;
        }

        final LayoutParams lp = (LayoutParams) view.getLayoutParams();
        
        // 如果 LayoutParams 没有接受这个事件序列,则不用处理
        if (!lp.isNestedScrollAccepted(type)) {
            continue;
        }

        final Behavior viewBehavior = lp.getBehavior();
        if (viewBehavior != null) {
            // 如果子 View 有 Behavior ,则调用 Bihavior 的 onNestedScroll 方法将滑动事件分发给子 View
            viewBehavior.onNestedScroll(this, view, target, dxConsumed, dyConsumed,
                    dxUnconsumed, dyUnconsumed, type);
            accepted = true;
        }
    }

    if (accepted) {
        onChildViewsChanged(EVENT_NESTED_SCROLL);
    }
}

这里的分发就比较简单了,只要 CoordinatorLayout 中检测到了子 View 产生了滑动,就会将对应的滑动事件分发给所有配置了 Behavior 的 View 中,这样在 Behavior 的滑动系列方法中,当前 View 就可以根据滑动做不同的相应。

其他 onNestedPreScroll、onStopNestedScroll、onNestedFling 等一系列方法同 onNestedScroll 过程类似,就不一一分析了。

6. Behavior 总结

到这里 Behavior 的工作过程就分析完了,在分析的过程中也逐渐发现设计的巧妙。通过 Behavior 我们也能实现更多的 View 间的依赖效果。Google 也给我们提供了 AppBarLayout、CollapsingToolbarLayout 等类提供了很多效果。当然我们不仅要会用这些类实现我们的需求,还要了解其深层次的原理与工作机制,这样在自定义、修改的时候就会更加得心应手。

思路延伸

通过 CoordinatorLayout 对其中配置了 Behavior 的 View 的处理方式我们可以得到一些思路,在自定义 ViewGroup 的时候,如果其中的子 View 可以配置一些由 Viewroup 提供的自定义的一些属性,当然系统的子 View 是无法感知的,我们可以通过 ViewGroup 在 inflat 解析布局时解析到 View 的 attr 中配置的自定义属性

然后由 ViewGroup 来管理配置了这些属性的 View ,在 ViewGroup 想要这些属性生效或者根据这些属性作出一定的效果时,就可以直接操作这些配置了自定义属性的 View,并且可以根据配置的自定义属性的值作出不同的效果。

推荐阅读更多精彩内容