使用 CoordinatorLayout 实现复杂联动效果

13,406 阅读10分钟

CoordinatorLayout 是 Google 在 Design Support 包中提供的一个十分强大的布局视图,它本质是一个 FrameLayout,然而它允许开发者通过制定 Behavior 从而实现各种复杂的 UI 效果。

本文就通过一个具体的例子来讲解一下 Behavior 的开发思路,首先我们看效果(GIF 图效果一般,大家就看看大概意思吧):


效果图

我们先归纳一下整个效果的细节:

  • 界面分为上下两部分,上部分随列表滑动而折叠与展开;
  • 头部视图背景随折叠状态而缩放和渐变;
  • 浮动搜索框随折叠状态改变位置和 margins;
  • 滑动结束前会根据滑动速度动画到相应的状态:
    • 如果速度达到一定阈值,则按速度方向切换状态
    • 如果速度未达到阈值,则切换到距离当前状态最近的状态;

主要的细节就是这些,下面我们来一步步实现它!

编写布局文件

首先我们将所有的控件在 xml 写好,由于是 Demo,我这里就用一些很简单的控件了。

activity_main.xml:




    

    
        
    

    


这里需要注意的是 CoordinatorLayout 子视图的层级关系,如果想在子视图中使用 Behavior 进行控制,那么这个子视图一定是 CoordinatorLayout 的直接孩子,间接子视图是不具有 behavior 属性的,原因当然也很简单,behavior 是 LayoutParams 的一个属性,而间接子视图的 LayoutParams 根本不是 CoordinatorLayout 类型的。

通过分解整个效果,我们可以将 Behavior 分为两个,分别应用于 RecyclerView (或者其他支持 Nested Scrolling 的滚动视图)和搜索框。

Behavior 基本概念

不要其被表面吓到了,Behavior 实际就是将一些布局的过程以及 Nested Scrolling 的过程暴露了出来,利用代理和组合模式,可以让开发者为 CoordinatorLayout 添加各种效果插件。

依赖视图

一个 Behavior 能够将指定的视图作为一个依赖项,并且监听这个依赖项的一切布局信息,一旦依赖项发生变化,Behavior 就可以做出适当的响应。很简单的例子就是 FABSnackBar 的联动,具体表现就是 FAB 会随 SnackBar 的弹出而上移,从而不会被 SnackBar 遮挡,这就是依赖视图的最简单的一个用法。

Nested Scrolling

这是 Google 开发的一种全新嵌套滚动方案,由 NestedScrollingParentNestedScrollingChild 组成,一般来讲我们都会围绕 NestedScrollingParent 来进行开发,而 NestedScrollingChild 相比来说较为复杂,本文也不赘述其具体用法了。NestedScrollingParent(下文简称 NSP) 和 NestedScrollingChild(下文简称 NSC) 有一组相互配对的事件方法,NSC 负责派发这些方法到 NSPNSP 可以对这些方法做出响应。同时 Google 也提供了一组 Helper 类来帮助开发者使用 NSPNSC,其中 NestedScrollingParentHelper 较为简单,仅是记录一下滚动的方向。对于 Nested Scrolling 的具体用法,我在下文中会详细讲解。

案例 Behavior 实现思路

我们最终需要实现两个 Behavior 类:
HeaderScrollingBehavior 负责协调 RecyclerView 与 Header View 的关系,同时它依赖于 Header View,因为它要根据 Header View 的位移调整自己的位置。
HeaderFloatBehavior 负责协调搜索框与 Header View 的关系,也是依赖于 Header View,相对比较简单。

可以看到,整个视图体系都是围绕 Header View 展开的,Recycler View 通过 Nested Scrolling 机制调整 Header View 的位置,进而因 Header View 的改变而影响自身的位置。搜索框也是随 Header View 的位置变化而改变自己的位置、大小与背景颜色,这里只需要依赖视图这一个概念就可以完成。

实现 HeaderScrollingBehavior

首先继承自 Behavior,这是一个范型类,范型类型为被 Behavior 控制的视图类型:

public class HeaderScrollingBehavior extends CoordinatorLayout.Behavior {

    private boolean isExpanded = false;
    private boolean isScrolling = false;

    private WeakReference dependentView;
    private Scroller scroller;
    private Handler handler;

    public HeaderScrollingBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
        scroller = new Scroller(context);
        handler = new Handler();
    }

    ...

}

解释一下这几个实例变量的作用,Scroller 用来实现用户释放手指后的滑动动画,Handler 用来驱动 Scroller 的运行,而 dependentView 是依赖视图的一个弱引用,方便我们后面的操作。剩下的是几个状态变量,不多解释了。

我们先看这几个方法:

@Override
public boolean layoutDependsOn(CoordinatorLayout parent, RecyclerView child, View dependency) {
    if (dependency != null && dependency.getId() == R.id.scrolling_header) {        
        dependentView = new WeakReference<>(dependency);
        return true;
    }
    return false;
}

负责查询该 Behavior 是否依赖于某个视图,我们在这里判读视图是否为 Header View,如果是则返回 true,那么之后其他操作就会围绕这个依赖视图而进行了。

