使用RecyclerView替代ListView已經是老生常談的話題了,RecyclerView的優秀和靈活已經經過了大量項目的實踐。最近在完成一個分組列表的需求時,使用到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允許應用結合adapter的數據集,對特定的item添加繪制一個周邊圖案。可以用于給items之間添加分割線、高亮裝飾效果或者分組邊界等等。
從谷歌官方的介紹可以知道,ItemDecoration是用于給列表的item添加各種裝飾效果,開發中最常見的就是為item添加分割線。
ItemDecoration本身是一個抽象類,拋去廢棄的方法,我們需要關心的方法只有三個:
public static abstract class ItemDecoration {
public void onDraw(Canvas c, RecyclerView parent, State state) {
onDraw(c, parent);
}
public void onDrawOver(Canvas c, RecyclerView parent, State state) {
onDrawOver(c, parent);
}
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),parent);
}
}
從源碼注釋中,可以大概了解這三個方法的用途:
-
onDraw
:在item繪制之前時被調用,將指定的內容繪制到item view內容之下; -
onDrawOver
:在item被繪制之后調用,將指定的內容繪制到item view內容之上 -
getItemOffsets
:在每次測量item尺寸時被調用,將decoration的尺寸計算到item的尺寸中
ItemDecoration三個方法的測試
谷歌官方在support.v7包中提供了ItemDecoration的一個實現DividerItemDecoration
,這里結合這個實現,來看看其三個需要實現的方法對UI的影響。
onDraw
private void drawVertical(Canvas canvas, RecyclerView parent) {
canvas.save();
final int left;
final int 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();
}
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(ViewCompat.getTranslationY(child));
final int top = bottom - mDivider.getIntrinsicHeight();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(canvas);
}
canvas.restore();
}
drawVertical
方法實現了對Orientation == VERTICAL
的RecyclerView繪制item之間的分割線。從傳入的canvas參數可以推斷,分割線的繪制是通過canvas機制繪制到屏幕上:mDivider.draw(canvas);
其中,mDivider是一個Drawable對象,可以通過setDrawable傳入自定義對象,不傳入時,會自動使用系統內置的分割線樣式:android.R.attr.listDivider
。通過遍歷每一個可見的child view,計算mDivider對應的left、top、right、bottom值,從而繪制到正確的位置上。對于縱向的RecyclerView而言,mDivider的left和right是固定的,和parent的左右內容邊界保持一致,也就是說,把parent的左右padding都計算進去,因而是代表了RecyclerView實際的內容區域。縱向的分割線一般位于每個item的底部,因此mDivider的top值理論上應該和child view的內容下邊界保持貼合。實際上,計算top和bottom的代碼,谷歌官方也有所調整,在最新的實現中,先通過parent.getDecoratedBoundsWithMargins(child, mBounds);
拿到之前在onMeasure過程中,通過調用getItemOffsets獲取到的mBounds,mBounds是包括了整個child view以及其decoration的總邊界,之后再計算mDivider的bottom、top值。
getItemOffsets
public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
RecyclerView.State state) {
if (mOrientation == VERTICAL) {
outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
} else {
outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
}
}
官方實現的getItemOffsets比較簡單,只是根據列表的方向,返回了分割線在相應方向的尺寸。這里可能有一個坑,即通過setDrawable設置自定義的分割線時,容易傳入一個無尺寸的drawable對象,導致分割線無法顯示出來的bug,典型的代碼是這樣:
decoration.setDrawable(new ColorDrawable(Color.RED));
DividerItemDecoration的實現中,是沒有復寫onDrawOver方法的,對于分割線場景而言,也確實不需要去實現它。接下來,通過幾個例子,展示一下getItemOffsets對于ItemDecoration在UI上的影響。
getItemOffsets & onDraw
先上動圖【注2】:
上圖中,getItemOffsets方法里,返回outRect不同,而onDraw方法繪制的分割線高度初始值設為25,并通過外部增減來觀察其UI效果。
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
outRect.set(0, 0, 0, 50);// outRect.set(50,50,50,50);
}
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
for (int i = 0; i < childCount; i++) {
final View view = parent.getChildAt(i);
top = view.getBottom();
left = view.getPaddingLeft() + mSize;
right = view.getWidth() - view.getPaddingRight() - mSize ;
bottom = top + mSize;
divider.setBounds(left, top, right, bottom);
divider.draw(c);
}
}
從上面兩個動圖對比,可以得出以下幾個結論:
- getItemOffsets返回的矩形outRect會被計算到child view的尺寸當中;
- onDraw方法繪制的圖形,可以超出outRect所規定的區域;
- onDraw方法繪制的圖形,確實是處于child view的底下,當兩者發生重疊時,只會顯示child view的內容;
getItemOffsets & onDrawOver
將之前onDraw方法內代碼完整拷貝到onDrawOver下,并注釋掉之前onDraw中的方法,很容易驗證出onDrawOver與onDraw的唯一不同之處。
- onDrawOver繪制的圖形,處于child view之上,當兩者發生重疊時,會顯示onDrawOver的內容;
ItemDecoration三個方法的含義,就介紹到這里。可以感覺到,三個方法都很簡單而基礎,可以十分優雅的實現item的分割線效果,然而簡單的如DividerItemDecoration,往往是無法滿足項目開發需求的。經常會遇到某幾個item不想要分割線(如頭部或者最后一個item),這就需要開發者自行來實現。
利用ItemDecoration實現分組列表效果
先看效果圖:
上圖展示了利用ItemDecoration實現分組欄的效果,對于分組效果,需要注意的點在于,如何確定分組欄位置和內容,如何實現分組欄吸頂效果(如果需要)。
- 分組欄位置一般是由外部決定,常見是根據數據源list中某個特征值來決定,比較好的做法是通過接口來實現。
public interface IHover {
/**
* 當前position是否需要繪制分組欄
* @param position 當前位置
* @return true表示需要繪制
*/
boolean isGroup(int position);
/**
* 當前位置需要繪制的文本
* @param position 當前位置
* @return String
*/
String groupText(int position);
}
- 分組欄效果實際上是利用了onDrawOver和onDraw方法,onDraw方法負責繪制每一個需要分組的Decoration,而onDrawOver方法只繪制最頂部item的Decoration,由于onDrawOver繪制的內容永遠會顯示在最頂層,因此,實際上是,每一個頂部item都繪制了一個Decoration,但是相同分組的Decoration內容和位置一摸一樣,就導致看上去是一直吸頂的效果。部分代碼如下:
#onDraw:
if (builder.iHover.isGroup(position)) {
bottom = childView.getTop();
top = bottom - builder.decorationHeight;
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(c);
String text = builder.iHover.groupText(position);
if (!TextUtils.isEmpty(text)) {
Paint.FontMetrics fm = textPaint.getFontMetrics();
//文字豎直居中顯示
float baseLine = bottom - (builder.decorationHeight - (fm.bottom - fm.top)) / 2 - fm.bottom;
int textLeft = left;
float textWidth = textPaint.measureText(text, 0, text.length());
if (builder.textAlign == Builder.ALIGN_MIDDLE) {
textLeft = (int) (parent.getPaddingLeft() + parent.getWidth()/2 - textWidth/2);
}
c.drawText(text, textLeft + builder.textLeftPadding, baseLine, textPaint);
}
}
#getItemOffsets:
// 分組模式只在分組時才繪制
if (builder.iHover.isGroup(pos)) {
outRect.set(0, builder.decorationHeight, 0, 0);
}
#onDrawOver:
// 只有需要分組功能時,才走以下邏輯
if (builder.iHover != null) {
int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition();
int bottom, top;
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
top = parent.getPaddingTop();
bottom = top + builder.decorationHeight;
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(c);
String text = builder.iHover.groupText(position);
if (!TextUtils.isEmpty(text)) {
Paint.FontMetrics fm = textPaint.getFontMetrics();
//文字豎直居中顯示
float baseLine = bottom - (builder.decorationHeight - (fm.bottom - fm.top)) / 2 - fm.bottom;
int textLeft = left;
float textWidth = textPaint.measureText(text, 0, text.length());
if (builder.textAlign == Builder.ALIGN_MIDDLE) {
textLeft = (int) (parent.getPaddingLeft() + parent.getWidth()/2 - textWidth/2);
}
c.drawText(text, textLeft + builder.textLeftPadding, baseLine, textPaint);
}
}
簡單的封裝MKItemDecoration
- 支持簡單顏色分割線
- 支持簡單顏色分割線 + 文字:文字可以居左、居中
- 支持分割線跳過起始諾干個item,跳過最后一個item
- 支持分組懸停效果
-
支持自定義View作為Decoration
customView.gif
上圖hoverGroup.gif的使用代碼如下:
recyclerView.addItemDecoration(new MKItemDecoration.Builder()
.height(50)
.color(Color.parseColor("#525D97"))
.textSize(30)
.textColor(Color.WHITE)
.itemOffset(0)
.iHover(new IHover() {
@Override
public boolean isGroup(int position) {
return position % 4 == 0;
}
@Override
public String groupText(int position) {
return adapter.data.get(4 * (position / 4));
}
}).
.textAlign(MKItemDecoration.Builder.ALIGN_MIDDLE)
.build());
通過封裝,利用builder模式來更好的自定義需要的Decoration,其中,為了支持自定義View,需要外部傳入相關的view的資源id和需要綁定的數據List,控件內部會通過view的measure,layout,draw的流程,將其繪制在屏幕上。
具體代碼見:https://github.com/Dragon-Boat/library
歡迎提issue 和 star~
TODO:
- itemDecoration是通過draw繪制圖形,不支持點擊事件
感謝:
- https://blog.piasy.com/2016/03/26/Insight-Android-RecyclerView-ItemDecoration/
- https://github.com/fishyer/PinnedRecyclerView
注1:圖片引用自該文章鏈接。
注2:動圖使用Vysor+GifCam錄制,前者將手機屏幕內容投射到電腦上,后者錄制git圖片。