[Digging] 支付宝首页交互三部曲 2 自定义Behavior

cover_2

前言

这个系列源自前几天看到一篇使用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工具类进行偏移处理。

1
2
3
4
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,这个方法一共有两个重载声明,可以设置边界值避免滑动越界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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的实现内容。

1
2
3
4
5
6
7
8
9
10
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的可滚动区域

1
2
3
4
5
6
7
8
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下方。

1
2
3
4
5
6
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,在其基础上,主要实现了以下功能:

  1. 支持在布局文件中定义滚动效果:SCROLL / EXIT_UNTIL_COLLAPSED / ENTER_ALWAYS / ENTER_ALWAYS_COLLAPSED / SNAP
  2. 实现NestedScrolling回调

滚动效果不是这篇文章的重点,我们主要看下NestedScrolling的相关实现。

onStartNestedScroll

判断是否为纵向滑动,并且AppBarLayout支持折叠并且ScrollingView的大小超出屏幕范围。

1
2
3
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主要关心以下两个方面:

  1. 测量和布局
  2. 实现滑动效果

其中滑动效果有三种实现方式:

  1. 经典Touch事件。
  2. NestedScrolling。
  3. LayoutDependent。

一般情况下,CoL的child,如果自身不可滚动,需要实现NestedScrolling来进行联动,或者实现Touch事件回调。如果自身可滚动,通过onDependentViewChanged方法来响应其他View的偏移量改变事件。