@Override
public boolean onLayoutChild(CoordinatorLayout parent, RecyclerView child, int layoutDirection) {
    CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
    if (lp.height == CoordinatorLayout.LayoutParams.MATCH_PARENT) {
        child.layout(0, 0, parent.getWidth(), (int) (parent.getHeight() - getDependentViewCollapsedHeight()));
        return true;
    }
    return super.onLayoutChild(parent, child, layoutDirection);
}

负责对被 Behavior 控制的视图进行布局,就是将 ViewGrouponLayout 针对该视图的部分抽出来给 Behavior 处理。我们判断一下如果目标视图高度要填充父视图,我们就自己将其高度减去 Header View 折叠后的高度。为什么要这么做呢?因为 CoodinatorLayout 就是一个 FrameLayout,不像 LinearLayout 一样能自动分配各个 View 的高度,因此我们要自己实现大小控制。

@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, RecyclerView child, View dependency)
 {
    Resources resources = getDependentView().getResources();
    final float progress = 1.f -
            Math.abs(dependency.getTranslationY() / (dependency.getHeight() - resources.getDimension(R.dimen.collapsed_header_height)));

    child.setTranslationY(dependency.getHeight() + dependency.getTranslationY());

    float scale = 1 + 0.4f * (1.f - progress);
    dependency.setScaleX(scale);
    dependency.setScaleY(scale);

    dependency.setAlpha(progress);

    return true;
}

这段就是根据依赖视图进行调整的方法,当依赖视图发生变化时,这个方法就会被调用。这里我把相关的尺寸数据写到了 dimens.xml 中,通过当前依赖视图的位移,计算出一个位移因数(取值 0 - 1),对应到依赖视图的缩放和透明度。

在这个例子中,依赖视图的属性影响到了依赖视图自己的属性,这也是可以的,因为我们主要依赖的就是 translateY 这个属性,其他依赖视图属性本质就是一个 Computed Property。最后别忘了设置目标视图的位移,让其始终跟在 Header View 下面。

还有两个便利函数,比较简单:

private float getDependentViewCollapsedHeight() {
    return getDependentView().getResources().getDimension(R.dimen.collapsed_header_height);
}

private View getDependentView() {
    return dependentView.get();
}

下面我们主要来看看 Nested Scrolling 怎么实现。

本例子中我们需要 NSP (Behavior 就是 NSP 的一个代理) 的这几个回调方法:

  • onStartNestedScroll
  • onNestedScrollAccepted
  • onNestedPreScroll
  • onNestedScroll
  • onNestedPreFling
  • onStopNestedScroll

onStartNestedScroll

用户按下手指时触发,询问 NSP 是否要处理这次滑动操作,如果返回 true 则表示“我要处理这次滑动”,如果返回 false 则表示“我不 care 你的滑动,你想咋滑就咋滑”,后面的一系列回调函数就不会被调用了。它有一个关键的参数,就是滑动方向,表明了用户是垂直滑动还是水平滑动,本例子只需考虑垂直滑动,因此判断滑动方向为垂直时就处理这次滑动,否则就不 care。

onNestedScrollAccepted

NSP 接受要处理本次滑动后,这个回调被调用,我们可以做一些准备工作,比如让之前的滑动动画结束。

onNestedPreScroll

NSC 即将被滑动时调用,在这里你可以做一些处理。值得注意的是,这个方法有一个参数 int[] consumed,你可以修改这个数组来表示你到底处理掉了多少像素。假设用户滑动了 100px,你做了 90px 的位移,那么就需要把 consumed[1] 改成 90(下标 0、1 分别对应 x、y 轴),这样 NSC 就能知道,然后继续处理剩下的 10px。

onNestedScroll

上一个方法结束后,NSC 处理剩下的距离。比如上面还剩 10px,这里 NSC 滚动 2px 后发现已经到头了,于是 NSC 结束其滚动,调用该方法,并将 NSC 处理剩下的像素数作为参数(dxUnconsumeddyUnconsumed)传过来,这里传过来的就是 8px。参数中还会有 NSC 处理过的像素数(dxConsumeddyConsumed)。这个方法主要处理一些越界后的滚动。

onNestedPreFling

用户松开手指并且会发生惯性滚动之前调用。参数提供了速度信息,我们这里可以根据速度,决定最终的状态是展开还是折叠,并且启动滑动动画。通过返回值我们可以通知 NSC 是否自己还要进行滑动滚动,一般情况如果面板处于中间态,我们就不让 NSC 接着滚了,因为我们还要用动画把面板完全展开或者完全折叠。

onStopNestedScroll

一切滚动停止后调用,如果不会发生惯性滚动,fling 相关方法不会调用,直接执行到这里。这里我们做一些清理工作,当然有时也要处理中间态问题。

思路有了,我们直接看代码就很容易理解了:

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

@Override
public void onNestedScrollAccepted(CoordinatorLayout coordinatorLayout, RecyclerView child, View directTargetChild, View target, int nestedScrollAxes) {
    scroller.abortAnimation();
    isScrolling = false;
    super.onNestedScrollAccepted(coordinatorLayout, child, directTargetChild, target, nestedScrollAxes);
}

