寫在前面
本系列博客的demo都上傳到了github:RecyclerViewDemo
如果有幫助到你的話不妨給我點(diǎn)個(gè)star~
在介紹ItemDecoration之前我們不妨先看下它能實(shí)現(xiàn)什么功能。
這是一個(gè)國內(nèi)大部分城市的列表,通過城市拼音對(duì)其排序,通過拼音首字母對(duì)其分組。在滑動(dòng)到某一組的城市時(shí),它的Header會(huì)在頂部保持不動(dòng),下一組滑動(dòng)上來時(shí),新的Header會(huì)把上一組“頂”上去,這個(gè)效果就是ItemDecoration實(shí)現(xiàn)的。當(dāng)然,為了功能的完整性,我還添加了側(cè)邊欄用于搜索查找。

看完效果后是不是對(duì)ItemDecoration充滿了興趣?現(xiàn)在讓我們一步一步去認(rèn)識(shí)它并實(shí)現(xiàn)這個(gè)功能吧。
一、ItemDecoration簡介
1.1 API介紹
ItemDecoration是定義在RecyclerView內(nèi)部的抽象類,排除過時(shí)的方法,它提供了3個(gè)可重寫的方法,代碼如下。
public abstract static class ItemDecoration {
/**
* 繪制提供給RecyclerView的裝飾
* 任何由此方法繪制的內(nèi)容都會(huì)在item繪制之前就繪制完畢,因此它是處于item下層的
*/
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull State state) {
onDraw(c, parent);
}
/**
* 此方法與onDraw相對(duì),方法中的內(nèi)容是在item繪制完畢后開始繪制的
* 因此會(huì)顯示在item上層
*/
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent,
@NonNull State state) {
onDrawOver(c, parent);
}
/**
* 通過outRect表示當(dāng)前item距離left、top、right、bottom 4個(gè)方向的距離
*/
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
@NonNull RecyclerView parent, @NonNull State state) {
getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
parent);
}
}
其中onDraw(...)和onDrawOver(...)方法的參數(shù)中傳入了畫布Canvas,通過畫布我們可以在任何坐標(biāo)繪制任何事物。
getItemOffsets(...)方法用于指定每個(gè)item距離左上右下4個(gè)方向的距離,效果同margin。參數(shù)中傳入的view表示當(dāng)前的item,如果你想根據(jù)item的數(shù)據(jù)設(shè)置不同margin的話,可以通過RecyclerView的getChildAdapterPosition(View)得到該item在Adapter中的position。
1.2 DividerItemDecoration分析
ItemDecoration最簡單的用法就是添加分隔線,如果使用DividerItemDecoration,之后你會(huì)發(fā)現(xiàn)item之間多了一根細(xì)細(xì)的分隔線。
RecyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
那么這個(gè)細(xì)線是怎么畫出來的呢?回憶一下ItemDecoration中方法的作用,步驟應(yīng)該是這樣的:首先我們通過getItemOffsets()在item之間添加間隔,然后通過onDraw()或者onDrawOver()在這段間隔內(nèi)繪制線段。
為了驗(yàn)證我們的想法,來看一下DividerItemDecoration的源碼。我們使用的時(shí)候是垂直方向,代碼中水平方向的代碼我就省略掉了。
public class DividerItemDecoration extends RecyclerView.ItemDecoration {
private static final int[] ATTRS = new int[]{ android.R.attr.listDivider };
private Drawable mDivider;
// 垂直或水平方向
private int mOrientation;
private final Rect mBounds = new Rect();
public DividerItemDecoration(Context context, int orientation) {
final TypedArray a = context.obtainStyledAttributes(ATTRS);
mDivider = a.getDrawable(0); // 默認(rèn)的分隔線
a.recycle();
setOrientation(orientation);
}
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
if (mOrientation == VERTICAL) {
drawVertical(c, parent);
}
}
private void drawVertical(Canvas canvas, RecyclerView parent) {
canvas.save();
final int left;
final int right;
// 根據(jù)RecyclerView是否有padding獲取線段的left和right的坐標(biāo)
if (parent.getClipToPadding()) {
left = parent.getPaddingLeft();
right = parent.getWidth() - parent.getPaddingRight();
canvas.clipRect(left, parent.getPaddingTop(), right,
parent.getHeight() - parent.getPaddingBottom());
} else {
left = 0;
right = parent.getWidth();
}
// 得到當(dāng)前顯示的每個(gè)child的線段的top和bottom坐標(biāo)
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
parent.getDecoratedBoundsWithMargins(child, mBounds);
final int bottom = mBounds.bottom + Math.round(child.getTranslationY());
final int top = bottom - mDivider.getIntrinsicHeight();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(canvas);
}
canvas.restore();
}
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
RecyclerView.State state) {
if (mDivider == null) {
outRect.set(0, 0, 0, 0);
return;
}
if (mOrientation == VERTICAL) {
// 設(shè)置每個(gè)item與下方的間隔為mDivider.getIntrinsicHeight()
outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
} else {
outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
}
}
}
DividerItemDecoration的實(shí)現(xiàn)邏輯果然與我們想的一樣,先在getItemOffsets()中設(shè)置每個(gè)item與下方的間隔,隨后在onDraw(...)中調(diào)用drawVertical(Canvas canvas, RecyclerView parent)得到線段的邊界坐標(biāo)并繪制。
二、城市列表實(shí)現(xiàn)
2.1 分析實(shí)現(xiàn)方式
雖然demo中的效果是下一組的Header將上一組的Header頂了上去,但實(shí)現(xiàn)的邏輯并非如此,如果把Header的背景色調(diào)整為半透明,效果是這樣的。

