一 前言
該文詳細的介紹了RecyclerView.ItemDecoration實現分組粘性頭部的功能,讓我們自己生產程式碼,告別程式碼搬運工的時代.另外文末附有完整Demo的連線.看下效果:
二 知識準備
RecyclerView.ItemDecoration對於我們最熟悉的功能就是給RecyclerView實現各種各樣自定義的分割線了,實現分割線的功能其實和實現粘性頭部的功能大同小異,那我們就來看看這神奇的RecyclerView.ItemDecoration.
該類是RecyclerView的內部靜態抽象類:
public abstract static class ItemDecoration {
/**
* 繪製*除Item內容*以外的佈局,這個方法是再****Item的內容繪製之前****執行的,
* 所以呢如果兩個繪製區域重疊的話,Item的繪製區域會覆蓋掉該方法繪製的區域.
* 一般配合getItemOffsets來繪製分割線等.
*
* @param c Canvas 畫布
* @param parent RecyclerView
* @param state RecyclerView的狀態
*/
public void onDraw(Canvas c, RecyclerView parent, State state) {
onDraw(c, parent);
}
@Deprecated
public void onDraw(Canvas c, RecyclerView parent) {
}
/**
* 繪製*除Item內容*以外的東西,這個方法是在****Item的內容繪製之後****才執行的,
* 所以該方法繪製的東西會將Item的內容覆蓋住,既顯示在Item之上.
* 一般配合getItemOffsets來繪製分組的頭部等.
*
* @param c Canvas 畫布
* @param parent RecyclerView
* @param state RecyclerView的狀態
*/
public void onDrawOver(Canvas c, RecyclerView parent, State state) {
onDrawOver(c, parent);
}
/**
* @deprecated
* Override {@link #onDrawOver(Canvas, RecyclerView, RecyclerView.State)}
*/
@Deprecated
public void onDrawOver(Canvas c, RecyclerView parent) {
}
/**
* @deprecated
* Use {@link #getItemOffsets(Rect, View, RecyclerView, State)}
*/
@Deprecated
public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
outRect.set(0, 0, 0, 0);
}
/**
* 設定Item的佈局四周的間隔.
*
* @param outRect 確定間隔 Left Top Right Bottom 數值的矩形.
* @param view RecyclerView的ChildView也就是每個Item的的佈局.
* @param parent RecyclerView本身.
* @param state RecyclerView的各種狀態.
*/
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
parent);
}
}
複製程式碼
這裡面呢有個問題一定要明白幾個問題:
-
getItemOffsets這個方法設定的Item間隔到底是那個間隔?
我們來看一張圖.
我們知道getItemOffsets()第一個引數是一個矩形的物件,這個物件的left、 top、right、bottpm四個屬性值分別表示圖中的outRect.left、outRect.top、outRect.right、outRect.bottom四個線段所表示的空間.也就是說當RecyclerView的Item再確定自己的大小的時候會將getItemOffsets()裡面的Rect物件的Left、Top、Right、Bottom屬性取出來,看看需要再Item佈局的四周留出多大的空間.我們來看下原始碼:
Rect getItemDecorInsetsForChild(View child) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (!lp.mInsetsDirty) {
return lp.mDecorInsets;
}
if (mState.isPreLayout() && (lp.isItemChanged() || lp.isViewInvalid())) {
// changed/invalid items should not be updated until they are rebound.
return lp.mDecorInsets;
}
final Rect insets = lp.mDecorInsets;
insets.set(0, 0, 0, 0);
final int decorCount = mItemDecorations.size();
for (int i = 0; i < decorCount; i++) {
mTempRect.set(0, 0, 0, 0);
//這裡呢mTempRect就是我們再getItemOffsets()裡面的第一個Rect的物件,我們再實現類的方法裡面給mTempRect賦值.
mItemDecorations.get(i).getItemOffsets(mTempRect, child, this, mState);
insets.left += mTempRect.left;
insets.top += mTempRect.top;
insets.right += mTempRect.right;
insets.bottom += mTempRect.bottom;
}
lp.mInsetsDirty = false;
return insets;
}
這裡呢就是RecyclerView再測量每個Child的大小的時候都把insets這個矩形的l t r b 數值都加上了.insets就是方法getItemDecorInsetsForChild()返回的矩形物件.
/**
* Measure a child view using standard measurement policy, taking the padding
* of the parent RecyclerView and any added item decorations into account.
*
* <p>If the RecyclerView can be scrolled in either dimension the caller may
* pass 0 as the widthUsed or heightUsed parameters as they will be irrelevant.</p>
*
* @param child Child view to measure
* @param widthUsed Width in pixels currently consumed by other views, if relevant
* @param heightUsed Height in pixels currently consumed by other views, if relevant
*/
public void measureChild(View child, int widthUsed, int heightUsed) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
widthUsed += insets.left + insets.right;
heightUsed += insets.top + insets.bottom;
final int widthSpec = getChildMeasureSpec(getWidth(), getWidthMode(),
getPaddingLeft() + getPaddingRight() + widthUsed, lp.width,
canScrollHorizontally());
final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
getPaddingTop() + getPaddingBottom() + heightUsed, lp.height,
canScrollVertically());
if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
child.measure(widthSpec, heightSpec);
}
}
複製程式碼
原始碼的講解過於粗糙,希望大家見諒,目的就是為了讓大家知道這個getItemOffsets()方法是怎麼讓RecyclerView再Item之外留出空間的.
-
onDraw()和onDrawOver()方法應該用哪一個?
首先我們看過上面的程式碼之後知道,onDraw執行再Item的繪製之前,也就是ItemDecoration的onDraw方法先執行,再執行Item的onDraw方法,這樣Item的內容就會覆蓋在ItemDecoration的onDraw上面.ItemDecoration的onDrawOver()方法執行在Item的繪製之後,那就是onDrawOver()繪製的內容會覆蓋再Item內容之上.這樣就形成了層層遮蓋的問題,那麼我們平常的分割線通常繪製在ItemDecoration的onDraw()方法裡面,為了避免Item的內容覆蓋掉,我們就要getItemOffsets()為我們留出繪製的空間了.這樣我們的思路不是不有了呢.
我們可以用onDrawOver()和getItemOffsets()方法一起使用來實現Item的粘性頭部和頂部懸浮的效果.
三 程式碼部分
需求分析:這部分其實是寫程式碼前尤為重要的一部分,再分析的過程中你可以知道我們要完成的是哪些功能,用什麼東西去完成,怎麼才能更好的去完成.最後自己能確定出一套完美實現需求的方案.
我們要做的是區域分組顯示,每個分組的開始要有一個粘性頭部.如圖所示:
- 資料準備
首先後臺返回的資料一定要有組類區分,每個分組的標記不能一樣,最好是我們方便處理的.該Demo採用的標記位是int型別的標記tag,每組的標記以此+1,每五個城市分為一組,每組的第一個城市當做頭部局顯示的內容.我們的分組頭部的高度為40dp.
- getItemOffsets()
該方法再recyclerView的每個Item測量大小的時候都會被呼叫到, 我們要在該方法裡面判斷出那個HeadItem並且給HeadItem留出繪製的空間,這裡有兩種方式.
第一種方式:
給Item 的Top留出空間,也就是outRect.top屬性賦值.
第二種方式:
給Item 的Bottom留出空間也就是outRect.bottpm屬性賦值.
因為我們在列表一開始的時候就要繪製一次Head,也就是說我們要留出Head的空間,那麼我們只能選擇第一種方法去預留空間了. 當你選擇方式1的時候,給outRect.top賦值,這樣的話我們判斷是否是HeadItem的話就要拿當前Item的標記跟前一個Item的標記判斷了.如果用第二種的話就要用當前的標記跟下一個Item的標記判斷了.
下面我來解釋下第一種方式,第二種方式雷同:
a b c d e f g h i
分組1 abc
分組2 def
分組3 ghi
如果 a d g 是HeadItem . a的tag = 1 , b的tag = 1, c 的tag = 1....d的tag = 2,e的tag = 2 ,f的tag = 2,g的tag = 3...等等 .
前一個Item的tag用 preTag 來表示 ,初始值為 -1.
假如當前的Item為a,當前tag = 1,那麼它的前一個Item為空,也就是發現preTag和a的tag不一樣,那麼a就是分組的頭部.
假如當前的Item為b,當前tag = 1,那麼它前一個preTag 也就是a的tag = 1,發現一樣那就是是同一組的.
假如當前的Item為d,當前tag = 2,那麼它前一個preTag 也就是c的tag = 1,發現前一個的tag跟當前的不一樣,那麼當前的就是新分組的第一個頭部Item.程式碼是最有說服力的,下面來看程式碼:
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
if (citiList == null || citiList.size() == 0) {
return;
}
int adapterPosition = parent.getChildAdapterPosition(view);
RecBean.CitiListBean beanByPosition = getBeanByPosition(adapterPosition);
if(beanByPosition == null){
return;
}
int preTage = -1;
int tage = beanByPosition.getTage();
//一定要記住這個 >= 0
if(adapterPosition - 1 >= 0) {
RecBean.CitiListBean nextBean = getBeanByPosition(adapterPosition - 1);
if (nextBean == null) {
return;
}
preTage = nextBean.getTage();
}
if(preTage != tage){
outRect.top = headHeight;
}else {
//這個目的是留出分割線
outRect.top = lineHeight;
}
}
複製程式碼
這樣下來我們給分組頭部的空間就預留出來了.接下來繪製分組頭部,因為分割線我直接顯示的背景色所以就不用去繪製分割線了.
- onDrawOver()
這個方法裡面我們要做的不只是繪製Head,當列表滑動的時候RecyclerView會不斷的載入之後的Item,佈局發生複用,我們要在不斷的變化中去重新繪製我們的HeadItem的佈局.這個方法當每個Item消失或者出現的時候都會被呼叫,我們在這裡去繪製HeadItem的區域.所以在該方法裡面我們會遍歷所有可見的Item去重新判斷那個Item有Head,然後去繪製.
1.判斷頭佈局繪製頭佈局 ?
那麼我們在這裡呢還是需要判重新去判斷哪個Item是有Head.按照getItemOffsets裡面的我們需要跟之前的Item的tag做比較.但是有個問題就是我們再這裡並不能拿到Item的佈局或者別的東西,只能遍歷所有已經顯示的Item,也就是隻能一個個的將RecyclerView的ChildView拿出來.這樣的話我們的前一個preTag就需要我們自己去定義,然後用preTag來記錄我們遍歷過的ChildView的Tag,當遍歷到下個Item的Tag跟之前的preTag一樣的話,那就繼續遍歷不去繪製頭佈局,當遍歷到Item的tag跟preTag不一樣的時候就去繪製有佈局.因為滑動的Item都會作為RecyclerView的第0個ChildView出現,我們拿不到它之前的Item的tag.
2.怎麼讓頭佈局懸停在頂部 ?
這個問題其實拿一個場景去說明是最好的了.當我們HeadItem正好出現在螢幕的頂部的時候,我們繼續滑動列表HeadItem就會漸漸的消失,也就是Item的getTop距離會小於我們HeadItem的Head的高度,當出現這種情況的時候, 我們就讓Item的getTop和Head的高度中去選擇一個最大值.這樣就好保證當HeadItem畫出螢幕的時候Head佈局一直留在頂部.
3.下個頭部來的時候怎麼替換呢 ?
當頂部有一個頭部局在懸停的時候,我們滑動列表時下個頭部肯定會和當前懸停的頭部相遇.我們再這裡做的是當前懸浮的頭佈局跟下個頭佈局相遇發生交替的時候有個漸變的效果.因為該方法在每一個Item出現或者消失的時候都會執行,每當執行的時候都要遍歷一遍當前已經顯示的Item佈局,那麼一定會出現,當前遍歷的第0個Item正好是螢幕中的第一個Item,它的下一個Item正好是分組的頭布.這樣的話再往前滑的時候就會出現頭部交替的情況.我們這裡就需要判斷下一個Item是不是有頭佈局的Item,比較的方法就是用當前Item資料的tag跟下一個Item資料的nextTag比較,如果不同的話那下個Item就是有頭佈局的.如果一樣的話就continue繼續遍歷.再列表滑動的時候回一直繪製所有可見Item的Head佈局.
4.漸變效果呢?
上面我們知道了當下個HeadItem跟螢幕頂部的Head相遇的時候就要發生交替.交替的時候有個漸變效果,也就是之前再螢幕頂部懸停的Head要隨著一個Item的消失而消失.下個Head要滑動到螢幕之後停在那裡.那就好辦了當onDrawOver()方法執行的時候,RecyclerView的第0個ChildView正好是螢幕頂部的Item,當它的下一個Item有個Head的時候,我們只需要將當前Item的getTop數值賦值給繪製Head的矩形的bottpm屬性就可以了.我們一定要明白當出現Item出現消失的時候Head是再不斷的繪製的.
上程式碼:
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
if(citiList == null || citiList.size() == 0){
return;
}
int parentLeft = parent.getPaddingLeft();
int parentRight = parent.getWidth() - parent.getPaddingRight();
int childCount = parent.getChildCount();
int tag = -1;
int preTag;
for (int i = 0; i <childCount; i++) {
View childView = parent.getChildAt(i);
if(childView == null){
continue;
}
int adapterPosition = parent.getChildAdapterPosition(childView);
當前Item的Top
int top = childView.getTop();
int bottom = childView.getBottom();
preTag = tag;
tag = citiList.get(adapterPosition).getTage();
//判斷下一個是不是分組的頭部
if(preTag == tag){
continue;
}
//這裡面我把每個分組的頭部顯示的文字列表單獨提出來了,為了測試方便用,
String name = index.get((tag - 1 ) < 0 ? 0 : (tag -1));
int height = Math.max(top,headHeight);
//判斷下一個Item是否是分組的頭部
if(adapterPosition + 1 < citiList.size()){
int nextTag = citiList.get(adapterPosition + 1).getTage();
if(tag != nextTag){
//這裡就是實現漸變效果的地方
//因為如果遍歷到
height = bottom;
}
}
paint.setColor(Color.parseColor("#ffffff"));
c.drawRect(parentLeft,height - headHeight,parentRight,height,paint);
paint.setColor(Color.BLACK);
paint.getTextBounds(name, 0, name.length(), rectOver);
c.drawText(name, dip2px(10), height - (headHeight - rectOver.height()) / 2, paint);
}
}
複製程式碼
到這裡我們的功能已經結束了,我們要知道getItemOffsets()會提前執行,每個Item的回收和出現都會執行一次.onDraw或者onDrawOver再螢幕中的Item發生變化的時候都會執行,只要發生變化.我們的Head會不停的繪製.
結束
這是2018年的第一篇文章,之前太忙了也沒好好的總結知識點.寫的倉促希望大家多多指導文章出現的問題,謝謝大家的反饋,歡迎評論吐槽哦~