@Override
public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, RecyclerView child, View target, int dx, int dy, int[] consumed) {
    if (dy < 0) {
        return;
    }
    View dependentView = getDependentView();
    float newTranslateY = dependentView.getTranslationY() - dy;
    float minHeaderTranslate = -(dependentView.getHeight() - getDependentViewCollapsedHeight());
    if (newTranslateY > minHeaderTranslate) {
        dependentView.setTranslationY(newTranslateY);
        consumed[1] = dy;
    }
}

@Override
public void onNestedScroll(CoordinatorLayout coordinatorLayout, RecyclerView child, View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
    if (dyUnconsumed > 0) {
        return;
    }
    View dependentView = getDependentView();
    float newTranslateY = dependentView.getTranslationY() - dyUnconsumed;
    final float maxHeaderTranslate = 0;
    if (newTranslateY < maxHeaderTranslate) {
        dependentView.setTranslationY(newTranslateY);
    }
}

@Override
public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, RecyclerView child, View target, float velocityX, float velocityY) {
    return onUserStopDragging(velocityY);
}

@Override
public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, RecyclerView child, View target) {
    if (!isScrolling) {
        onUserStopDragging(800);
    }
}

值得注意的是展开和折叠两个动作我分别分配到 onNestedPreScrollonNestedScroll 中处理了,为什么这么做呢。我来解释一下,当 Header 完全展开时,用户只能向上滑动,此时 onNestedPreScroll 会先调用,我们判断滚动方向,如果是向上滚动,我们再看面板的位置,如果可以被折叠,那么我们就改变 Header 的 translateY,并且消耗掉相应的像素数。如果 Header 完全折叠了,NSC 就可以继续滚动了。

任何情况下用户向下滑动都不会走 onNestedPreScroll,因为我们在这个方法一开始就短路掉了,因此直接到 onNestedScroll,如果 NSC 还可以滚动,那么 dyUnconsumed 就是 0,我们就什么都不需要做了,此时用户要滚动 NSC,一旦 dyUnconsumed 有数值了,则说明 NSC 滚到头了,而如果此时正向下滚动,我们就有机会再处理 Header 位移了。这里为什么不放到 onNestedPreScroll 处理呢?因为如果 Header 完全折叠了,RecyclerView 又可以向下滚动,这时我们就不能决定是让 Header 位移还是 RecyclerView 滚动了,只有让 RecyclerView 向下滚动到头才能保证唯一性。

这里比较绕,大家要结合效果好好理解一下。

最后这个类还有一个方法:

private boolean onUserStopDragging(float velocity) {
    View dependentView = getDependentView();
    float translateY = dependentView.getTranslationY();
    float minHeaderTranslate = -(dependentView.getHeight() - getDependentViewCollapsedHeight());

    if (translateY == 0 || translateY == minHeaderTranslate) {
        return false;
    }

    boolean targetState; // Flag indicates whether to expand the content.
    if (Math.abs(velocity) <= 800) {
        if (Math.abs(translateY) < Math.abs(translateY - minHeaderTranslate)) {
            targetState = false;
        } else {
            targetState = true;
        }
        velocity = 800; // Limit velocity's minimum value.
    } else {
        if (velocity > 0) {
            targetState = true;
        } else {
            targetState = false;
        }
    }

    float targetTranslateY = targetState ? minHeaderTranslate : 0;
    scroller.startScroll(0, (int) translateY, 0, (int) (targetTranslateY - translateY), (int) (1000000 / Math.abs(velocity)));
    handler.post(flingRunnable);
    isScrolling = true;

    return true;
}

用来判断是否处于中间态,如果处于中间态,我们需要根据滑动速度决定最终切换到哪个状态,这里滚动我们使用 Scroller 配合 Handler 来实现。这个函数的返回值将会被作为 onNestedPreFling 的返回值。

方法中向 Handler 添加的 Runnable 如下:

private Runnable flingRunnable = new Runnable() {
    @Override
    public void run() {
        if (scroller.computeScrollOffset()) {
            getDependentView().setTranslationY(scroller.getCurrY());
            handler.post(this);
        } else {
            isExpanded = getDependentView().getTranslationY() != 0;
            isScrolling = false;
        }
    }
};

很简单就不解释了。


OK,以上就是 HeaderScrollingBehavior 的全部内容了。

实现 HeaderFloatBehavior

相信大家有了上面的经验,这个类写起来就很简单了。我们只需要实现 layoutDependsOnonDependentViewChanged 就行了。
下面是 onDependentViewChanged 的代码:


到这里两个 Behavior 就都写完了,直接在布局 xml 中引用就可以了,Activity 或 Fragment 中不需要做任何设置,是不是很方便。

总结

CoordinatorLayoutBehavior 结合可以做出十分复杂的界面效果,本文也只是介绍了冰山一角,很难想象没有它,这些效果的实现将是一件多么复杂的事情 :-)

- EOF -