Header半透明之后,它就露出了馬腳,仔細(xì)觀察后我們可以總結(jié)實(shí)現(xiàn)這個(gè)效果所需的步驟:
① 每個(gè)分組的第一個(gè)城市item的上方都有一個(gè)Header,例如“阿壩”和“白城”的上方都有一個(gè)Header
② 當(dāng)前RecyclerView所展示的第一個(gè)item的分類會(huì)顯示在RecyclerView的最上方,例如當(dāng)前RecyclerView第一個(gè)item是“阿克蘇”、“安慶”等城市時(shí),RecyclerView最上方會(huì)漂浮一個(gè)"A"類Header
③ 當(dāng)某個(gè)分組的最后一個(gè)item滑出RecyclerView時(shí),Header會(huì)隨著這個(gè)item一起滑走,這也是“頂上去”效果的由來。例如當(dāng)“澳門”即將滑出RecyclerView時(shí),"A"類Header會(huì)隨著“澳門”item一起滑走,并且我們很容易得到他們坐標(biāo)之間的關(guān)系:item.bottom = Header.bottom
2.2 具體實(shí)現(xiàn)
分析完步驟,即可開始實(shí)現(xiàn)這個(gè)效果。項(xiàng)目中的城市數(shù)據(jù)保存在arrays文件中,數(shù)據(jù)格式如下所示,我已經(jīng)先為每個(gè)城市添加了拼音并為所有城市進(jìn)行了排序,數(shù)據(jù)中還包括城市ID,完整數(shù)據(jù)請(qǐng)去博客開頭的github下載。
<string-array name="city">
<item>阿壩</item>
<item>aba</item>
<item>101271901</item>
<item>阿克蘇</item>
<item>akesu</item>
<item>101130801</item>
<item>阿勒泰</item>
<item>aletai</item>
<item>101131401</item>
<item>阿里</item>
<item>ali</item>
<item>101140701</item>
<item>安康</item>
<item>ankang</item>
<item>101110701</item>
<item>安慶</item>
<item>anqing</item>
<item>101220601</item>
<item>鞍山</item>
<item>anshan</item>
<item>101070301</item>
......
</string-array>
在繪制Header時(shí),我們需要知道一個(gè)item是不是它分組的第一個(gè)或者是最后一個(gè),那么需要構(gòu)建這樣的一個(gè)實(shí)體類:
public class CityInfo {
private String mCityName;
private String mPinYin;
private String mGroup;
private String mCityID;
private boolean mIsFirstInGroup;
private boolean mIsLastInGroup;
public CityInfo(String cityName, String pinYin, String cityID,
boolean isFirstInGroup, boolean isLastInGroup) {
this.mCityName = cityName;
this.mPinYin = pinYin;
this.mGroup = mPinYin.substring(0, 1).toUpperCase();
this.mCityID = cityID;
this.mIsFirstInGroup = isFirstInGroup;
this.mIsLastInGroup = isLastInGroup;
}
// setters and getters...
}
再來將數(shù)據(jù)都解析成實(shí)體類。由于數(shù)據(jù)是已經(jīng)排好序的,那么判斷一個(gè)item是不是它分組的第一個(gè)或最后一個(gè)可以用這種方式:如果第i個(gè)數(shù)據(jù)與第i-1個(gè)的group不相同,那么i就是i的分組的第一個(gè)item;而i-1就是i-1的分組的最后一個(gè)item。
private void prepareCityInfo() {
mCityInfoList = new ArrayList<>();
String[] cityArray = getResources().getStringArray(R.array.city);
String curGroup = "0";
// 每 3 個(gè)String構(gòu)成一個(gè)CityInfo
for (int i = 0; i < cityArray.length; i += 3) {
CityInfo cityInfo = new CityInfo(cityArray[i], cityArray[i + 1], cityArray[i + 2],
false, false);
if (!cityInfo.getGroup().equals(curGroup)) {
// 如果當(dāng)前城市的 group 信息與保存的不一致, 那么就是該 group 的第一個(gè)
cityInfo.setIsFirstInGroup(true);
// 同時(shí)將該 group 信息添加到索引中
indexList.add(cityInfo.getGroup());
// 它的上一個(gè)城市就是上一個(gè) group 的最后一個(gè)
if (i > 0) {
mCityInfoList.get(mCityInfoList.size() - 1).setIsLastInGroup(true);
}
curGroup = cityInfo.getGroup();
}
mCityInfoList.add(cityInfo);
}
}
下面重點(diǎn)來看下怎么自定義ItemDecoration,我們根據(jù)之前總結(jié)的步驟來,首先為每組的第一個(gè)item繪制Header,在繪制Header之前需要通過getItemOffsets()方法為Header預(yù)留空間。
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent,
@NonNull RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
int position = parent.getChildAdapterPosition(view);
CityInfo cityInfo = mCityInfoList.get(position);
if (cityInfo.isFirstInGroup()) {
outRect.top = GROUP_ITEM_TOP;
}
}
再通過onDraw()繪制,這里parent.getChildCount()獲取到的是RecyclerView中當(dāng)前可見的所有item的數(shù)量。
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDraw(c, parent, state);
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
View view = parent.getChildAt(i);
int position = parent.getChildAdapterPosition(view);
CityInfo cityInfo = mCityInfoList.get(position);
if (cityInfo.isFirstInGroup()) {
int left = parent.getPaddingLeft();
int top = view.getTop() - GROUP_ITEM_TOP;
int right = parent.getRight() - parent.getPaddingRight();
int bottom = view.getTop();
// 繪制背景
c.drawRect(left, top, right, bottom, mBackGroundPaint);
// 繪制文字
drawText(c, left, top, bottom, cityInfo.getGroup());
}
}
}
再來繪制固定于RecyclerView頂端的Header,這里只要得到RecyclerView所展示的第一個(gè)item的group并將其繪制即可。由于這個(gè)Header是在RecyclerView上層的,因此需要在onDrawOver()中繪制。
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDrawOver(c, parent, state);
// 得到當(dāng)前所展示的第一個(gè)View
View view = parent.getChildAt(0);
int position = parent.getChildAdapterPosition(view);
CityInfo cityInfo = mCityInfoList.get(position);
int left = parent.getPaddingLeft();
int top = parent.getPaddingTop();
int right = parent.getRight() - parent.getPaddingRight();
int bottom = top + GROUP_ITEM_TOP;
c.drawRect(left, top, right, bottom, mBackGroundPaint);
drawText(c, left, top, bottom, cityInfo.getGroup());
}
最后需要實(shí)現(xiàn)就是Header隨著當(dāng)前group的最后一個(gè)item移動(dòng)的效果,移動(dòng)時(shí)Header的bottom與item的bottom一致即可。那什么時(shí)候開始移動(dòng)呢?很顯然就是當(dāng)這個(gè)item的bottom與RecyclerView頂部的距離小于Header的高度時(shí)開始移動(dòng)。我們修改onDrawOver()方法如下即可。
@Override
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDrawOver(c, parent, state);
View view = parent.getChildAt(0);
int position = parent.getChildAdapterPosition(view);
CityInfo cityInfo = mCityInfoList.get(position);
// 當(dāng)前第一個(gè)item是它group的最后一個(gè)
// 且view的bottom距離RecyclerView頂端小于Header的高度
if (cityInfo.isLastInGroup() && view.getBottom() < GROUP_ITEM_TOP) {
int left = parent.getPaddingLeft();
int top = view.getBottom() - GROUP_ITEM_TOP;
int right = parent.getRight() - parent.getPaddingRight();
int bottom = view.getBottom();
c.drawRect(left, top, right, bottom, mBackGroundPaint);
drawText(c, left, top, bottom, cityInfo.getGroup());
} else {
int left = parent.getPaddingLeft();
int top = parent.getPaddingTop();
int right = parent.getRight() - parent.getPaddingRight();
int bottom = top + GROUP_ITEM_TOP;
c.drawRect(left, top, right, bottom, mBackGroundPaint);
drawText(c, left, top, bottom, cityInfo.getGroup());
}
}
代碼介紹到這里就結(jié)束了,如果你對(duì)整體的程序感興趣,歡迎去github下載。