一、问题背景

熟悉云音乐的同学应该对云音乐的个人主页、歌手页等详情页面不陌生,这些详情页一般分为上下两部分,上方头部部分一般用来展示一些重要信息,下方内容区域一般是多tab结构,每个tab下通常用列表展示详情一个维度的信息。滑动头部和下方内容区域都可以让页面滚动,页面上滑时会优先收起头部部分,悬停标题tab再上滚列表内容,反之亦然。当头部背景是图片时还可以下拉放大图片,方便用户查看图片细节。这样的设计还是很经典的,其他App也会经常看到类似的设计。

为了实现上面的效果,在那个遥远的还没有嵌套滚动机制的年代,我们想出了如下这种取巧的设计,既不需要复杂的事件拦截与派发,也能保证较好的滑动性能。示意图如下:

不同于直观上的上下结构,ViewPage和里面的ListView是占满整个屏幕的,而头部的header则是覆盖在ViewPage上面的。同时每个ViewPage里面的ListView都会添加一个占位的headView,它的高度和头部的header一样高。在listView滚动时使header保持和listView的headView底部对齐,这样滑动listView时头部header也会随著变化,从而实现上面效果。这样设计还有一个好处是滑动头部其实相当于滑动下方listView的headView,避免手动拦截头部header的事件再分发给listView。

当然事物都是有利有弊的,这样的设计也带了一些问题。首先是实现缺乏内聚性,为了实现效果需要对每个tab下面的listView添加占位的headView、设置各种OnScrollChangeListener和OnTouchListener,当然这些可以通过框架封装统一处理,不过还是不符合我们程序员简单即美的调性。

其次有一些可以性能优化的地方,我们都知道触发requestLayout和measure是很多意想不到的性能损耗的主要原因,比如下拉放大图片时会同时修改listView的headView的高度,会导致ListView不断的requestLayout,再比如切tab时或者收起头部head时都会调用listView的setSelectionFromTop方法,该方法内部也会调用requestLayout。

最后为了处理一些特别的场景会存在一些诡异的代码,比如第一个tab的listView有很多数据,可以一直上滚到头部隐藏,但第二个tab只有1个数据,它其实是无法上滚的,如果此时从第一个tab切到第二个tab则会出现头部从隐藏突变到全部出现。为了解决这个问题得给每个listView在设置一个占位的footView,它的高度具体是多少得先测量整个listView的高度是否比屏幕高度下以及小多少。而这个测量过程也会产生额外的measure开销。

二、嵌套滚动方案及调优

随著google在Android 5.0开始在View框架中加入了嵌套滚动机制以及在support v4库中提供了相应的介面,问题迎来了转机。关于嵌套滚动机制以及NestedScroll相关api在这里就不赘述,大家感兴趣的话可以查看相关api文档以及相关资料[1]。嵌套滚动机制核心是可以避免传统的从上到下的事件分发机制只要有一级消费了事件其他层级就不能参与事件消费的尴尬,它提供了一种可能,父级View和子级View共同协商消费滚动事件。关于使用嵌套滚动实现详情页的基本效果网上还是有一些实现,比如微信读书和网易考拉的实现[2][3],大家也可以看一看博采众长。下面我们开始讲解我们的方案实现以及相关的调整优化。

1.初步实现

其实上面一直在讲的详情页设计的关键就是滚动页面时是先滚头部还是先滚下面的列表区域,为了让滚动更丝滑流畅还需要能够让一部分先滚再让另一部分接著滚,这种共同协商消费滚动事件正是嵌套滚动机制的用武之地。首先我们要做的就是自定义我们的AdjustableHeaderLinearLayout作为父级View来实现上面的效果,同时将原来的布局层级做如下调整。整体布局变得扁平了,同时也避免了header覆盖在ViewPager上产生的过度绘制,当然在KitKat之后过度绘制的影响被大幅度削减了,不过我们还是应该尽可能的避免过度绘制。

