使用 CoordinatorLayout + 自定义 Behavior 实现卡片堆叠效果

使用 CoordinatorLayout 实现堆叠效果

一、视图之间的滑动关联,使用 Behavior 连接

如官方示例中在布局中增加 AppBarLayout 布局,将需关联变化的 View 通过 标签app:layout_behavior="@string/appbar_scrolling_view_behavior" 建立连接关系,当AppBarLayout 发生变化会通知关联的子 view,子 view 再处理自身的变化完成一个复合型的视图变换,示例效果如下:

使用 CoordinatorLayout + 自定义 Behavior 实现卡片堆叠效果

简单的布局代码,需关联视图添加 Behavior ,AppBarLayout 中需滚动效果的视图添加标签 layout_scrollFlags="scroll|exitUntilCollapsed",完整代码示例:

<android.support.design.widget.CoordinatorLayout
	xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="6dp">

    <android.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:gravity="center"
            android:text="标题"
            android:textColor="@color/white"
            android:textSize="20sp"
            app:layout_scrollFlags="scroll|exitUntilCollapsed" />
    </android.support.design.widget.AppBarLayout>

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@drawable/bg_round_card"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />

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

实际上添加的 layout_behavior 标签,解析后会生成具体的 Behavior 实例,如 appbar_scrolling_view_behavior 生成的是 ScrollingViewBehavior,具体代码在 AppBarLayout 类中。我们没有看到 AppBarLayout 在 xml 文件中指定 behavior,但实际上它在内部有一个默认的 Behavior ,继承自 HeaderBehavior<AppBarLayout>,通过这个 Behavior 实现对外一层布局触摸事件的拦截监听,从而改变自身视图。

二、简单介绍 Behavior

这里指的是 CoordinatorLayout 类中的 Behavior

  • 1.Behavior 能做什么?

    • 可以拦截父布局的触摸事件,实现自己的业务逻辑,不需要通过自定义 View 实现
    • 可以添加需要关联滑动的 view,当依赖(关联)的视图发生变化时,可以及时获取通知并处理,不需通过观察者模式
    • 使用消耗量计算,如滑动是10dp,A消耗了6dp,那么剩余的4dp将给下一个视图使用,若没有消耗则由父布局接收处理,做一个事件的传递
    • 简单说明几个方法:
      • boolean layoutDependsOn 说明使用该 behavior 的视图依赖于哪个类型的 View 而变化,也就是监听指定 view
      • boolean onDependentViewChanged 当依赖的视图发生变化时,会回调这个方法,我们将在这做我们的视图处理
      • boolean onStartNestedScroll 嵌套滑动启动时回调
      • void onStopNestedScroll 嵌套滑动停止回调
      • void onNestedScroll 正在发生滑动回调
      • void onNestedPreScroll 依赖的视图处理滑动前回调
  1. Behavior 如何实现关联?
    在 CoordinatorLayout 源码中我们可以看到,无论在测量布局、摆放布局,还是处理触摸事件,都有如下代码:
for (int i = 0; i < childCount; i++) {
            final View child = topmostChildList.get(i);
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            final Behavior b = lp.getBehavior();
			if(b!=null){
				//省略
			}
}
通过遍历子view,调用 behavior (如果存在的话)去处理各种事件,从而实现关联,将变化处理转移到各自的 behavior 中去
  • 3.自定义 Behavior 注意事项
    • CoordinatorLayout 布局继承的是 ViewGroup,所以布局的测量、位置摆放可能需要自己实现,重写方法 onMeasureChildonLayoutChild
    • 大部分的关联滑动可以通过重写和 NestedScroll 相关的几个方法实现
    • 如果需要拦截整体触摸事件,可以重写 onInterceptTouchEventonTouchEvent 实现更复杂的效果

三、堆叠效果实现

效果图:
使用 CoordinatorLayout + 自定义 Behavior 实现卡片堆叠效果
通过自定behavior实现,检测触摸事件,在可滑动范围内拦截事件,改变自身的位置,因为本身变化只需要监听父布局的滑动,不依赖其他视图,所以重写几个相关的 NestedScroll 方法即可,详细代码如下:

Behavior 关键代码:

  1. 因为使用 CoordinatorLayout,所以需要设置 layout 的位置
private int limitOffset = 500, currentOffset = 500, targetOffset = 0;

