利用ItemDecoration實現懸浮頭部
在我們的日常開發中,RecyclerView已經被使用的越來越廣泛,今天來講一講使用ItemDecoration來實現專案中需要的懸浮頭部的效果。我們使用listView就可以知道,直接從xml檔案中使用 android:divider 這個屬性就可以直接設定listVie中itemw的分割線,可以設定分割線的drawable,但是在recyclerView中卻沒有這個屬性了,有時候為了圖方便,直接在RecyclerView的item裡面通過設定view的方式設定分割線,Google其實並不推薦這種做法的,因為這樣設定了之後,一些notifyItemInsert等這樣的效果就失去了,因為,為了更靈活的定製分割線,Google給我們提供了一個類RecyclerView.ItemDecoration,如果想要實現自定義分割線的話需要去繼承這個類,然後實現他的幾個方法。具體如下:
-
如果懶的實現分割線,Google給我們提供了一個預設的分割線,DividerItemDecoration(),裡面兩個引數,一個context,一個是分割線的方向,橫向或者縱向DividerItemDecoration.Vertical或者DividerItemDecoration.Horizantal
-
我們點進原始碼就可以看到,RecyclerView.ItemDecoration並不複雜,是一個抽象類,並且只有幾個方法,主要的方法分別為 getItemOffsets、onDraw、onDrawOver,其餘的都是已廢棄的,也是相互呼叫的方法,三個方法如下,
public abstract static class ItemDecoration { public void onDraw(Canvas c, RecyclerView parent, State state) { onDraw(c, parent); } @Deprecated public void onDraw(Canvas c, RecyclerView parent) { } public void onDrawOver(Canvas c, RecyclerView parent, State state) { onDrawOver(c, parent); } @Deprecated public void onDrawOver(Canvas c, RecyclerView parent) { } @Deprecated public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) { outRect.set(0, 0, 0, 0); } public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) { getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(), parent); } }
getItemOffsets方法包含4個引數,其中outRect 若不設定則是一個全為 0 的 Rect。view 指 RecyclerView 中的 Item。parent 就是 RecyclerView 本身,state 就是一個狀態。
@Deprecated
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
outRect.set(0, 0, 0, 0);
}
可以看這張圖,綠色區域代表 RecyclerView 中的一個 ItemView,而外面橙色區域也就是相應的 outRect,也就是 ItemView 與其它元件的偏移區域,等同於 margin 屬性,通過複寫 getItemOffsets() 方法,然後指定 outRect 中的 top、left、right、bottom 就可以控制各個方向的間隔了。注意的是這些屬性都是偏移量,是指偏移 ItemView 各個方向的數值。
我們知道,onDraw()方法是自定義View必不可少的方法,具體就是繪製出自己想要的外觀,裡面三個引數canvas、recyclerView以及狀態,這個方法是配合前面一個 getItemOffsets方法一起繪製的,getItemOffsets 撐開了 ItemView 的上下左右間隔區域,而 onDraw 方法通過計算每個 ItemView 的座標位置與它的 outRect 值來確定它要繪製內容的區間。需要注意的是,onDraw方法是在繪製每一個itemView之前進行繪製的,如果繪製不當的話,itemView的內容就很可能會覆蓋掉我們在onDraw方法裡繪製的內容。
假設,我們要設計一個高度為 1 px 的分割線,那麼我們就需要在每個 ItemView top位置上方畫一個 1 px 高度的矩形,然後填充顏色為紅色。 程式碼也挺簡單。
/**
* 針對每一個ItemView設定偏移
* outRect 全為0的一個矩形Rect
* view RecyclerView中的Item
* parent RecyclerView本身
* state Item的狀態
*/
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
//設定偏移的高度
outRect.top = 1
}
需要注意的一點是 getItemOffsets 是針對每一個 ItemView,而 onDraw 方法卻是針對 RecyclerView 本身,所以在 onDraw 方法中需要遍歷螢幕上可見的 ItemView,分別獲取它們的位置資訊,然後分別的繪製對應的分割線。
/**
* 針對 RecyclerView 本身,需要遍歷螢幕上可見的Item
* 在Item之前繪製
* 通過計算每個Item的座標位置與outRect確定繪製內容的區間
*/
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
}
接下來是onDrawOver方法,可以看到,onDrawOver和onDraw方法差別並不大,方法只是名字不一樣而已,區別就是onDraw是繪製在itemView的內容之前,而onDrawOver則是在繪製itemView之後進行繪製,可以覆蓋itemView的內容之上,因此,我們可以製造出我們想要的如時光軸效果,但是,我們今天要研究的是實現懸浮頭部,就是每個item都有自己的頭部,當上移至移出螢幕時,頭部依然懸浮在最上方,常見的就是微信聯絡人那種效果了。
/**
* Item之後繪製
* 當前的item
* 1、不是螢幕上第一個可見的Item,但是是組內第一個Item,此時需要繪製
* 2、不是螢幕上第一個可見的Item,而且不是組內第一個Item,此時不需要繪製
* 3、是螢幕上第一個可見的Item,需要繪製,位置固定
*/
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state)
}
接下來看看如何實現,首先,新建一個類繼承RecyclerView.ItemDecoration,然後,依然在我們的getItemOffsets方法裡面為我們要繪製的頭部設定合適的區間,如果想繪製文字,要先測量出文字的高度,然後再設定outRect的top值,具體初始化的程式碼如下,
init {
mPaint.color = Color.YELLOW
mPaint.isDither = true
mTvPaint.color = Color.RED
mTvPaint.isDither = true
mTvPaint.textSize = TypedValue.applyDimension(COMPLEX_UNIT_SP,12f,context.resources.displayMetrics)
val rect = Rect()
mTvPaint.getTextBounds("王",0,1,rect)
fontMetricsInt = mTvPaint.fontMetricsInt
mTvHeight = rect.height()
Log.e(TAG,"文字高度-> $mTvHeight")
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
//設定偏移的高度為文字的高度
outRect.top = mTvHeight
}
緊接著,在onDraw方法裡,繪製出想要的文字及背景,程式碼如下,這裡只是簡單的繪製,需要注意的是,由於android座標系的原因,我們在drawRect的時候,top的值應該為view.top-tvHeight,因為向下為正,然後對我們想要繪製的效果進行分析:
噹噹前的itemView為螢幕上第一個可見的 ItemView,此時需要繪製,而且該起始位置應該依附在 RecyclerView 的內容起始位置,因為只有這樣才會表現出懸浮的效果。因此,我們可以對程式碼進行這樣編寫,註釋寫的比較清楚了
/**
* Item之後繪製
* 當前的item
* 1、不是螢幕上第一個可見的Item,但是是組內第一個Item,此時需要繪製
* 2、不是螢幕上第一個可見的Item,而且不是組內第一個Item,此時不需要繪製
* 3、是螢幕上第一個可見的Item,需要繪製,位置固定
*/
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state)
val childCount = parent.childCount
for(i in 0 until childCount){
var view = parent.getChildAt(i)
var index = parent.getChildAdapterPosition(view)
var left = parent.paddingLeft.toFloat()
var right = (parent.width - parent.paddingRight).toFloat()
//不是螢幕上第一個可見的Item
if (i!=0){
var top = (view.top - mTvHeight).toFloat()
var bottom = view.top.toFloat()
//
drawHeader(view.top,c,left,top,right,bottom)
}else{
// 螢幕上第一個可見的Item 此時因為要懸浮,所以要以recyclerView的頂部為準,而不是item了,位置要注意下
var top = parent.paddingTop
var sugTop = view.bottom - mTvHeight
// 當 ItemView 與 Header 底部平齊的時候,判斷 Header 的頂部是否小於
// parent 頂部內容開始的位置,如果小於則對 Header.top 進行位置更新,
//否則將繼續保持吸附在 parent 的頂部
if (sugTop<=top){
top = sugTop
}
var bottom = top + mTvHeight
c.drawRect(left, top.toFloat(), right, bottom.toFloat(),mPaint)
//之前寫的是 bottom/2 改成 top + mTvHeight/2
val baselineY = bottom/2 +(fontMetricsInt.bottom-fontMetricsInt.top)/2-fontMetricsInt.bottom
Log.e(TAG,"onDrawOver-> $left,$top,$right,$bottom")
Log.e(TAG,"onDrawOver基線-> $baselineY")
c.drawText("王", left, baselineY.toFloat(),mTvPaint)
}
}
}
這裡有幾個點需要注意:
1.由於我們這裡只是簡單的對每一個item都設定的header,因為在判斷的時候只判斷了位置為0和不為0。不為0的時候按照正常的情況進行繪製,為0的時候此時我們就要繪製在recyclerView的頂部,此時的位置應該以recycerView為準,區別就是:
var top = parent.paddingTop
然後這裡的baseline應該是val baselineY = bottom/2 +(fontMetricsInt.bottom-fontMetricsInt.top)/2-fontMetricsInt.bottom
因為文字的center就直接是bottom/2,即(top+mTvHeight)/2
2.在實現該效果之後,執行發現有一點小bug,發現頂上去的效果不是很理想,是因為文字的高度沒有計算正確,因為之前考慮的文字的中心位置是bottom/2,在加入吸頂的程式碼之後,因為我們修改了top的值,所以會引起一些小的誤差,在這裡文字中心位置修改為top+mTvHeight/2,然後測試就可以了