作为嵌套滑动的父控制项,AdjustableHeaderLinearLayout(后面简写AHLLayout)需要实现parent相关的介面。网上非常多的资料还是实现的是NestedScrollingParent介面,但推荐实现的应该是NestedScrollingParent2。因为使用NestedScrollingParent的话,fling操作会变成在onNestedPreFling和onNestedFling进行判断是否消费的一锤子买卖,如果父控制项决定不消费fling事件,那么它后面在子控制项消费了部分fling距离后也不能接著处理剩下的fling距离,导致列表进行fling操作时会出现生硬的中断,具体可以阅读这篇文章[4]。

如果滚动下方的RecyclerView(RV)的,相关的调用流程如下: (RV)startNestedScroll → (AHLLayout)onStartNestedScroll → (AHLLayout)onNestedScrollAccepted→ (RV)dispatchNestedPreScroll → (AHLLayout)onNestedPreScroll→ (RV)dispatchNestedScroll→ (AHLLayout)onNestedScroll → (RV)stopNestedScroll → (AHLLayout)onStartNestedScroll。如上RV在自己滚动都会先通过onNestedPreScroll询问AHLLayout要不消费相应的滚动量以及具体消费多少。所以我们先看最关键的onNestedPreScroll方法。

@Override
public void onNestedPreScroll(@NonNull final View target, int dx, int dy, int[] consumed, int type) {
if (dy > 0) { // 向上滚动
if (getScrollY() < mMinHeaderHeight) {
scrollBy(0, dy);
consumed[1] = dy;
}
} else { // 向下滚动
boolean canScrollDown = target.canScrollVertically(-1);
if (!canScrollDown) {
if (getScrollY() > 0) {
scrollBy(0, dy);
consumed[1] = dy;
}
}
}
}

consumed数组表示的parent分别在x,y方向上需要消费多少距离,consumed[1]表示的就是y方向要消费的距离。scrollBy方法会改变View的scrollY从而整体移动View的内容且不会引起View的重新layout和measure,整体移动AHLLayout其实就相当于滚动了header,所以我们用这个来实现上滑头部的功能。这样再看实现逻辑还是很清晰的,如果是向上滚的情况,应该先判断是否需要上滚头部,向下滚动时先让RV滚动,如果RV不能滚动时再向下移动header。 还要注意的是通过fling产生的滚动也会通过这个方法先询问AHLLayout要不要消费滚动距离,我们这里对两种滚动采用相关处理。

2.分发头部的滚动

上面已经实现了基本的效果,不过还有很多细节需要考虑,有时候这些细节比功能的实现还让人头疼,就好比功能实现一小时,UI调整大半天。在详情页的设计中应该是可以滑动头部的header的,但在上面的实现中header只是一个普通的ViewGroup,而且它和RV是处于同级的关系,不存在父子关系也没法传递touch事件。当然这里可以手动拦截各种事件再进行各种处理再进行分发,方法还是有很多,我们这里选了一个简单的方案,我们在AHLLayout dispatchTouchEvent接受到Down事件是先判断是否在头部header之内,接著在通过后续事件判断发生了滚动动作时,先补发一个Down事件并将后续事件的y坐标添加一个等于头部header高度的偏移,这样正好落在下方的RV的处理范围等价于滚动了下方的RV,这样做还有一个好处是只在确定是滚动动作才hook事件的分发不会吃掉头部一些按钮等的点击事件。 下面是dispatchTouchEvent中的示意代码:

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
mTouchDownOnHeader = ev.getY() < getMaxNeedHideHeight() - getScrollY();
}
if (mTouchDownOnHeader && mNeedHackDispatchTouch) {
return super.dispatchTouchEvent(obtainNewMotionEvent(ev));
}
return super.dispatchTouchEvent(ev);
}

