1. 程式人生 > >利用ItemDecoration實現懸浮頭部

利用ItemDecoration實現懸浮頭部

在我們的日常開發中,RecyclerView已經被使用的越來越廣泛,今天來講一講使用ItemDecoration來實現專案中需要的懸浮頭部的效果。我們使用listView就可以知道,直接從xml檔案中使用 android:divider 這個屬性就可以直接設定listVie中itemw的分割線,可以設定分割線的drawable,但是在recyclerView中卻沒有這個屬性了,有時候為了圖方便,直接在RecyclerView的item裡面通過設定view的方式設定分割線,Google其實並不推薦這種做法的,因為這樣設定了之後,一些notifyItemInsert等這樣的效果就失去了,因為,為了更靈活的定製分割線,Google給我們提供了一個類RecyclerView.ItemDecoration,如果想要實現自定義分割線的話需要去繼承這個類,然後實現他的幾個方法。具體如下:

  1. 如果懶的實現分割線,Google給我們提供了一個預設的分割線,DividerItemDecoration(),裡面兩個引數,一個context,一個是分割線的方向,橫向或者縱向DividerItemDecoration.Vertical或者DividerItemDecoration.Horizantal

  2. 我們點進原始碼就可以看到,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 各個方向的數值。

當然,這個方法只是設定item的偏移量,具體要設定背景什麼的要看下面的幾個方法。

 這裡寫圖片描述

我們知道,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,然後測試就可以了