
博客原文:kyleduo.com
前言
這個系列源自前幾天看到一篇使用CoordinatorLayout實現(xiàn)支付寶首頁效果的文章,下載看了效果和源碼,不敢茍同,所以打算自己動手。實現(xiàn)的過程有點曲折,但也發(fā)現(xiàn)了一些有意思的事情,用三篇文章來記錄并分享給大家。
- CoordinatorLayout和Behavior
- 自定義CoordinatorLayout.Behavior
- 支付寶首頁效果實現(xiàn)
文中:CoL代表CoordinatorLayout,ABL表示AppBarLayout,CTL表示CollapsingToolbarLayout,SRL表示SwipeRefreshLayout,RV表示RecyclerView。
源碼:Github
先看下最終效果:
效果分析
支付寶首頁基本可以看成4個部分:

折疊時QuickAction部分折疊,繼續(xù)向上滑動,GridMenu移出屏幕。下拉時,刷新動畫出現(xiàn)在GridMenu和MessageList之間。
結構設計
前一部分只是分析了一下結構,這里就要開始設計了。為了實現(xiàn)QuickAction折疊的效果,其實有好幾種設計方法:
- 除SearchBar外,剩下的部分均使用RecyclerView實現(xiàn)。
- SearchBar和QuickAction作為Header,其他部分使用RecyclerView實現(xiàn)。
- SearchBar、QuickAction、GridMenu作為Header,MessageList使用RecyclerView實現(xiàn)。
- ……
為了方便擺放下拉刷新的位置,我選擇了第三種結構,同時使用SwipeRefreshLayout實現(xiàn)下拉刷新,這種結構也是為了方便替換成其他下拉刷新控件??聪伦罱K的實現(xiàn)效果:
視頻
除了下拉刷新效果使用了SwipeRefreshLayout以及在GridMenu和QuickAction位置下拉不能觸發(fā)下拉刷新外,其他的交互效果都和支付寶無異。
在開始動手之前,我還查看了支付寶的實現(xiàn)方法,很意外的是支付寶是使用ListView實現(xiàn)的這個頁面,除了SearchBar,其他部分均為ListView。

實現(xiàn)
我們的效果實際上和AppBarLayout有很多相似之處,通過上篇文章,我們知道了AppBarLayout使用的兩個Behavior使用了3個基類,如果能用就好了。不過這三個基類的訪問權限是包可見,所以只好從Support中拷出來使用了。還有些步驟需要修改基類中的方法,以及增加方法可見性。
APHeaderView
APHeaderView包括除MessageList以外的其他部分,要實現(xiàn)的大致相當于AppBarLayout和CollapsingToolbarLayout結合的效果。
APHeaderView繼承自ViewGroup。
首先在onFinishInflate()方法中獲取子View的引用,mBar是SearchBar部分,mSnapView是QuickAction部分,mScrollableViews是其余的View。之所以沒有使用上面的命名方式,是因為我不想把這個效果限制的那么死,這些變量就以他們的功能命名了。
@Override
protected 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移至頂部,這樣可以一直顯示。
布局部分沒啥好說的,實現(xiàn)的是類似LinearLayout的布局,子View順次排列。偏移量的處理并不在此處。
@Override
protected 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
);
}
@Override
protected 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();
}
}
滾動區(qū)域是控制滾動的重要部分,這里涉及到兩個方法。getScrollRange返回總的可滾動區(qū)域,getSnapRange返回折疊效果的區(qū)域。
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事件處理,需要實現(xiàn)的,是NestedScrolling和snap效果。比AppBarLayout更多的,APHeaderView.Behavior實現(xiàn)了精確地Fling效果。也就是說Fling效果和RecyclerView也是聯(lián)動的。這里主要說一下我是怎么處理Fling效果的。
Header -> ScrollingView
fling效果的實現(xiàn),是通過Scroller不斷修改偏移量最終呈現(xiàn)出連貫的動畫。如果不處理fling效果,結果就是ScrollingView在fling到頂端時,出現(xiàn)overscroll效果,也就是剩余了部分偏移量沒有消費。所以,要實現(xiàn)fling的聯(lián)動,就是消費多余的偏移量。
fling可能在兩個方法中觸發(fā),一個是onTouchEvent,還有就是onNestedPreFling。我們在onNestedPreFling方法中,判斷如果是向上滑動,就手動調用fling方法,和onTouchEvent一致。
@Override
public 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方法設置偏移量:
@Override
public 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的實現(xiàn),就是覆寫這個方法,將沒有消費的偏移量分發(fā)出去。我們先看覆寫的fling方法:如果判斷向上滑動,除了設置標記為為true,同時會修改邊界值:
@Override
protected 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效果依然不能停止,因為我們要把多余的偏移量再次分發(fā)給ScrollingView。
@Override
public 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實現(xiàn),這個方法返回父類消費的偏移量。然后計算剩余的偏移量:
int unconsumedY = newOffset - curOffset + consumed - mTempFlingDispatchConsumed;
注意這里的mTempFlingDispatchConsumed變量,因為不能直接獲取總的dy,在使用newOffset-curOffset獲取dy時,當?shù)竭_實際邊界時,因為curOffset不會繼續(xù)變小,所以獲取到的dy實際上是累計的,所以使用mTempFlingDispatchConsumed變量存儲額外消費的掉的偏移量。
當unconsumedY不為0時,說明有剩余未消費的偏移量,我們把它分發(fā)出去,同時記錄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分發(fā)。
ScrollingView -> Header
ScrollingView需要在向下觸發(fā)fling效果時,將未消費的偏移量交給Header處理。RecyclerView依賴LayoutManager進行滾動。具體為scrollVerticallyBy方法,我們需要覆寫這個方法,分發(fā)未消費的偏移量,這里直接使用匿名內部類進行覆寫。
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的,所以不會造成循環(huán)調用。
這樣就實現(xiàn)了fling事件的雙向分發(fā)。
APScrollingBehavior
因為APScrollingBehavior和AppBarLayout.ScrollingBehavior并沒有特別不同,這里就不贅述了。
總結
雖然實現(xiàn)這個效果沒用多少時間,但是借此機會又完整的分析了AppBarLayout、CoordinatorLayout、Behavior等官方的實現(xiàn),讓這個效果變得有意義了一些。
如果單獨評價這個交互的話,我倒覺得下拉刷新應該出現(xiàn)在GridMenu上面,這樣頁面看起來重心就比較穩(wěn)了,不至于頭重腳輕。