之前寫過一篇文章《Android開發(fā)之仿微博詳情頁(滑動固定頂部欄效果)》,當時采用的解決方案是用一個ScrollView去包裹內容布局,通過監(jiān)聽滑動狀態(tài)惜论,在適當?shù)臅r候,移入/移出所要固定的布局止喷,這樣雖然可以達到想要的視覺效果馆类,但這種實現(xiàn)方式并不優(yōu)雅,比如被包裹內容布局中帶有滑動特性的View(ListView弹谁,RecyclerView等)乾巧,這樣做就需要我們額外的去處理滑動沖突,而且這種方式的包裹會使得它們的緩存機制失效预愤,為了一個視覺效果去犧牲它們最具靈動的特性的一面卧抗,我不提倡這種做法。
這里介紹另外一種解決方案鳖粟,可以更加優(yōu)雅的實現(xiàn)這種視覺效果社裆,而且不會有滑動沖突,也不需要犧牲緩存機制向图,在文章末尾會給出思路泳秀,需要你先看完文章哈~
先來看下今天要實現(xiàn)的效果圖:
要實現(xiàn)這個效果很簡單,只需要一個RecyclerView就可以實現(xiàn)了榄攀,不需要多余的布局控件嗜傅,當然網(wǎng)上也有另外的一些實現(xiàn)方式,比如利用幀布局或者相對布局在RecyclerView上面再蓋上要固定的ViewGroup檩赢,通過滑動去判斷是否需要動態(tài)的將固定布局移入/移出吕嘀,其實和上面提到的文章實現(xiàn)思路一樣,這樣做,很明顯的會有幾個缺點偶房,比如增加了布局的深度或者在業(yè)務發(fā)生變化的時候需要同時去改動至少兩處代碼等趁曼,如果中間還耦合著一些業(yè)務操作,出錯幾率也會對應的增加棕洋。
列表的組成
這是一個帶有分組的列表挡闰,我們可以把它拆分成3部分,頭部數(shù)據(jù)+列表數(shù)據(jù)+分割線
列表數(shù)據(jù):
RecyclerView的基本使用掰盘,這里我就不再重復闡述了摄悯。
分割線:
要實現(xiàn)分割線,如果是在以前的ListView愧捕,我們通過設置divider奢驯,dividerHeigh等屬性就可以很輕松的達到目的,或者直接在布局文件中畫上一個帶有高度和背景色的View來實現(xiàn)次绘。到了RecyclerView這里瘪阁,我們可不再需要這樣做了,官方給我們提供了一個強大的裝飾器ItemDecoration断盛,它可以幫助我們實現(xiàn)分割線的功能,但它可不僅僅只能實現(xiàn)分割線愉舔,一會下文會介紹钢猛。
頭部數(shù)據(jù):
要繪制這個頭部,以前我們在ListView里轩缤,可能有些人會這樣做命迈,讓每個Item布局都帶上這個頭部布局,然后根據(jù)是否是每組數(shù)據(jù)的第一個來動態(tài)控制頭部布局是顯示還是隱藏火的,當然這樣做也可以實現(xiàn)我們想要的效果壶愤,但卻會多余的去耗費一定的性能,因為明明每組數(shù)據(jù)只需要繪制一個頭部馏鹤,而你卻每個Item都去繪制征椒,最終每組卻又只需要一個,所以這里我們依然可以采用官方提供的ItemDecoration來解決這個問題湃累。
什么是ItemDecoration勃救?
說了這么多ItemDecoration,我們來看下官方給出的介紹吧:
An ItemDecoration allows the application to add a special drawing and layout offset to specific item views from the adapter's data set. This can be useful for drawing dividers between items, highlights, visual grouping boundaries and more.
大概意思是ItemDecoration允許給特定的item視圖添加特性的繪制以及布局間隔治力。它可以用來實現(xiàn)item之間的分割線蒙秒,高亮,分組邊界等宵统。
public class ItemDecoration extends RecyclerView.ItemDecoration {
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDraw(c, parent, state);
}
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
}
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
}
}
ItemDecoration是RecyclerView下的抽象方法晕讲,我們要使用它只需要繼承它,并實現(xiàn)對應的方法即可,然后讓RecyclerView去調用它
mRecyclerView.addItemDecoration(new ItemDecoration());
具體來看下這3個方法瓢省,順便來一張圖幫助理解:
getItemOffsets:它是用來給Item設置間距的弄息,可以這樣理解在Item外還有一層布局,而這個方法是用來設置布局的Padding净捅。
onDraw:它的繪制和Item之下疑枯,它繪制的東西會在Item的下面。
onDrawOver:它的繪制在Item之上蛔六,它繪制的東西會覆蓋在Item的上面荆永。
事實上并不是真的有層次之分,這里只是為了方便理解国章,最根本的原因是因為它們方法的調用方法的順序具钥,又因為都作用于同一個Canvas上,才出現(xiàn)這種覆蓋的層次的效果液兽。
知道了這些方法的作用后骂删,我們配合RecyclerView給我們的一些API方法,要做其它事情容易多了四啰,隨意舉2個例子:
1宁玫、如果我們想要繪制分割線,只需要先調用getItemOffsets柑晒,讓Item空出一定的間隙欧瘪,然后再調用onDraw在這個間隙上填充顏色即可。
2匙赞、我們經(jīng)常會遇到一些節(jié)假日活動的需求佛掖,需要在列表上的邊角處標記“活動”,“特價”等特殊符號涌庭,這時候我們只需要調用onDrawOver在Item上繪制即可芥被。
言歸正傳,我們來看下今天我們要實現(xiàn)的效果坐榆,帶有吸頂效果的分組列表拴魄,上文已經(jīng)提及了可以分為3部分來看,頭部數(shù)據(jù)+列表數(shù)據(jù)+分割線席镀,其中列表數(shù)據(jù)是最基礎的RecyclerView的使用羹铅,這個我們就不說了,我們來看下其它2部分愉昆。
為了測試方便职员,這里我建立了一些本地數(shù)據(jù):
數(shù)據(jù)實體:
public class Bean {
private String text;
private String groupName;
public Bean(String text, String groupName) {
this.text = text;
this.groupName = groupName;
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
public String getGroupName() {
return groupName;
}
public void setGroupName(String groupName) {
this.groupName = groupName;
}
}
數(shù)據(jù)集合:
List<Bean> beanList = new ArrayList<>();
for (int i = 0; i < 6; i++) {
beanList.add(new Bean(String.format("第一組%d號", i + 1), "第一組"));
}
for (int i = 0; i < 6; i++) {
beanList.add(new Bean(String.format("第二組%d號", i + 1), "第二組"));
}
for (int i = 0; i < 6; i++) {
beanList.add(new Bean(String.format("第三組%d號", i + 1), "第三組"));
}
for (int i = 0; i < 6; i++) {
beanList.add(new Bean(String.format("第四組%d號", i + 1), "第四組"));
}
分割線的實現(xiàn):
首先我們需要在getItemOffsets方法中讓Item間空出空隙:
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
outRect.bottom = 1;
}
然后我們在onDraw方法中去對這個空隙繪制顏色(繪制一個帶有顏色矩形)
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
int count = parent.getChildCount();
for (int i = 0; i < count; i++) {
View view = parent.getChildAt(i);
c.drawRect(0, view.getBottom(), parent.getWidth(), view.getBottom() + 1, mLinePaint);
}
}
這里需要注意的一個地方是RecyclerView的getChildCount方法只會拿到當前可視區(qū)域的Item項,然后我們對Item進行遍歷繪制矩形(分割線)跛溉。
就這么簡單焊切,我們的分割線已經(jīng)畫好了扮授,看下實現(xiàn)效果:
頭部布局的實現(xiàn):
頭布局的實現(xiàn)和分割線是一樣的,它一樣需要讓Item空出空隙专肪,然后填充顏色刹勃,只是空出的空隙距離和顏色不一樣罷了,所以我們需要知道什么時候空出的分割線的空隙嚎尤,什么時候空出頭部布局的空隙荔仁,這個就和我們數(shù)據(jù)源有關系了,我們寫一個方法來判斷當前position所對應的Item項是不是每組數(shù)據(jù)的第一項:
/**
* 判斷position對應的Item是否是組的第一項
*
* @param position
* @return
*/
public boolean isItemHeader(int position) {
if (position == 0) {
return true;
} else {
String lastGroupName = mList.get(position - 1).getGroupName();
String currentGroupName = mList.get(position).getGroupName();
//判斷上一個數(shù)據(jù)的組別和下一個數(shù)據(jù)的組別是否一致芽死,如果不一致則是不同組乏梁,也就是為第一項(頭部)
if (lastGroupName.equals(currentGroupName)) {
return false;
} else {
return true;
}
}
}
然后來看下getItemOffsets方法,如果是每組第一項我們空出頭部布局的高度关贵,如果不是遇骑,我們則空出分割線的高度:
/**
* 設置Item的間距
*
* @param outRect
* @param view
* @param parent
* @param state
*/
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
if (parent.getAdapter() instanceof RecyclerViewAdapter) {
RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter();
int position = parent.getChildLayoutPosition(view);
boolean isHeader = adapter.isItemHeader(position);
if (isHeader) {
outRect.top = mItemHeaderHeight;
} else {
outRect.top = 1;
}
}
}
然后一樣的在onDraw方法里繪制背景顏色和文字即可,關于繪制的知識點這邊就不說了揖曾,屬于基礎的自定義View需要掌握的知識:
/**
* 繪制Item的分割線和組頭
*
* @param c
* @param parent
* @param state
*/
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
if (parent.getAdapter() instanceof RecyclerViewAdapter) {
RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter();
int count = parent.getChildCount();
for (int i = 0; i < count; i++) {
View view = parent.getChildAt(i);
int position = parent.getChildLayoutPosition(view);
boolean isHeader = adapter.isItemHeader(position);
if (isHeader) {
c.drawRect(0, view.getTop() - mItemHeaderHeight, parent.getWidth(), view.getTop(), mItemHeaderPaint);
mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect);
c.drawText(adapter.getGroupName(position), mTextPaddingLeft, (view.getTop() - mItemHeaderHeight) + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint);
} else {
c.drawRect(0, view.getTop() - 1, parent.getWidth(), view.getTop(), mLinePaint);
}
}
}
}
此時我們的頭部布局也畫好了落萎,看下實現(xiàn)效果:
吸頂效果的實現(xiàn):
關于吸頂?shù)男Ч鋵嵵灰覀兝砬宄牧鞒叹蜁l(fā)現(xiàn)其實并不復雜炭剪,50行代碼不到就可以把它完成练链。
首先我們需要知道以下幾點:
1、當我們滑動列表的時候奴拦,第一個頭布局是固定在我們的列表頂部的媒鼓。
2、通過滑動列表粱坤,當下一個頭布局和第一個頭布局相碰的時候隶糕,會把第一個布局“頂出去”瓷产,當?shù)谝粋€頭布局完全被“頂出去”后站玄,第二個頭布局并替代了第一個頭布局固定在列表頂部。
知道了上面2點后濒旦,有時候我們所看到的視覺效果會把我們帶入一個思維誤區(qū)株旷,比如這個吸頂效果,有的朋友可能會這樣去考慮尔邓,是不是需要在滑動的時候晾剖,動態(tài)的去改變getItemOffsets的空隙大小和在onDraw的繪制高度。如果真的這樣去做梯嗽,你會發(fā)現(xiàn)實現(xiàn)起來十分困難齿尽。
我們換一種思維,既然頂部的布局是固定不動的灯节,是不是可以利用onDrawOver在RecyclerView的上繪制一個和頭部布局一模一樣的布局呢循头,讓它覆蓋住了第一個頭布局绵估,在視覺上我們是不會有所察覺的,然后當列表滑動的時候卡骂,其實“原來的頭布局”早已經(jīng)滑動走了国裳,留下的其實是我們繪制的固定布局而已,等到下一個頭部布局“碰頭”的時候全跨,讓它隨著滑動的速度慢慢改變布局的高度缝左,當布局高度為0的時候,也就是被頂出去的時候浓若,然后再讓高度改變回來渺杉,覆蓋住第二個布局,然后不斷重復以上步驟七嫌。
可能說的有點抽象少办,我們來一張圖看一下,這次我故意把頭布局顏色改成紅色诵原,不清楚的朋友多看幾次就可以理解了趁餐。
看下具體代碼,我們先通過findFirstVisibleItemPosition拿到第一個可見的Item的position量愧,那我們就可以根據(jù)position+1可以知道下一個Item是否是另一組的頭布局(判斷組名是否發(fā)生了變化)鸣个,如果不是,我們的依舊繪制固定布局即可吗蚌,如果是腿倚,我們根據(jù)第一個可見Item的getBottom值的變小,慢慢的改變固定布局的高度蚯妇,直到被“頂出去”敷燎。
/**
* 繪制Item的頂部布局(吸頂效果)
*
* @param c
* @param parent
* @param state
*/
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
if (parent.getAdapter() instanceof RecyclerViewAdapter) {
RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter();
int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition();
View view = parent.findViewHolderForAdapterPosition(position).itemView;
boolean isHeader = adapter.isItemHeader(position + 1);
if (isHeader) {
int bottom = Math.min(mItemHeaderHeight, view.getBottom());
c.drawRect(0, view.getTop() - mItemHeaderHeight, parent.getWidth(), bottom, mItemHeaderPaint);
mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect);
c.drawText(adapter.getGroupName(position), mTextPaddingLeft, mItemHeaderHeight / 2 + mTextRect.height() / 2 - (mItemHeaderHeight - bottom), mTextPaint);
} else {
c.drawRect(0, 0, parent.getWidth(), mItemHeaderHeight, mItemHeaderPaint);
mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect);
c.drawText(adapter.getGroupName(position), mTextPaddingLeft, mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint);
}
}
}
吸頂效果就這么簡單的完成了,其實關鍵就在于onDrawOver這個方法箩言。
這里附上完整的ItemDecoration代碼(避免太多參數(shù)增加代碼閱讀難度硬贯,上面的講解沒有考慮RecyclerView存在Padding的情況,這邊已給出補充):
package com.lcw.view.stickheaderview;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.View;
/**
* 自定義裝飾器(實現(xiàn)分組+吸頂效果)
* Create by: chenWei.li
* Date: 2018/11/2
* Time: 上午1:14
* Email: lichenwei.me@foxmail.com
*/
public class StickHeaderDecoration extends RecyclerView.ItemDecoration {
//頭部的高
private int mItemHeaderHeight;
private int mTextPaddingLeft;
//畫筆陨收,繪制頭部和分割線
private Paint mItemHeaderPaint;
private Paint mTextPaint;
private Paint mLinePaint;
private Rect mTextRect;
public StickHeaderDecoration(Context context) {
mItemHeaderHeight = dp2px(context, 40);
mTextPaddingLeft = dp2px(context, 6);
mTextRect = new Rect();
mItemHeaderPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mItemHeaderPaint.setColor(Color.BLUE);
mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setTextSize(46);
mTextPaint.setColor(Color.WHITE);
mLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mLinePaint.setColor(Color.GRAY);
}
/**
* 繪制Item的分割線和組頭
*
* @param c
* @param parent
* @param state
*/
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
if (parent.getAdapter() instanceof RecyclerViewAdapter) {
RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter();
int count = parent.getChildCount();//獲取可見范圍內Item的總數(shù)
for (int i = 0; i < count; i++) {
View view = parent.getChildAt(i);
int position = parent.getChildLayoutPosition(view);
boolean isHeader = adapter.isItemHeader(position);
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
if (isHeader) {
c.drawRect(left, view.getTop() - mItemHeaderHeight, right, view.getTop(), mItemHeaderPaint);
mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect);
c.drawText(adapter.getGroupName(position), left + mTextPaddingLeft, (view.getTop() - mItemHeaderHeight) + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint);
} else {
c.drawRect(left, view.getTop() - 1, right, view.getTop(), mLinePaint);
}
}
}
}
/**
* 繪制Item的頂部布局(吸頂效果)
*
* @param c
* @param parent
* @param state
*/
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
if (parent.getAdapter() instanceof RecyclerViewAdapter) {
RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter();
int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition();
View view = parent.findViewHolderForAdapterPosition(position).itemView;
boolean isHeader = adapter.isItemHeader(position + 1);
int top = parent.getPaddingTop();
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
if (isHeader) {
int bottom = Math.min(mItemHeaderHeight, view.getBottom());
c.drawRect(left, top + view.getTop() - mItemHeaderHeight, right, top + bottom, mItemHeaderPaint);
mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect);
c.drawText(adapter.getGroupName(position), left + mTextPaddingLeft, top + mItemHeaderHeight / 2 + mTextRect.height() / 2 - (mItemHeaderHeight - bottom), mTextPaint);
} else {
c.drawRect(left, top, right, top + mItemHeaderHeight, mItemHeaderPaint);
mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect);
c.drawText(adapter.getGroupName(position), left + mTextPaddingLeft, top + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint);
}
c.save();
}
}
/**
* 設置Item的間距
*
* @param outRect
* @param view
* @param parent
* @param state
*/
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
if (parent.getAdapter() instanceof RecyclerViewAdapter) {
RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter();
int position = parent.getChildLayoutPosition(view);
boolean isHeader = adapter.isItemHeader(position);
if (isHeader) {
outRect.top = mItemHeaderHeight;
} else {
outRect.top = 1;
}
}
}
/**
* dp轉換成px
*/
private int dp2px(Context context, float dpValue) {
float scale = context.getResources().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
}
額外補充:
對于文章開頭說的那個仿微博固定頂部欄的效果:
我相信可以理解這篇文章的朋友應該都可以很輕松的用一個RecyclerView做出來了饭豹,簡單的說下思路,首先是微博內容务漩,我們把它當成是RecyclerView的HeaderView即可拄衰,也是Item的一項,然后下面的評論列表就是基礎的RecyclerView使用了饵骨,然后中間固定的布局翘悉,就可以這篇文章所講的ItemDecoration里的getItemOffsets和onDrawOver來配合實現(xiàn)了。
好了居触,到這里內容就結束了妖混,有什么疑問包吝,歡迎大家在評論給我留言~
源碼下載:
這里附上源碼地址(歡迎Star,歡迎Fork):StickHeaderView