// stickHeaderHeight表示的是悬停的tab的高度,
// minHeaderHeight表示是头部header的高度
private int getMaxNeedHideHeight() {
return getMinHeaderHeight() - mStickHeaderHeight;
}

下面是判读是否需要hook dispatchTouchEvent的示意代码:

final GestureDetector gestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onDown(MotionEvent e) {
return mTouchDownOnHeader;
}

@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (mTouchDownOnHeader) {
mNeedHackDispatchTouch = true;
AdjustableHeaderLinearLayout.this.dispatchTouchEvent(e1);
AdjustableHeaderLinearLayout.this.dispatchTouchEvent(e2);
}
return mTouchDownOnHeader;
}
});
setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
return gestureDetector.onTouchEvent(event);
}
});

obtainNewMotionEvent方法是通过将接受到的MotionEvent的y坐标增加一个偏移量构造一个新的MotionEvent,这里有个注意点就是需要考虑多指触摸的场景,所以要使用对应的MotionEvent.obtain方法构造,不然多指拖动下方的RV时会出现IndexOutOfRangeException。

3.实现下拉放大效果

详情页头部一般都会使用图片作为背景,通常交互为了用户更好的使用体验都会支持下拉放大图片。以前的方案是下拉时动态调整header的高度,导致不停地重新requestLayout,那有没有更合理和高效的实现吗?

我们知道高效移动的关键是尽量使用支持直接修改渲染线程的RenderProperty的相关API,比如translation和scale等属性动画API,随著这个思路我们想出了如下方案,核心是使用translationY整体下移和使用scale放大头部的背景图。

需要注意的是使用scale放大背景View还需要将它的parent也就是header的clipChildren设置为false;如果整体下移的是AHLLayout的话还需要将AHLLayout以及AHLLayout的parent的clipChildren都设为false;如果AHLLayout不动,下移每个child,只需要将AHLLayout的clipChildren设为false。 这种方案非常高效但需要对背景View做一些特别处理,可以选择暴露相关介面和参数让使用者去处理对背景View的缩放,最终我们选择将背景View传给AHLLayout统一处理,减少重复逻辑和工作量。 示意代码如下:

...
if (mNeedDragOver && type == ViewCompat.TYPE_TOUCH){
if (getContentTransY() < getMaxDragOverHeight()) {
doOverScroll(getContentTransY() - dy);
}
...

public void setHeaderBackground(View image) {
mHeadBackgroundView = image;
((ViewGroup) mHeaderView).setClipChildren(image == null);
setClipChildren(image == null);
}

private void doOverScroll(float targetTransY) {
if (targetTransY < 0) {
targetTransY = 0;
}
for (int i = 0; i < getChildCount(); i++) {
getChildAt(i).setTranslationY(targetTransY);
}
if (mHeadBackgroundView != null) {
int originHeight = mHeadBackgroundView.getMeasuredHeight();
float scale = (originHeight + targetTransY) * 1f / originHeight;
mHeadBackgroundView.setScaleX(scale);
mHeadBackgroundView.setScaleY(scale);
}
}

4.上滑图片的视差效果

这里还有一个细节,原来由于是改变header的高度同时背景图片用的是centerCrop,所以上滑收起头部时背景图片一直显示的是中间部分,整个过程有一种视差效果,看起来不单调。但新方法上滑其实只是整体移动了内容,背景图片也会跟著上移,所以我们得想个方案。

遵循上面提到的高效移动原则,我们可以在上滑过程中减小背景图片的translationY,让背景图有个向下运动,与整体的向上运动相抵,看上去背景图片一直居中显示。具体的代码如下所示:

@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if (mHeaderScrollListener != null) {
if (oldt > 0 && t <=0) {
mHeaderScrollListener.onHeaderTotalShow();
} else if (t == getMaxNeedHideHeight()) {
mHeaderScrollListener.onHeaderTotalHide();
}
mHeaderScrollListener.onScroll(t > 0 ? t : 0);
}
if (t <= 0) {
setHeaderViewPaddingTop(getMaxDragOverHeight() + t);
}
}

