博客原文:kyleduo.com
前言
這個系列源自前幾天看到一篇使用CoordinatorLayout實(shí)現(xiàn)支付寶首頁效果的文章销部,下載看了效果和源碼,不敢茍同亚隙,所以打算自己動手宾抓。實(shí)現(xiàn)的過程有點(diǎn)曲折抑月,但也發(fā)現(xiàn)了一些有意思的事情树叽,用三篇文章來記錄并分享給大家。
- CoordinatorLayout和Behavior
- 自定義CoordinatorLayout.Behavior
- 支付寶首頁效果實(shí)現(xiàn)
文中:CoL代表CoordinatorLayout谦絮,ABL表示AppBarLayout题诵,CTL表示CollapsingToolbarLayout,SRL表示SwipeRefreshLayout层皱,RV表示RecyclerView性锭。
源碼:Github
先看下最終效果:
效果分析
支付寶首頁基本可以看成4個部分:
折疊時QuickAction部分折疊,繼續(xù)向上滑動叫胖,GridMenu移出屏幕草冈。下拉時,刷新動畫出現(xiàn)在GridMenu和MessageList之間瓮增。
結(jié)構(gòu)設(shè)計
前一部分只是分析了一下結(jié)構(gòu)怎棱,這里就要開始設(shè)計了。為了實(shí)現(xiàn)QuickAction折疊的效果钉赁,其實(shí)有好幾種設(shè)計方法:
- 除SearchBar外蹄殃,剩下的部分均使用RecyclerView實(shí)現(xiàn)携茂。
- SearchBar和QuickAction作為Header你踩,其他部分使用RecyclerView實(shí)現(xiàn)。
- SearchBar讳苦、QuickAction带膜、GridMenu作為Header,MessageList使用RecyclerView實(shí)現(xiàn)鸳谜。
- ……
為了方便擺放下拉刷新的位置膝藕,我選擇了第三種結(jié)構(gòu),同時使用SwipeRefreshLayout實(shí)現(xiàn)下拉刷新咐扭,這種結(jié)構(gòu)也是為了方便替換成其他下拉刷新控件芭挽。看下最終的實(shí)現(xiàn)效果:
視頻
除了下拉刷新效果使用了SwipeRefreshLayout以及在GridMenu和QuickAction位置下拉不能觸發(fā)下拉刷新外蝗肪,其他的交互效果都和支付寶無異袜爪。
在開始動手之前,我還查看了支付寶的實(shí)現(xiàn)方法薛闪,很意外的是支付寶是使用ListView實(shí)現(xiàn)的這個頁面辛馆,除了SearchBar,其他部分均為ListView豁延。
實(shí)現(xiàn)
我們的效果實(shí)際上和AppBarLayout有很多相似之處昙篙,通過上篇文章腊状,我們知道了AppBarLayout使用的兩個Behavior使用了3個基類,如果能用就好了苔可。不過這三個基類的訪問權(quán)限是包可見缴挖,所以只好從Support中拷出來使用了。還有些步驟需要修改基類中的方法焚辅,以及增加方法可見性醇疼。
APHeaderView
APHeaderView包括除MessageList以外的其他部分,要實(shí)現(xiàn)的大致相當(dāng)于AppBarLayout和CollapsingToolbarLayout結(jié)合的效果法焰。
APHeaderView繼承自ViewGroup秧荆。
首先在onFinishInflate()
方法中獲取子View的引用,mBar是SearchBar部分埃仪,mSnapView是QuickAction部分乙濒,mScrollableViews是其余的View。之所以沒有使用上面的命名方式卵蛉,是因?yàn)槲也幌氚堰@個效果限制的那么死颁股,這些變量就以他們的功能命名了。
@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移至頂部傻丝,這樣可以一直顯示甘有。
布局部分沒啥好說的,實(shí)現(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事件處理茄茁,需要實(shí)現(xiàn)的魂贬,是NestedScrolling和snap效果。比AppBarLayout更多的裙顽,APHeaderView.Behavior實(shí)現(xiàn)了精確地Fling效果付燥。也就是說Fling效果和RecyclerView也是聯(lián)動的。這里主要說一下我是怎么處理Fling效果的锦庸。
Header -> ScrollingView
fling效果的實(shí)現(xiàn)机蔗,是通過Scroller不斷修改偏移量最終呈現(xiàn)出連貫的動畫。如果不處理fling效果,結(jié)果就是ScrollingView在fling到頂端時萝嘁,出現(xiàn)overscroll效果梆掸,也就是剩余了部分偏移量沒有消費(fèi)。所以牙言,要實(shí)現(xiàn)fling的聯(lián)動酸钦,就是消費(fèi)多余的偏移量。
fling可能在兩個方法中觸發(fā)咱枉,一個是onTouchEvent卑硫,還有就是onNestedPreFling。我們在onNestedPreFling方法中蚕断,判斷如果是向上滑動欢伏,就手動調(diào)用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回調(diào)硝拧,最終調(diào)用setHeaderTopAndBottomOffset
方法設(shè)置偏移量:
@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的實(shí)現(xiàn),就是覆寫這個方法葛假,將沒有消費(fèi)的偏移量分發(fā)出去障陶。我們先看覆寫的fling方法:如果判斷向上滑動,除了設(shè)置標(biāo)記為為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);
}
修改邊界值是因?yàn)槲覀兿M词惯_(dá)到邊界抱究,fling效果依然不能停止,因?yàn)槲覀円讯嘤嗟钠屏吭俅畏职l(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;
}
首先修正邊界值鼓寺,然后調(diào)用父類的setHeaderTopBottomOffset
實(shí)現(xiàn),這個方法返回父類消費(fèi)的偏移量遏暴。然后計算剩余的偏移量:
int unconsumedY = newOffset - curOffset + consumed - mTempFlingDispatchConsumed;
注意這里的mTempFlingDispatchConsumed
變量侄刽,因?yàn)椴荒苤苯荧@取總的dy,在使用newOffset-curOffset獲取dy時朋凉,當(dāng)?shù)竭_(dá)實(shí)際邊界時,因?yàn)閏urOffset不會繼續(xù)變小醋安,所以獲取到的dy實(shí)際上是累計的杂彭,所以使用mTempFlingDispatchConsumed
變量存儲額外消費(fèi)的掉的偏移量。
當(dāng)unconsumedY
不為0時吓揪,說明有剩余未消費(fèi)的偏移量亲怠,我們把它分發(fā)出去,同時記錄Listener消費(fèi)的值柠辞,把這個值加上header本身消費(fèi)的值团秽,作為總消費(fèi)量返回。
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中,直接調(diào)用RecyclerView的scrollBy方法進(jìn)行滑動(注意符號)习勤。
這樣就完成了Header向ScrollingView的fling分發(fā)踪栋。
ScrollingView -> Header
ScrollingView需要在向下觸發(fā)fling效果時,將未消費(fèi)的偏移量交給Header處理图毕。RecyclerView依賴LayoutManager進(jìn)行滾動夷都。具體為scrollVerticallyBy
方法,我們需要覆寫這個方法予颤,分發(fā)未消費(fèi)的偏移量囤官,這里直接使用匿名內(nèi)部類進(jìn)行覆寫。
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類似蛤虐,調(diào)用HeaderBehavior.scroll方法進(jìn)行滾動党饮,注意邊界的處理。
雖然scroll也會調(diào)用到
setHeaderTopBottomOffset
驳庭,但是因?yàn)榇藭rmShouldDispatchFling
一定是為false的劫谅,所以不會造成循環(huán)調(diào)用。
這樣就實(shí)現(xiàn)了fling事件的雙向分發(fā)嚷掠。
APScrollingBehavior
因?yàn)锳PScrollingBehavior和AppBarLayout.ScrollingBehavior并沒有特別不同捏检,這里就不贅述了。
總結(jié)
雖然實(shí)現(xiàn)這個效果沒用多少時間不皆,但是借此機(jī)會又完整的分析了AppBarLayout贯城、CoordinatorLayout、Behavior等官方的實(shí)現(xiàn)霹娄,讓這個效果變得有意義了一些能犯。
如果單獨(dú)評價這個交互的話,我倒覺得下拉刷新應(yīng)該出現(xiàn)在GridMenu上面犬耻,這樣頁面看起來重心就比較穩(wěn)了踩晶,不至于頭重腳輕。