RecyclerView 已經推出了一年多了,日常開發中也已經徹底從 ListView 遷移到了 RecyclerView,但前兩天有人在一個安卓群里面問了個關於最頂上的 item view 加蒙層的問題,被人用 ItemDecoration 完美解決。此時我發現自己對 RecyclerView 的使用一直太過基本,更深入更強大的功能完全沒有涉及,像 ItemDecoration, ItemAnimator, SmoothScroller, OnItemTouchListener, LayoutManager 之類,以及 RecyclerView 重用 view 的原理。網上也有很多對 RecyclerView 使用的講解博客,要么講的內容非常少,要么提到了高級功能,但是並沒講代碼為什么這樣寫,每個方法和參數的含義是什么,像張鴻洋的博客,也講了 ItemDecoration 的使用,但是看了仍然雲里霧里,只能把他的代碼拿來用,並不能根據自己的需求編寫自己的 ItemDecoration。
在這個系列中,我將對上述各個部分進行深入研究,目標就是看了這一系列的文章之后,開發者可以清楚快捷的根據自己的需求,編寫自己需要的各個高級模塊。本系列第一篇就聚焦在:RecyclerView.ItemDecoration。本文涉及到的完整代碼可以在 Github 獲取。
TL; DR
- getItemOffsets 中為 outRect 設置的4個方向的值,將被計算進所有 decoration 的尺寸中,而這個尺寸,被計入了 RecyclerView 每個 item view 的 padding 中
- 在 onDraw 為 divider 設置繪制范圍,並繪制到 canvas 上,而這個繪制范圍可以超出在 getItemOffsets 中設置的范圍,但由於 decoration 是繪制在 child view 的底下,所以並不可見,但是會存在 overdraw
- decoration 的 onDraw,child view 的 onDraw,decoration 的 onDrawOver,這三者是依次發生的
- onDrawOver 是繪制在最上層的,所以它的繪制位置並不受限制
RecyclerView.ItemDecoration
這個類包含三個方法 1:
onDraw(Canvas c, RecyclerView parent, State state)
onDrawOver(Canvas c, RecyclerView parent, State state)
getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)
getItemOffsets
官方樣例的 DividerItemDecoration
里面是這樣實現的:
[代碼]java代碼:
1
2
3
4
5
|
if
(mOrientation == VERTICAL_LIST) {
outRect.set(
0
,
0
,
0
, mDivider.getIntrinsicHeight());
}
else
{
outRect.set(
0
,
0
, mDivider.getIntrinsicWidth(),
0
);
}
|
這個outRect設置的四個值是什么意思呢?先來看看它是在哪里調用的,它在RecyclerView中唯一被調用的地方就是 getItemDecorInsetsForChild(View child)
函數。
[代碼]java代碼:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
|
Rect getItemDecorInsetsForChild(View child) {
final
LayoutParams lp = (LayoutParams) child.getLayoutParams();
if
(!lp.mInsetsDirty) {
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
);
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;
}
|
可以看到,getItemOffsets
函數中設置的值被加到了 insets
變量中,並被該函數返回,那么 insets 又是啥呢?
insets 是啥?
根據Inset Drawable文檔,它的使用場景是:當一個view需要的背景小於它的邊界時。例如按鈕圖標較小,但是我們希望按鈕有較大的點擊熱區,一種做法是使用ImageButton,設置background="@null"
,把圖標資源設置給src
屬性,這樣ImageButton可以大於圖標,而不會導致圖標也跟着拉伸到ImageButton那么大。那么使用Inset drawable也能達到這樣的目的。但是相比之下有什么優勢呢?src
屬性也能設置selector drawable,所以點擊態也不是問題。也許唯一的優勢就是更“優雅”吧 :)
回到正題,getItemDecorInsetsForChild 函數中會重置 insets 的值,並重新計算,計算方式就是把所有 ItemDecoration 的 getItemOffsets 中設置的值累加起來 2,而這個 insets 實際上是 RecyclerView 的 child 的 LayoutParams 中的一個屬性,它會在 getTopDecorationHeight
,getBottomDecorationHeight
等函數中被返回,那么這個 insets 的意義就很明顯了,它記錄的是所有 ItemDecoration 所需要的 3尺寸的總和。
而在 RecyclerView 的 measureChild(View child, int widthUsed, int heightUsed)
函數中,調用了 getItemDecorInsetsForChild,並把它算在了 child view 的 padding 中。
[代碼]java代碼:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
|
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);
}
}
|
上面這段代碼中調用 getChildMeasureSpec
函數的第三個參數就是 child view 的 padding,而這個參數就把 insets 的值算進去了。那么現在就可以確認了,getItemOffsets 中為 outRect 設置的4個方向的值,將被計算進所有 decoration 的尺寸中,而這個尺寸,被計入了 RecyclerView 每個 item view 的 padding 中。
PoC
這一步測試主要是對 getItemOffsets 函數傳入的 outRect 參數各個值的設置,以證實上述分析的結論。
可以看到,當 left, top, right, bottom 全部設置為50時,RecyclerView 的每個 item view 各個方向的 padding 都增加了,對比各種情況,確實 getItemOffsets 中為 outRect 設置的值都將被計入 RecyclerView 每個 item view 的 padding 中。
onDraw
先來看看官方樣例的 DividerItemDecoration
實現:
[代碼]java代碼:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
|
public
void
drawVertical(Canvas c, RecyclerView parent) {
final
int
left = parent.getPaddingLeft();
final
int
right = parent.getWidth() - parent.getPaddingRight();
final
int
childCount = parent.getChildCount();
for
(
int
i =
0
; i < childCount; i++) {
final
View child = parent.getChildAt(i);
final
RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
.getLayoutParams();
final
int
top = child.getBottom() + params.bottomMargin +
Math.round(ViewCompat.getTranslationY(child));
final
int
bottom = top + mDivider.getIntrinsicHeight();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(c);
}
}
|
drawVertical 是為縱向的 RecyclerView 繪制 divider,遍歷每個 child view 4 ,把 divider 繪制到 canvas 上,而 mDivider.setBounds
則設置了 divider 的繪制范圍。其中,left 設置為 parent.getPaddingLeft(),也就是左邊是 parent 也就是 RecyclerView 的左邊界加上 paddingLeft 之后的位置,而 right 則設置為了 RecyclerView 的右邊界減去 paddingRight 之后的位置,那這里左右邊界就是 RecyclerView 的內容區域 5了。top 設置為了 child 的 bottom 加上 marginBottom 再加上 translationY,這其實就是 child view 的下邊界 6,bottom 就是 divider 繪制的下邊界了,它就是簡單地 top 加上 divider 的高度。
PoC
這一步測試主要是對 onDraw 函數中對 divider 的繪制邊界的設置。
可以看到,當我們把 left, right, top 7 設置得和官方樣例一樣,bottom 設置為 top + 25
,注意,這里 getItemOffsets 對 outSets 的設置只有 bottom = 50
,也就是 decoration 高度為50,我們可以看到,decoration 的上半部分就繪制為黑色了,下半部分沒有繪制。而如果設置top = child.getBottom() + params.bottomMargin - 25
,bottom = top + 50
,就會發現 child view 的底部出現了 overdraw。所以這里我們可以得出結論:在 onDraw 為 divider 設置繪制范圍,並繪制到 canvas 上,而這個繪制范圍可以超出在 getItemOffsets 中設置的范圍,但由於 decoration 是繪制在 child view 的底下,所以並不可見,但是會存在 overdraw。
onDrawOver
有一點需要注意:decoration 的 onDraw,child view 的 onDraw,decoration 的 onDrawOver,這三者是依次發生的。而由於 onDrawOver 是繪制在最上層的,所以它的繪制位置並不受限制(當然,decoration 的 onDraw 繪制范圍也不受限制,只不過不可見),所以利用 onDrawOver 可以做很多事情,例如為 RecyclerView 整體頂部繪制一個蒙層,或者為特定的 item view 繪制蒙層。這里就不單獨進行測試了,請見下一節的整體效果。
All in together
實現的效果:除了最后一個 item view,底部都有一個高度為25的黑色 divider,為整個 RecyclerView 的頂部繪制了一個漸變的蒙層。效果圖如下:
小結
- getItemOffsets 中為 outRect 設置的4個方向的值,將被計算進所有 decoration 的尺寸中,而這個尺寸,被計入了 RecyclerView 每個 item view 的 padding 中
- 在 onDraw 為 divider 設置繪制范圍,並繪制到 canvas 上,而這個繪制范圍可以超出在 getItemOffsets 中設置的范圍,但由於 decoration 是繪制在 child view 的底下,所以並不可見,但是會存在 overdraw
- decoration 的 onDraw,child view 的 onDraw,decoration 的 onDrawOver,這三者是依次發生的
- onDrawOver 是繪制在最上層的,所以它的繪制位置並不受限制
腳注
-
不算被 Deprecated 的方法
-
把 left, top, right, bottom 4個屬性分別累加
-
也就是在 getItemOffsets 函數中為 outRect 參數設置的4個屬性值
-
child view,並不是 adapter 的每一個 item,只有可見的 item 才會繪制,才是 RecyclerView 的 child view
-
可以類比 CSS 的盒子模型,一個 view 包括 content, padding, margin 三個部分,content 和 padding 加起來就是 view 的尺寸,而 margin 不會增加 view 的尺寸,但是會影響和其他 view 的位置間距,但是安卓的 view 沒有 margin 的合並
-
bottom 就是 content 的下邊界加上 paddingBottom,而為了不“吃掉” child view 的底部邊距,所以就加上 marginBottom,而 view 還能設置 translation 屬性,用於 layout 完成之后的再次偏移,同理,為了不“吃掉”這個偏移,所以也要加上 translationY
-
這里由於並沒有對 child view 設置 translation,為了代碼簡短,就沒有減去 translationY,實際上是需要的
繼續深入理解:深入理解 RecyclerView 系列之二:ItemAnimator