cover_3
博客原文:kyleduo.com
前言
这个系列源自前几天看到一篇使用CoordinatorLayout实现支付宝首页效果的文章,下载看了效果和源码,不敢苟同,所以打算自己动手。实现的过程有点曲折,但也发现了一些有意思的事情,用三篇文章来记录并分享给大家。
CoordinatorLayout和Behavior
自定义CoordinatorLayout.Behavior
支付宝首页效果实现
文中:CoL代表CoordinatorLayout,ABL表示AppBarLayout,CTL表示CollapsingToolbarLayout,SRL表示SwipeRefreshLayout,RV表示RecyclerView。
源码:Github
先看下最终效果:
效果分析
支付宝首页基本可以看成4个部分:
alipay_home_struct
折叠时QuickAction部分折叠,继续向上滑动,GridMenu移出屏幕。下拉时,刷新动画出现在GridMenu和MessageList之间。
结构设计
前一部分只是分析了一下结构,这里就要开始设计了。为了实现QuickAction折叠的效果,其实有好几种设计方法:
除SearchBar外,剩下的部分均使用RecyclerView实现。
SearchBar和QuickAction作为Header,其他部分使用RecyclerView实现。
SearchBar、QuickAction、GridMenu作为Header,MessageList使用RecyclerView实现。
……
为了方便摆放下拉刷新的位置,我选择了第三种结构,同时使用SwipeRefreshLayout实现下拉刷新,这种结构也是为了方便替换成其他下拉刷新控件。看下最终的实现效果:
视频
除了下拉刷新效果使用了SwipeRefreshLayout以及在GridMenu和QuickAction位置下拉不能触发下拉刷新外,其他的交互效果都和支付宝无异。
在开始动手之前,我还查看了支付宝的实现方法,很意外的是支付宝是使用ListView实现的这个页面,除了SearchBar,其他部分均为ListView。
alipay-home-uiviewer
实现
我们的效果实际上和AppBarLayout有很多相似之处,通过上篇文章,我们知道了AppBarLayout使用的两个Behavior使用了3个基类,如果能用就好了。不过这三个基类的访问权限是包可见,所以只好从Support中拷出来使用了。还有些步骤需要修改基类中的方法,以及增加方法可见性。
APHeaderView
APHeaderView包括除MessageList以外的其他部分,要实现的大致相当于AppBarLayout和CollapsingToolbarLayout结合的效果。
APHeaderView继承自ViewGroup。
首先在onFinishInflate()
方法中获取子View的引用,mBar是SearchBar部分,mSnapView是QuickAction部分,mScrollableViews是其余的View。之所以没有使用上面的命名方式,是因为我不想把这个效果限制的那么死,这些变量就以他们的功能命名了。
@Overrideprotected void onFinishInflate() { super.onFinishInflate(); final int childCount = getChildCount(); if (childCount < 2) { throw new IllegalStateException("Child count must >= 2"); } mBar = findViewById(R.id.alipay_bar); mSnapView = findViewById(R.id.alipay_snap); mScrollableViews = new ArrayList<>(); for (int i = 0; i < childCount; i++) { View v = getChildAt(i); if (v != mBar && v != mSnapView) { mScrollableViews.add(v); } } mBar.bringToFront(); }
最后一行语句将mBar移至顶部,这样可以一直显示。
布局部分没啥好说的,实现的是类似LinearLayout的布局,子View顺次排列。偏移量的处理并不在此处。
@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); if (heightSize == 0) { heightSize = Integer.MAX_VALUE; } int height = 0; final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { View c = getChildAt(i); measureChildWithMargins( c, MeasureSpec.makeMeasureSpec(widthSize - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY), 0, MeasureSpec.makeMeasureSpec(heightSize - getPaddingTop() - getPaddingBottom(), MeasureSpec.AT_MOST), height ); height += c.getMeasuredHeight(); } height += getPaddingTop() + getPaddingBottom(); setMeasuredDimension( widthSize, height ); }@Overrideprotected void onLayout(boolean changed, int left, int top, int right, int bottom) { int childTop = getPaddingTop(); int childLeft = getPaddingLeft(); mBar.layout(childLeft, childTop, childLeft + mBar.getMeasuredWidth(), childTop + mBar.getMeasuredHeight()); childTop += mBar.getMeasuredHeight(); mSnapView.layout(childLeft, childTop, childLeft + mSnapView.getMeasuredWidth(), childTop + mSnapView.getMeasuredHeight()); childTop += mSnapView.getMeasuredHeight(); for (View sv : mScrollableViews) { sv.layout(childLeft, childTop, childLeft + sv.getMeasuredWidth(), childTop + sv.getMeasuredHeight()); childTop += sv.getMeasuredHeight(); } }
滚动区域是控制滚动的重要部分,这里涉及到两个方法。getScrollRange
返回总的可滚动区域,getSnapRange
返回折叠效果的区域。
public int getScrollRange() { int range = mSnapView.getMeasuredHeight(); if (mScrollableViews != null) { for (View sv : mScrollableViews) { range += sv.getMeasuredHeight(); } } return range; }private int getSnapRange() { return mSnapView.getHeight(); }
APHeaderView.Behavior
继承自HeaderBehavior,和AppBarLayout一样,天生自带Offset处理和Touch事件处理,需要实现的,是NestedScrolling和snap效果。比AppBarLayout更多的,APHeaderView.Behavior实现了精确地Fling效果。也就是说Fling效果和RecyclerView也是联动的。这里主要说一下我是怎么处理Fling效果的。
Header -> ScrollingView
fling效果的实现,是通过Scroller不断修改偏移量最终呈现出连贯的动画。如果不处理fling效果,结果就是ScrollingView在fling到顶端时,出现overscroll效果,也就是剩余了部分偏移量没有消费。所以,要实现fling的联动,就是消费多余的偏移量。
fling可能在两个方法中触发,一个是onTouchEvent,还有就是onNestedPreFling。我们在onNestedPreFling方法中,判断如果是向上滑动,就手动调用fling
方法,和onTouchEvent一致。
@Overridepublic boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, APHeaderView child, View target, float velocityX, float velocityY) { if (velocityY > 0 && getTopAndBottomOffset() > -child.getScrollRange()) { fling(coordinatorLayout, child, -child.getScrollRange(), 0, -velocityY); mWasFlung = true; return true; } return false; }
HeaderBehavior类中的Scroller回调,最终调用setHeaderTopAndBottomOffset
方法设置偏移量:
@Overridepublic void run() { if (mLayout != null && mScroller != null) { if (mScroller.computeScrollOffset()) { setHeaderTopBottomOffset(mParent, mLayout, mScroller.getCurrY()); // Post ourselves so that we run on the next animation ViewCompat.postOnAnimation(mLayout, this); } else { onFlingFinished(mParent, mLayout); } } }
APHeaderView.Behavior的实现,就是覆写这个方法,将没有消费的偏移量分发出去。我们先看覆写的fling方法:如果判断向上滑动,除了设置标记为为true,同时会修改边界值:
@Overrideprotected boolean fling(CoordinatorLayout coordinatorLayout, APHeaderView layout, int minOffset, int maxOffset, float velocityY) { int min = minOffset; int max = maxOffset; if (velocityY < 0) { // 向上滚动 mShouldDispatchFling = true; mTempFlingDispatchConsumed = 0; mTempFlingMinOffset = minOffset; mTempFlingMaxOffset = maxOffset; min = Integer.MIN_VALUE; max = Integer.MAX_VALUE; } return super.fling(coordinatorLayout, layout, min, max, velocityY); }
修改边界值是因为我们希望即使达到边界,fling效果依然不能停止,因为我们要把多余的偏移量再次分发给ScrollingView。
@Overridepublic int setHeaderTopBottomOffset(CoordinatorLayout parent, APHeaderView header, int newOffset, int minOffset, int maxOffset) { final int curOffset = getTopAndBottomOffset(); final int min; final int max; if (mShouldDispatchFling) { min = Math.max(mTempFlingMinOffset, minOffset); max = Math.min(mTempFlingMaxOffset, maxOffset); } else { min = minOffset; max = maxOffset; } int consumed = super.setHeaderTopBottomOffset(parent, header, newOffset, min, max); // consumed 的符号和 dy 相反 header.dispatchOffsetChange(getTopAndBottomOffset()); int delta = 0; if (mShouldDispatchFling && header.mOnHeaderFlingUnConsumedListener != null) { int unconsumedY = newOffset - curOffset + consumed - mTempFlingDispatchConsumed; if (unconsumedY != 0) { delta = header.mOnHeaderFlingUnConsumedListener.onFlingUnConsumed(header, newOffset, unconsumedY); } mTempFlingDispatchConsumed += -delta; } return consumed + delta; }
首先修正边界值,然后调用父类的setHeaderTopBottomOffset
实现,这个方法返回父类消费的偏移量。然后计算剩余的偏移量:
int unconsumedY = newOffset - curOffset + consumed - mTempFlingDispatchConsumed;
注意这里的mTempFlingDispatchConsumed
变量,因为不能直接获取总的dy,在使用newOffset-curOffset获取dy时,当到达实际边界时,因为curOffset不会继续变小,所以获取到的dy实际上是累计的,所以使用mTempFlingDispatchConsumed
变量存储额外消费的掉的偏移量。
当unconsumedY
不为0时,说明有剩余未消费的偏移量,我们把它分发出去,同时记录Listener消费的值,把这个值加上header本身消费的值,作为总消费量返回。
mHeaderView.setOnHeaderFlingUnConsumedListener(new APHeaderView.OnHeaderFlingUnConsumedListener() { @Override public int onFlingUnConsumed(APHeaderView header, int targetOffset, int unconsumed) { APHeaderView.Behavior behavior = mHeaderView.getBehavior(); int dy = -unconsumed; if (behavior != null) { mRecyclerView.scrollBy(0, dy); } return dy; } });
在listener中,直接调用RecyclerView的scrollBy方法进行滑动(注意符号)。
这样就完成了Header向ScrollingView的fling分发。
ScrollingView -> Header
ScrollingView需要在向下触发fling效果时,将未消费的偏移量交给Header处理。RecyclerView依赖LayoutManager进行滚动。具体为scrollVerticallyBy
方法,我们需要覆写这个方法,分发未消费的偏移量,这里直接使用匿名内部类进行覆写。
final LinearLayoutManager lm = new LinearLayoutManager(mActivity, LinearLayoutManager.VERTICAL, false) { @Override public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { int scrolled = super.scrollVerticallyBy(dy, recycler, state); if (dy < 0 && scrolled != dy) { // 有剩余 APHeaderView.Behavior behavior = mHeaderView.getBehavior(); if (behavior != null) { int unconsumed = dy - scrolled; int consumed = behavior.scroll((CoordinatorLayout) mHeaderView.getParent(), mHeaderView, unconsumed, -mHeaderView.getScrollRange(), 0); scrolled += consumed; } } return scrolled; } };
和RecyclerView类似,调用HeaderBehavior.scroll方法进行滚动,注意边界的处理。
虽然scroll也会调用到
setHeaderTopBottomOffset
,但是因为此时mShouldDispatchFling
一定是为false的,所以不会造成循环调用。
这样就实现了fling事件的双向分发。
APScrollingBehavior
因为APScrollingBehavior和AppBarLayout.ScrollingBehavior并没有特别不同,这里就不赘述了。
总结
虽然实现这个效果没用多少时间,但是借此机会又完整的分析了AppBarLayout、CoordinatorLayout、Behavior等官方的实现,让这个效果变得有意义了一些。
如果单独评价这个交互的话,我倒觉得下拉刷新应该出现在GridMenu上面,这样页面看起来重心就比较稳了,不至于头重脚轻。
作者:kyleduo
链接:https://www.jianshu.com/p/e75e3e445d25