final ImageView bg = findViewById(R.id.imageBg);
nestedScrollParentLinearLayout.setHeaderScrollListener(new AdjustableHeaderLinearLayout.HeaderScrollListener() {
@Override
public void onScroll(int dy) {
bg.setTranslationY(dy / 2f);
}
}

5.scrollTo or 其他?

我们知道scrollTo的内部实现会调用postInvalidateOnAnimation,导致AHLLayout会调用它的invalidate方法重新走绘制流程,不过好在头部相关View也会在绘制流程中有防重入保护。在一次和同事的交流中聊到offsetTopAndBottom系列api,它们和属性动画等相关api一样,都是直接修改渲染线程的RenderProperty实现,可以避免UI线程绘制流程。不过它只能实现一些简单的效果,同时它不像scrollTo一样是布局稳定的,它在每次重新layout的时候会被重置,通常需要配合使用谷歌封装的ViewOffsetHelper保存一些状态量。但是冲著它优秀的绘制性能,我们决定还是使用offsetTopAndBottom实现滑动头部的效果。改造的话其实也不复杂,就是把头部原来scrollBy改成offsetTopAndBottom实现。

@Override
public void onNestedPreScroll(@NonNull final View target, int dx, int dy, int[] consumed, int type) {
LogUtil.test(" : " + getScrollY());
if (dy > 0) {
if (getTop() > -getMaxNeedHideHeight()) {
consumed[1] = dy;
if (getScrollY() < 0) {
scrollBy(0, dy);
} else {
scrollByOffsetTop(dy);
}
}
} else {
boolean canScrollDown = target.canScrollVertically(-1);
if (!canScrollDown) {
consumed[1] = dy;
if (getTop() < 0) {
scrollByOffsetTop(getTop() - dy > 0 ? getTop() : dy);
} else if (mNeedDragOver && type == ViewCompat.TYPE_TOUCH){
if (getScrollY() > -getMaxDragOverHeight()) {
scrollBy(0, dy);
}
}
}
}
}

scrollByOffsetTop方法做的就是设置AHLLayout的offsetTopAndBottom,同时将onScrollChanged相关的逻辑迁移过来。

private void scrollByOffsetTop(int dy) {
int oldTop = getTop();
int maxNeedHideHeight = getMaxNeedHideHeight();
int newTop = oldTop - dy;
if (newTop < -maxNeedHideHeight) {
dy = maxNeedHideHeight + oldTop;
}
newTop = oldTop - dy;
if (mHeaderScrollListener != null) {
if (oldTop < 0 && newTop >= 0) {
mHeaderScrollListener.onHeaderTotalShow();
} else if (newTop == -maxNeedHideHeight) {
mHeaderScrollListener.onHeaderTotalHide();
}
mHeaderScrollListener.onScroll(-newTop);
}
mOldTop = newTop;
offsetTopAndBottom(-dy);
}

如果这时触发重新requestLayout的话比如点击切换tab等,上面设置的offsetTopAndBottom会被重置导致布局抖动,所以我们在onLayout中还需要简单处理下。

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (mOldTop != 0) {
offsetTopAndBottom(mOldTop - t);
}
}

至此我们已经讲解了嵌套滚动方案实现原理以及调优的过程,相关demo的示例代码大家下载查看:note.youdao.com/notesha

三、CoordinateLayout实现方案

回过头来看,虽然使用AHLLayout可以高效的实现上面的交互效果,但从代码设计上它还是有问题,它做了太多事情,有太多职责。并且所有的交互逻辑都耦合在它内部,不方便抽象复用。 好在官方在design包中引入了CoordinateLayout,CoordinateLayout通过Behavior给了我们拦截ViewGroup很多处理的机会,包括如何处理子View的测量布局,如何处理子View的touch事件,如何处理嵌套滚动机制[5]。通过Behavior,我们基本上可以改变和自定义上面所有的行为。CoordinateLayout和官方实现的几个Behavior都非常优秀,相信大家查看后肯定会受益匪浅,后面有机会可以和大家分享下它们的实现原理。