@Override
public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
    //处理位置问题
    int childCount = parent.getChildCount();
    if (childCount > 1) {

        int left = parent.getPaddingLeft();
        int top = parent.getTop() + parent.getPaddingTop();
        for (int i = 0; i < childCount - 1; i++) {
            View _child = parent.getChildAt(i);
            CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) _child.getLayoutParams();
            top += _child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
            if (i == 0) {
                limitOffset = currentOffset = top / 2;
            }
        }

		//摆放最后一个view,计算前面所有视图的高
        final CoordinatorLayout.LayoutParams lp =
                (CoordinatorLayout.LayoutParams) child.getLayoutParams();
        limitOffset = currentOffset = currentOffset + lp.topMargin;//计算盖住的距离

        child.layout(left + lp.leftMargin, top + lp.topMargin,
                parent.getWidth() - parent.getPaddingRight() - lp.rightMargin,
                parent.getHeight() - parent.getPaddingBottom() - lp.bottomMargin + limitOffset);
        return true;
    } else {
        return super.onLayoutChild(parent, child, layoutDirection);
    }
}

  1. view 测量的问题,因为向上移动后,会导致布局的下方出现空白,所以给高度再加上偏移的距离
@Override
public boolean onMeasureChild(CoordinatorLayout parent, View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
    //处理测量问题
    int height = View.MeasureSpec.getSize(parentHeightMeasureSpec);

    int childCount = parent.getChildCount();
    if (childCount > 1) {
        int maxHeight = parent.getTop() + parent.getPaddingTop();
        for (int i = 0; i < childCount; i++) {
            View _child = parent.getChildAt(i);
            CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) _child.getLayoutParams();
            maxHeight += _child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
        }

        if (maxHeight > height) {
            CoordinatorLayout.LayoutParams lp =
                    (CoordinatorLayout.LayoutParams) child.getLayoutParams();

            int nWidthMeasureSpec = View.MeasureSpec.makeMeasureSpec(child.getMeasuredWidth(),
                    lp.width == ViewGroup.LayoutParams.MATCH_PARENT ? View.MeasureSpec.EXACTLY : lp.width);

			//加入偏移量
            int nHeight = child.getMeasuredHeight() - height + maxHeight;
            int nHeightSpec = View.MeasureSpec.makeMeasureSpec(nHeight, lp.height == ViewGroup.LayoutParams.MATCH_PARENT ?
                    View.MeasureSpec.EXACTLY : lp.height);
            child.measure(nWidthMeasureSpec, nHeightSpec);
            return true;
        }
    }
    return super.onMeasureChild(parent, child, parentWidthMeasureSpec, widthUsed, parentHeightMeasureSpec, heightUsed);
}
  1. 因为不依赖与其他视图,所以我们不需要重写 layoutDependsOn,而重写 nestedScroll 的几个方法

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


@Override
public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
    Log.i(TAG, "onNestedPreScroll == " + dy);
    if (canScroll(target)) {
        return;
    }
    if (dy > 0) {//手指往上滑动
        //消耗量
        int parentCanConsume = currentOffset - targetOffset;
        if (parentCanConsume > 0) {
            if (dy > parentCanConsume) {
                consumed[1] = parentCanConsume;//消耗量,已消耗的大小
                moveView(child, -parentCanConsume);
            } else {
                consumed[1] = dy;//消耗量,已消耗的大小
                moveView(child, -dy);
            }
        }
    }
}

@Override
public void onNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child,@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
    Log.i(TAG, "onNestedScroll == " + dyUnconsumed);
    if (dyUnconsumed < 0 && !canScroll(target)) {//手指往下滑动
		//边界判断,如果超过设置的偏移距离,不再处理偏移
        if (currentOffset < limitOffset) {
            int dy = currentOffset - dyUnconsumed <= limitOffset ? -dyUnconsumed : limitOffset - currentOffset;
           
            moveView(child, dy);
        }
    }
}

//判断是否可用滑动
private boolean canScroll(View target) {
    return ViewCompat.canScrollVertically(target, -1);
}

//视图的偏移处理
private void moveView(View child, int dy) {
    int dis = currentOffset + dy;
    dis = Math.max(dis, targetOffset);
    ViewCompat.offsetTopAndBottom(child, dis - currentOffset);
    currentOffset = dis;
}

xml 布局:使用 CoordinatorLayout 作为根布局,给指定View添加Behavior,当然需要在 strings.xml 文件中写明完整的路径

//strings 文件中
<string name="card_stack_behavior">com.zhou.android.common.CardStackBehavior</string>
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="6dp">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/shape_round_blue"
        android:gravity="center"
        android:text="标题"
        android:textColor="@color/white"
        android:textSize="20sp" />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@drawable/bg_round_card"
        app:layout_behavior="@string/card_stack_behavior" />

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

这样,我们一个自定义 Behavior 就写完了。

完整代码可移步:github AndroidDemo

参考文章:玩转Android嵌套滚动

已开通微信公众号码农茅草屋,有兴趣可以关注,一起学习

使用 CoordinatorLayout + 自定义 Behavior 实现卡片堆叠效果