RecyclerView#ItemDecoration入門與進階

使用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的尺寸中
繪制順序[注1]

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】:


outRect(0,0,0,50).gif

outRect(50,50,50,50).gif

上圖中,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

outRect(50,50,50,50).gif

將之前onDraw方法內代碼完整拷貝到onDrawOver下,并注釋掉之前onDraw中的方法,很容易驗證出onDrawOver與onDraw的唯一不同之處。

  • onDrawOver繪制的圖形,處于child view之上,當兩者發生重疊時,會顯示onDrawOver的內容;

ItemDecoration三個方法的含義,就介紹到這里。可以感覺到,三個方法都很簡單而基礎,可以十分優雅的實現item的分割線效果,然而簡單的如DividerItemDecoration,往往是無法滿足項目開發需求的。經常會遇到某幾個item不想要分割線(如頭部或者最后一個item),這就需要開發者自行來實現。

利用ItemDecoration實現分組列表效果

先看效果圖:

hoverGroup.gif

上圖展示了利用ItemDecoration實現分組欄的效果,對于分組效果,需要注意的點在于,如何確定分組欄位置和內容,如何實現分組欄吸頂效果(如果需要)。

  1. 分組欄位置一般是由外部決定,常見是根據數據源list中某個特征值來決定,比較好的做法是通過接口來實現。
public interface IHover {

    /**
     * 當前position是否需要繪制分組欄
     * @param position 當前位置
     * @return true表示需要繪制
     */
    boolean isGroup(int position);


    /**
     * 當前位置需要繪制的文本
     * @param position 當前位置
     * @return String
     */
    String groupText(int position);
}
  1. 分組欄效果實際上是利用了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繪制圖形,不支持點擊事件

感謝:

  1. https://blog.piasy.com/2016/03/26/Insight-Android-RecyclerView-ItemDecoration/
  2. https://github.com/fishyer/PinnedRecyclerView

注1:圖片引用自該文章鏈接
注2:動圖使用Vysor+GifCam錄制,前者將手機屏幕內容投射到電腦上,后者錄制git圖片。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,923評論 6 535
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,740評論 3 420
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,856評論 0 380
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,175評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,931評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,321評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,383評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,533評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,082評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,891評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,067評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,618評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,319評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,732評論 0 27
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,987評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,794評論 3 394
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,076評論 2 375

推薦閱讀更多精彩內容