好了,接下来让我们看看如何通过CoordinateLayout实现上面的交互效果,相关示例代码大家可以下载查看note.youdao.com/notesha

1.初步实现

我们这里并没有使用经常看到的CoordinatorLayout和AppBarLayout、CollapsingToolbarLayout三连,一是因为官方的默认实现不能完全满足我们的交互需求,二是使用它们会使得布局层级较多。

除了这里根布局使用的是CoordinatorLayout,其他的布局和代码都和正常的没什么区别。那ViewPager是如何一直在header的下方的呢?而滚动ViewPager里面的RV又是如何和header联动滚动的呢?这一切的关键都在定义和使用的BelowHeaderBehavior和HeaderBehavior。同时我们这里将背景图从header中取出而放在FrameLayout中,并且也给它绑定了一个HeaderBackgroundBehavior,具体原因后面我们再说。

我们首先看BelowHeaderBehavior,在CoordinatorLayout中无法像其他布局一样定义View之间的布局关系,但我们可以通过Behavior定义子View和哪些子View有依赖关系以及具体的依赖关系。CoordinatorLayout在每个View的位置发生变化时都会遍历所有的子View,依次调用它绑定的behavior的layoutDependsOn方法判断是否存在依赖关系,如果存在依赖关系再通过onDependentViewChanged通知被依赖的View。

我们知道下方的ViewPager应该是依赖上方的header,并且始终在header的下方,所以我们如下处理:首先通过看dependency绑定的是否是HeaderBehavior判断dependency是否是ViewPager想依赖的header。虽然官方判断依赖关系很多都是直接通过类似dependency instanceof AppBarLayout判断,但个人感觉还是通过behavior判断更加解耦一点。 当header位置发生变化,我们同步的移动ViewPager的OffsetTop就可以实现ViewPager始终在header下方的效果。

// BelowHeaderBehavior

@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior();
return behavior != null && behavior instanceof HeaderBehavior;
}

@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
ViewCompat.offsetTopAndBottom(child, dependency.getBottom() - child.getTop());
return false;
}

类似的CoordinatorLayout在onMeasure和onLayout也会依次调用每个子View的绑定的behavior的onMeasureChild和layoutChild,我们可以在这里定义ViewPager的高度以及它的布局位置。这里可以直接使用官方写的HeaderScrollingViewBehavior。这就体现了behavior的优势,解耦和复用。

同时CoordinatorLayout也完美支持嵌套滚动机制,它自身实现了NestedScrollingParent2介面,并且在相关滚动回调介面中会遍历所有的子View,并调用它们的behavior相关嵌套滚动的方法,所以CoordinatorLayout让所有的子View有机会处理嵌套滚动,哪怕这个子View和发生滚动的View一点关系也没有,厉害吧。 在HeaderBehavior中我们就利用这个特性,方便的实现了当RV发生滚动时先让header处理和滚动效果。和AHLLayout处理下拉稍微不同,我们这里下拉时还是移动header的OffsetTop。

// HeaderBehavior

@Override
public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
if (dy != 0) {
if (dy > 0) { // 向上滚动
consumed[1] = scroll(coordinatorLayout, child, dy, getMaxDragOffset(child), getOverScrollOffset(child));
} else { // 向下滚动
boolean canScrollDown = target.canScrollVertically(-1);
if (!canScrollDown) {
consumed[1] = scroll(coordinatorLayout, child, dy, getMaxDragOffset(child), type == ViewCompat.TYPE_NON_TOUCH ? 0 : getOverScrollOffsetchild));
if (consumed[1] == 0 && type == ViewCompat.TYPE_NON_TOUCH) {
((NestedScrollingChild2) target).stopNestedScroll(type);
}
}
}
}
}

