RecyclerView#ItemDecoration入門與進階

使用RecyclerView替代ListView已經(jīng)是老生常談的話題了,RecyclerView的優(yōu)秀和靈活已經(jīng)經(jīng)過了大量項目的實踐。最近在完成一個分組列表的需求時,使用到ItemDecoration,故在此對其做一番總結(jié),加深對其的理解。

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允許應用結(jié)合adapter的數(shù)據(jù)集,對特定的item添加繪制一個周邊圖案??梢杂糜诮oitems之間添加分割線、高亮裝飾效果或者分組邊界等等。

從谷歌官方的介紹可以知道,ItemDecoration是用于給列表的item添加各種裝飾效果,開發(fā)中最常見的就是為item添加分割線。
ItemDecoration本身是一個抽象類,拋去廢棄的方法,我們需要關(guān)心的方法只有三個:

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繪制之前時被調(diào)用,將指定的內(nèi)容繪制到item view內(nèi)容之下;
  • onDrawOver:在item被繪制之后調(diào)用,將指定的內(nèi)容繪制到item view內(nèi)容之上
  • getItemOffsets:在每次測量item尺寸時被調(diào)用,將decoration的尺寸計算到item的尺寸中
繪制順序[注1]

ItemDecoration三個方法的測試

谷歌官方在support.v7包中提供了ItemDecoration的一個實現(xiàn)DividerItemDecoration,這里結(jié)合這個實現(xiàn),來看看其三個需要實現(xiàn)的方法對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方法實現(xiàn)了對Orientation == VERTICAL的RecyclerView繪制item之間的分割線。從傳入的canvas參數(shù)可以推斷,分割線的繪制是通過canvas機制繪制到屏幕上:mDivider.draw(canvas);其中,mDivider是一個Drawable對象,可以通過setDrawable傳入自定義對象,不傳入時,會自動使用系統(tǒng)內(nèi)置的分割線樣式:android.R.attr.listDivider。通過遍歷每一個可見的child view,計算mDivider對應的left、top、right、bottom值,從而繪制到正確的位置上。對于縱向的RecyclerView而言,mDivider的left和right是固定的,和parent的左右內(nèi)容邊界保持一致,也就是說,把parent的左右padding都計算進去,因而是代表了RecyclerView實際的內(nèi)容區(qū)域??v向的分割線一般位于每個item的底部,因此mDivider的top值理論上應該和child view的內(nèi)容下邊界保持貼合。實際上,計算top和bottom的代碼,谷歌官方也有所調(diào)整,在最新的實現(xiàn)中,先通過parent.getDecoratedBoundsWithMargins(child, mBounds);拿到之前在onMeasure過程中,通過調(diào)用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);
        }
    }

官方實現(xiàn)的getItemOffsets比較簡單,只是根據(jù)列表的方向,返回了分割線在相應方向的尺寸。這里可能有一個坑,即通過setDrawable設(shè)置自定義的分割線時,容易傳入一個無尺寸的drawable對象,導致分割線無法顯示出來的bug,典型的代碼是這樣:
decoration.setDrawable(new ColorDrawable(Color.RED));

DividerItemDecoration的實現(xiàn)中,是沒有復寫onDrawOver方法的,對于分割線場景而言,也確實不需要去實現(xiàn)它。接下來,通過幾個例子,展示一下getItemOffsets對于ItemDecoration在UI上的影響。

getItemOffsets & onDraw

先上動圖【注2】:


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

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

上圖中,getItemOffsets方法里,返回outRect不同,而onDraw方法繪制的分割線高度初始值設(shè)為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);
            }
        }

從上面兩個動圖對比,可以得出以下幾個結(jié)論:

  • getItemOffsets返回的矩形outRect會被計算到child view的尺寸當中;
  • onDraw方法繪制的圖形,可以超出outRect所規(guī)定的區(qū)域;
  • onDraw方法繪制的圖形,確實是處于child view的底下,當兩者發(fā)生重疊時,只會顯示child view的內(nèi)容;

getItemOffsets & onDrawOver

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

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

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

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

利用ItemDecoration實現(xiàn)分組列表效果

先看效果圖:

hoverGroup.gif

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

  1. 分組欄位置一般是由外部決定,常見是根據(jù)數(shù)據(jù)源list中某個特征值來決定,比較好的做法是通過接口來實現(xiàn)。
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繪制的內(nèi)容永遠會顯示在最頂層,因此,實際上是,每一個頂部item都繪制了一個Decoration,但是相同分組的Decoration內(nèi)容和位置一摸一樣,就導致看上去是一直吸頂?shù)男Ч?。部分代碼如下:
#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,需要外部傳入相關(guān)的view的資源id和需要綁定的數(shù)據(jù)List,控件內(nèi)部會通過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錄制,前者將手機屏幕內(nèi)容投射到電腦上,后者錄制git圖片。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容