博客原文:kyleduo.com
前言
这个系列源自前几天看到一篇使用CoordinatorLayout实现支付宝首页效果的文章,下载看了效果和源码,不敢苟同,所以打算自己动手。实现的过程有点曲折,但也发现了一些有意思的事情,用三篇文章来记录并分享给大家。
- CoordinatorLayout和Behavior
- 自定义CoordinatorLayout.Behavior
- 支付宝首页效果实现
文中:CoL代表CoordinatorLayout,ABL表示AppBarLayout,CTL表示CollapsingToolbarLayout,SRL表示SwipeRefreshLayout,RV表示RecyclerView。
源码:Github
第二篇文章主要用经典的CoordinatorLayout、AppBarLayout、RecyclerView的连动场景(CAR场景)来分析一下自定义Behavior需要关注的内容,以及如何自定义一个Behavior。同时,支付宝首页效果和AppBarLayout的效果有相似之处,分析CAR场景,也有益于后文实现支付宝首页效果。
这篇文章适合同时阅读源码,如果已经读过源码,可以直接跳到最后的总结。
Support包中的Behavior基类
CAR场景中一共出现了两个Behavior,AppBarLayout.Behavior和AppBarLayout.ScrollingViewBehavior,前者应用于ABL,后者应用于RV。这两个Behavior是我们这篇文章要分析的主要的类,但是在开始之前,我们要看一下他们的基类(职责分割的很不错)。
ViewOffsetBehavior
使用ViewOffsetHelper工具类封装View的偏移量。View类支持对offset进行偏移,但是并不会保存偏移量。ViewOffsetHelper对Offset和Top/Left进行缓存,使用ViewCompat工具类进行偏移处理。
private void updateOffsets() {
ViewCompat.offsetTopAndBottom(mView, mOffsetTop - (mView.getTop() - mLayoutTop));
ViewCompat.offsetLeftAndRight(mView, mOffsetLeft - (mView.getLeft() - mLayoutLeft));
}
ViewOffsetBehavior除了封装了对水平和垂直方向偏移的Setter和Getter方法,还覆写了onLayoutChild()
方法,上一篇文章中有提到,实现这个方法可以代理CoL对子View的布局。不过ViewOffsetBehavior覆写这个方法的目的主要是创建ViewOffsetHelper、获取真实偏移量并且将child偏移到正确位置。
说句题外话,当我们考虑一个滑动交互时,不要把滑动看做一个连续过程,而要拆分成多个单独的循环过程,连续的滑动只不过是单独循环过程在时间上不断重复而已;而滑动的单个循环过程,说到底都是对View进行偏移处理。当看到一个复杂交互效果的时候,要学会拆分,一个是刚说的时间上拆分,另一个方面就是要能拆分成多个单独效果的合成,能做到这一步,再加上牢固的基础,就没有什么交互效果是做不出来的。
HeaderBehavior
HeaderBehavior封装了经典Touch事件分发逻辑,主要是实现了Behavior的onInterceptTouchEvent
方法和onTouchEvent
方法,逻辑其实也很简单:
- 判断是否可以滑动
- 当滑动距离超过阈值之后,标记滑动(mIsBeingDragged)并进行拦截。
- 处理ACTION_MOVE事件,调用ViewOffsetBehavior的方法进行偏移。
- 使用VelocityTracker计算滑动速度。
- 在ACTION_UP分支中停止滑动并判断是否应该Fling
- 实现scroll和fling方法。
HeaderBehavior的实现简单且清晰,都可以当做经典Touch事件实现滑动的范例了,有这方面需求的童鞋不要错过。因为HeaderBehavior的定位很明确,实现类似AppBarLayout类似的Header功能,所以只处理了纵向滑动。
除了scroll和fling暴露给子类的方法主要是setHeaderTopBottomOffset
,这个方法一共有两个重载声明,可以设置边界值避免滑动越界。
int setHeaderTopBottomOffset(CoordinatorLayout parent, V header, int newOffset) {
return setHeaderTopBottomOffset(parent, header, newOffset,
Integer.MIN_VALUE, Integer.MAX_VALUE);
}
int setHeaderTopBottomOffset(CoordinatorLayout parent, V header, int newOffset,
int minOffset, int maxOffset) {
final int curOffset = getTopAndBottomOffset();
int consumed = 0;
if (minOffset != 0 && curOffset >= minOffset && curOffset <= maxOffset) {
// If we have some scrolling range, and we're currently within the min and max
// offsets, calculate a new offset
newOffset = MathUtils.constrain(newOffset, minOffset, maxOffset);
if (curOffset != newOffset) {
setTopAndBottomOffset(newOffset);
// Update how much dy we have consumed
consumed = curOffset - newOffset;
}
}
return consumed;
}
这个方法是有返回值的,这个返回值在子类中处理嵌套滑动或者再次分发滑动是非常有用。
HeaderScrollingViewBehavior
同样继承自ViewOffsetBehavior,HeaderScrollingViewBehavior的职责主要是完成对ScrollingView的布局。CoL的职责是给子类提供协调滚动的接口,并不会具体实现某种效果,所有子类需要完成的功能和效果,都需要通过统一接口Behavior完成。
在Header+ScrollingView的结构中,HeaderBehavior完成对Touch事件的处理,而HeaderScrollingViewBehavior要完成的,就是对ScrollingView的控制。这两者结合要实现的就是MaterialDesign中经典的可收起Header的效果。
为了让Header可收起,视觉上ScrollingView的高度被拉长了,但实际上ScrollingView的高度并没有变,变的是ScrollingView的位置。ScrollingView的测量和布局工作就是HeaderScrollingViewBehavior的实现内容。
final int childLpHeight = child.getLayoutParams().height;
if (childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT || childLpHeight == ViewGroup.LayoutParams.WRAP_CONTENT) {
// If the menu's height is set to match_parent/wrap_content then measure it
// with the maximum visible height
// {...}
return true;
}
}
return false;
onMeasureChild
方法中的注释说明了只要child的LayoutParams是MATCH_PARENT或者WRAP_CONTENT,就设置child的高度为最大可见高度。这里的最大可见高度包含除header之外的区域以及header收起时额外空出的区域,也就是header的可滚动区域。
int availableHeight = View.MeasureSpec.getSize(parentHeightMeasureSpec);
if (availableHeight == 0) {
// If the measure spec doesn't specify a size, use the current height
availableHeight = parent.getHeight();
}
final int height = availableHeight - header.getMeasuredHeight()
+ getScrollRange(header);
onLayout
中将ScrollingView置于header下方。
available.set(
parent.getPaddingLeft() + lp.leftMargin,
header.getBottom() + lp.topMargin,
parent.getWidth() - parent.getPaddingRight() - lp.rightMargin,
parent.getHeight() + header.getBottom() - parent.getPaddingBottom() - lp.bottomMargin
);
注意这里Rect的top值取header.getBottom() + lp.topMargin
,而不是getPaddingTop() + header.getHeight() + lp.topMargin
,这是因为header在onLayout时可能已经包含偏移量,不能假定header在初始位置,即便可能90%的情况均是如此。
说句题外话,项目开发过程中会遇到很多这类情况,有多种实现方式都能达到预期效果,但并不是所有的实现方案都是完整符合预期逻辑的。比如上面的例子,ScrollingView的预期位置是header下方,而不是父控件中除header高度以外的区域。有的时候,需要转换角度看问题,体会下这其中的区别。
AppBarLayout.Behavior
AppBarLayout.ScrollingViewBehavior相对简单,这里略过。AppBarLayout.Behavior继承自HeaderBehavior,在其基础上,主要实现了以下功能:
- 支持在布局文件中定义滚动效果:SCROLL / EXIT_UNTIL_COLLAPSED / ENTER_ALWAYS / ENTER_ALWAYS_COLLAPSED / SNAP
- 实现NestedScrolling回调
滚动效果不是这篇文章的重点,我们主要看下NestedScrolling的相关实现。
onStartNestedScroll
判断是否为纵向滑动,并且AppBarLayout支持折叠并且ScrollingView的大小超出屏幕范围。
final boolean started = (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0
&& child.hasScrollableChildren()
&& parent.getHeight() - directTargetChild.getHeight() <= child.getHeight();
onNestedPreScroll
这个方法会提前于ScrollingView消费滑动事件。AppBarLayout的scrollFlags,也就是上面说的滚动效果会影响onNestedPreScroll方法的实现。抛开这个影响,这个方法中,首先确定AppBarLayout的可滑动范围,然后调用scroll()
方法(继承自ViewOffsetBehavior)进行滚动,并将消费多少传递给consumed
数组。
onNestedScroll
如果向下滚动时,在ScrollingView消费完滑动事件之后,还有剩余,说明ScrollingView已经滚动到顶部,AppBarLayout开始展开。
onNestedFling
这里并没有进行精确的消费,只是当ScrollingView触发fling时,对AppBarLayout执行动画,展开或者收起。下篇文章实现支付宝首页效果时,实现了对fling的精确消费。
总结
自定义Behavior主要关心以下两个方面:
- 测量和布局
- 实现滑动效果
其中滑动效果有三种实现方式:
- 经典Touch事件。
- NestedScrolling。
- LayoutDependent。
一般情况下,CoL的child,如果自身不可滚动,需要实现NestedScrolling来进行联动,或者实现Touch事件回调。如果自身可滚动,通过onDependentViewChanged
方法来响应其他View的偏移量改变事件。