当发生下拉时,我们同样会在滚动停止和ACTION_UP时进行回弹动画,这里就要用到behavior拦截onInterceptTouchEvent和onTouchEvent的机制,CoordinatorLayout同样会在onInterceptTouchEvent和onInterceptTouchEvent遍历所有的子View,并调用它们的behavior相关方法。比如在HeaderBehavior中我们会拦截UP和Cancel事件,并且判断是否需要回弹动画。

// HeaderBehavior

@Override
public boolean onTouchEvent(CoordinatorLayout parent, View child, MotionEvent ev) {
...
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
isUpOrCancel = true;
mActivePointerId = INVALID_POINTER;
startRevertAnimator(child);
...
}

至此我们基本实现了详情页的交互效果,同时也见识了Behavior的能力,相信我使用Behavior这些能力,再炫酷的交互效果也能轻松实现。下图很好的概况总结了这些能力[6]。

2.分发头部滚动

头部滚动的效果我们在AHLLayout方案中是通过hook ViewGroup的dispatchTouchEvent实现的,那通过behavior可以实现吗?

其实官方已经写了一个HeaderBehavior,它会拦截header上的touch事件并让header跟随手势移动和fling,对touch事件的拦截和处理非常精彩。和我们的交互效果还是有点类似的,不过唯一的区别是它无法实现在fling时先让header收起再让下方的RV继续fling的滚动,如果不在意这个微小区别的话完全可以使用官方的HeaderBehavior来实现头部滚动。

但如果还是想实现这种交互效果话其实也很简单,思路和AHLLayout类似,不过这次换成header拦截滚动事件再分发给下方的ViewPager。我们知道下方ViewPager绑定的behavior是BelowHeaderBehavior,所以可以通过这个找到下方的ViewPager,需要注意的是第一次还需要手动下发一个Down事件才能让ViewPager接受和处理所有的touch事件。

// HeaderBehavior

if (mIsBeingDragged) {
for (int i = 0, c = parent.getChildCount(); i < c; i++) {
View sibling = parent.getChildAt(i);
final CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams) sibling.getLayoutParams()).getBehavior();
if (behavior != null && behavior instanceof BelowHeaderBehavior) {
final int offset = child.getHeight() - child.getBottom();
if (mNeedDispatchDown) {
mNeedDispatchDown = false;
mCurrentDownEvent.offsetLocation(0, offset);
sibling.dispatchTouchEvent(mCurrentDownEvent);
}
ev.offsetLocation(0, offset);
sibling.dispatchTouchEvent(ev);
}
}
}

3.图片下拉放大和时差效果

在AHLLayout中,为了实现图片放大效果,我们让AHLLayout和header的clipChildren都设成false,这样其实还是有一定风险和留坑的。

这次我们利用CoordinatorLayout的优势和behavior的便利,将背景图放在了独立的FrameLayout并绑定HeaderBackgroundBehavior。我们让HeaderBackgroundBehavior首先将背景图的FrameLayout的底部和header的底部始终对齐,再让FrameLayout的高度增加一个最大下拉放大的高度,最后让FrameLayout所有的View下移一个最大下拉放大的高度。这也是一个高性能动效很常用的方法,你可以在一开始时给控制项多预留点空间,避免后面反复调整控制项大小。

// HeaderBackgroundBehavior

@Override
public boolean onMeasureChild(CoordinatorLayout parent, View child,
int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec,
int heightUsed) {
final List<View> dependencies = parent.getDependencies(child);
final View header = findFirstDependency(dependencies);
if (header != null) {
HeaderBehavior headerBehavior = (HeaderBehavior) ((CoordinatorLayout.LayoutParams) header.getLayoutParams()).getBehavior();
final int overScrollOffset = headerBehavior.getOverScrollOffset(header);
child.getLayoutParams().height = header.getMeasuredHeight() + overScrollOffset;
if (child instanceof ViewGroup) {
if (mOriginTransY == -1) {
mOriginTransY = overScrollOffset;
for (int i = 0, c = ((ViewGroup) child).getChildCount(); i < c; i++) {
View view = ((ViewGroup) child).getChildAt(i);
ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
layoutParams.height = header.getMeasuredHeight();
layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT;
view.setTranslationY(overScrollOffset);
}
}
}
return false;
}
return false;
}

