寫在前面
本系列博客的demo都上傳到了github:RecyclerViewDemo
如果有幫助到你的話不妨給我點個star~
在介紹ItemDecoration之前我們不妨先看下它能實現什麼功能。
這是一個國內大部分城市的列表,通過城市拼音對其排序,通過拼音首字母對其分組。在滑動到某一組的城市時,它的Header會在頂部保持不動,下一組滑動上來時,新的Header會把上一組“頂”上去,這個效果就是ItemDecoration實現的。當然,爲了功能的完整性,我還添加了側邊欄用於搜索查找。
看完效果後是不是對ItemDecoration充滿了興趣?現在讓我們一步一步去認識它並實現這個功能吧。
一、ItemDecoration簡介
1.1 API介紹
ItemDecoration是定義在RecyclerView內部的抽象類,排除過時的方法,它提供了3個可重寫的方法,代碼如下。
public abstract static class ItemDecoration {
/**
* 繪製提供給RecyclerView的裝飾
* 任何由此方法繪製的內容都會在item繪製之前就繪製完畢,因此它是處於item下層的
*/
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull State state) {
onDraw(c, parent);
}
/**
* 此方法與onDraw相對,方法中的內容是在item繪製完畢後開始繪製的
* 因此會顯示在item上層
*/
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent,
@NonNull State state) {
onDrawOver(c, parent);
}
/**
* 通過outRect表示當前item距離left、top、right、bottom 4個方向的距離
*/
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
@NonNull RecyclerView parent, @NonNull State state) {
getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
parent);
}
}
其中onDraw(...)
和onDrawOver(...)
方法的參數中傳入了畫布Canvas,通過畫布我們可以在任何座標繪製任何事物。
getItemOffsets(...)
方法用於指定每個item距離左上右下4個方向的距離,效果同margin。參數中傳入的view表示當前的item,如果你想根據item的數據設置不同margin的話,可以通過RecyclerView的getChildAdapterPosition(View)
得到該item在Adapter中的position。
1.2 DividerItemDecoration分析
ItemDecoration最簡單的用法就是添加分隔線,如果使用DividerItemDecoration,之後你會發現item之間多了一根細細的分隔線。
RecyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
那麼這個細線是怎麼畫出來的呢?回憶一下ItemDecoration中方法的作用,步驟應該是這樣的:首先我們通過getItemOffsets()
在item之間添加間隔,然後通過onDraw()
或者onDrawOver()
在這段間隔內繪製線段。
爲了驗證我們的想法,來看一下DividerItemDecoration的源碼。我們使用的時候是垂直方向,代碼中水平方向的代碼我就省略掉了。
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); // 默認的分隔線
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;
// 根據RecyclerView是否有padding獲取線段的left和right的座標
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();
}
// 得到當前顯示的每個child的線段的top和bottom座標
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) {
// 設置每個item與下方的間隔爲mDivider.getIntrinsicHeight()
outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
} else {
outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
}
}
}
DividerItemDecoration的實現邏輯果然與我們想的一樣,先在getItemOffsets()
中設置每個item與下方的間隔,隨後在onDraw(...)
中調用drawVertical(Canvas canvas, RecyclerView parent)
得到線段的邊界座標並繪製。
二、城市列表實現
2.1 分析實現方式
雖然demo中的效果是下一組的Header將上一組的Header頂了上去,但實現的邏輯並非如此,如果把Header的背景色調整爲半透明,效果是這樣的。
Header半透明之後,它就露出了馬腳,仔細觀察後我們可以總結實現這個效果所需的步驟:
① 每個分組的第一個城市item的上方都有一個Header,例如“阿壩”和“白城”的上方都有一個Header
② 當前RecyclerView所展示的第一個item的分類會顯示在RecyclerView的最上方,例如當前RecyclerView第一個item是“阿克蘇”、“安慶”等城市時,RecyclerView最上方會漂浮一個"A"類Header
③ 當某個分組的最後一個item滑出RecyclerView時,Header會隨着這個item一起滑走,這也是“頂上去”效果的由來。例如當“澳門”即將滑出RecyclerView時,"A"類Header會隨着“澳門”item一起滑走,並且我們很容易得到他們座標之間的關係:item.bottom = Header.bottom
2.2 具體實現
分析完步驟,即可開始實現這個效果。項目中的城市數據保存在arrays文件中,數據格式如下所示,我已經先爲每個城市添加了拼音併爲所有城市進行了排序,數據中還包括城市ID,完整數據請去博客開頭的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時,我們需要知道一個item是不是它分組的第一個或者是最後一個,那麼需要構建這樣的一個實體類:
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...
}
再來將數據都解析成實體類。由於數據是已經排好序的,那麼判斷一個item是不是它分組的第一個或最後一個可以用這種方式:如果第i個數據與第i-1個的group不相同,那麼i就是i的分組的第一個item;而i-1就是i-1的分組的最後一個item。
private void prepareCityInfo() {
mCityInfoList = new ArrayList<>();
String[] cityArray = getResources().getStringArray(R.array.city);
String curGroup = "0";
// 每 3 個String構成一個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)) {
// 如果當前城市的 group 信息與保存的不一致, 那麼就是該 group 的第一個
cityInfo.setIsFirstInGroup(true);
// 同時將該 group 信息添加到索引中
indexList.add(cityInfo.getGroup());
// 它的上一個城市就是上一個 group 的最後一個
if (i > 0) {
mCityInfoList.get(mCityInfoList.size() - 1).setIsLastInGroup(true);
}
curGroup = cityInfo.getGroup();
}
mCityInfoList.add(cityInfo);
}
}
下面重點來看下怎麼自定義ItemDecoration,我們根據之前總結的步驟來,首先爲每組的第一個item繪製Header,在繪製Header之前需要通過getItemOffsets()
方法爲Header預留空間。
@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中當前可見的所有item的數量。
@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所展示的第一個item的group並將其繪製即可。由於這個Header是在RecyclerView上層的,因此需要在onDrawOver()
中繪製。
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDrawOver(c, parent, state);
// 得到當前所展示的第一個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());
}
最後需要實現就是Header隨着當前group的最後一個item移動的效果,移動時Header的bottom與item的bottom一致即可。那什麼時候開始移動呢?很顯然就是當這個item的bottom與RecyclerView頂部的距離小於Header的高度時開始移動。我們修改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);
// 當前第一個item是它group的最後一個
// 且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());
}
}
代碼介紹到這裏就結束了,如果你對整體的程序感興趣,歡迎去github下載。