@Override
protected void layoutChild(final CoordinatorLayout parent, final View child, final int layoutDirection) {
final List<View> dependencies = parent.getDependencies(child);
final View header = findFirstDependency(dependencies);
final CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
mTempRect1.set(parent.getPaddingLeft() + lp.leftMargin,
header.getBottom()- lp.bottomMargin - child.getMeasuredHeight() + lp.topMargin,
parent.getWidth() - parent.getPaddingRight() - lp.rightMargin,
header.getBottom()- lp.bottomMargin);
child.layout(mTempRect1.left, mTempRect1.top, mTempRect1.right, mTempRect1.bottom);
}

当下拉时HeaderBackgroundBehavior让FrameLayout随著header移动,同时放大背景图片实现放大效果,同时再让背景图片上移实现视差效果。

@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior();
return behavior != null && behavior instanceof HeaderBehavior;
}

@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
ViewCompat.offsetTopAndBottom(child, dependency.getBottom() - child.getBottom());
for (int i = 0, c = ((ViewGroup) child).getChildCount(); i < c; i++) {
final View view = ((ViewGroup) child).getChildAt(i);
final int height = view.getMeasuredHeight();
final float scale = MathUtils.clamp((dependency.getTop() + height * 1f) / height, 1, Integer.MAX_VALUE);
view.setTranslationY(-dependency.getTop() / 2f + mOriginTransY);
view.setScaleX(scale);
view.setScaleY(scale);
}
return super.onDependentViewChanged(parent, child, dependency);
}

至此我们已经讲解了CoordinatorLayout方案实现原理。

四、数据和总结

为了测试效果我们特别选了一个比较卡的手机进行测试,打开开发者选项里的GPU呈现模式分析,分别对改造前改动后以及demo进行滑动测试,测试结果如下图所示:

我们还可以通过adb shell dumpsys gfxinfo "Package Name" framestats 计算对应的帧率进行定量比较。

可以发现优化的效果还是比较感人的,并且优化后整体帧率都在16ms附近。 除了流畅度的提升外,对代码结构的优化更加显著,对头部以及ViewPager里面的Fragment都没有代码侵入和修改。第一个AHLLayout方案实现简单轻量些,第二个CoordinatorLayout方案实现更解耦优雅些。 怎么样通过上面两个例子,你是不是觉得实现这样的动效也很简单了。

最后总结一下:

1. 遇到复杂的交互效果,优先考虑CoordinatorLayout和嵌套滚动机制,官方源码真是个大宝藏,很多的实现的机制和原理我们都可以学习借鉴过来优化我们的实现。

2. 布局移动变化时尽量保证布局的稳定,避免重新requestLayout,比如可以在一开始时给控制项多预留点空间,避免后面反复调整控制项大小。

3. 掌握各种动画方式的性能优先顺序,使用最高效的移动方式,比如setOffset系列,translation,scale变换等,这些应该是你设计方案时的首选。

五、参考文献

[1]:Experimenting with Nested Scrolling. androiddesignpatterns.com

[2]:玩转Android嵌套滚动. blog.cgsdream.org/2016/

[3]:嵌套滚动设计和源码分析. juejin.im/post/5ac35b82

[4]:Carry on Scrolling. chris.banes.me/2017/06/

[5]:Intercepting everything with CoordinatorLayout Behaviors. medium.com/androiddevel

[6]:CoordinatorLayout.Behavior Basic. medium.com/@zoha131/coo

推荐阅读:

相关文章