1. 程式人生 > >小甜點,RecyclerView 之 ItemDecoration 講解及高階特性實踐

小甜點,RecyclerView 之 ItemDecoration 講解及高階特性實踐

本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家釋出

毫無疑問,RecyclerView 是現在 Android 世界中最重要的系統元件之一,它的出現就是為了高效代替 ListView 和 GridView。當時它的出現解決了我一個大的需求,這個需求就是在電視盒子介面上橫向載入應用列表,由於 ListView 沒有橫向載入的功能,而網路上開源的那些 HorizontalListView 又不滿足需求,所以我們只能自定義 ViewGroup 來實現需求,但是回收機制不是很完善,所以效能並不好,所以當 RecyclerView 橫空出世時,我第一時間擁抱了它,並推薦 Android 開發小組成員們去了解它。

但後來,我發現 RecyclerView 除了比 ListView 好用外,某些地方它卻更復雜了,它將更多的權力交給了開發者自己,比如佈局,比如 ITEM 的分割線,比如點選監聽等等。但總歸它是好東西,所以我們得多花些時間來學習,平常開發我們一般按照 RecyclerView 的基本用法便可以實現絕大多數需求,但是某些場景下卻遠遠不夠,比如我們不想侷限於 LinearLayoutManager 想自己定義 LayoutManager,我們需要定義時光軸的效果,我們想實現美妙的新增刪除動畫等等,這些情況下解決問題的話需要我們對 RecyclerView 本身有足夠的瞭解。

今天,這篇文章不講 RecyclerView 基本的知識和用法,講它一個有趣的知識點 ItemDecoration。

ItemDecoration

Decoration 的英文意思是裝飾物的意思,引申到這裡來,肯定也是與 RecyclerView 的介面裝飾有關。我們常見的就是分割線了。
我們在使用 ListView 的時候只要在 xml 檔案中,使用 android:divider 就可以,但是很遺憾 RecyclerView 卻沒有相應的控制。

我們新建一個工程,然後在一個頁面裡面新增一個 RecyclerView。建立相關的 Adapter,載入佈局檔案,這裡佈局檔案很簡單,就是一個 TextView,再之後在 Activity 初始化它。

public class DividerActivity
extends AppCompatActivity {
RecyclerView mRecyclerView; List<String> data; TestAdapter mAdapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_divider); mRecyclerView = (RecyclerView) findViewById(R.id.divider_recyclerview); initDatas(); mAdapter = new TestAdapter(data); mRecyclerView.setAdapter(mAdapter); LinearLayoutManager layoutmanager = new LinearLayoutManager(this); layoutmanager.setOrientation(LinearLayoutManager.VERTICAL); mRecyclerView.setLayoutManager(layoutmanager); } private void initDatas() { data = new ArrayList<>(); for (int i = 0; i < 56;i++) { data.add(i+" test "); } } }

這裡寫圖片描述

可以看到所有的選項都混在一起,為了美觀應該需要 1 px 的分割線,之前我一般在 Item 的佈局檔案中設定它的 topMargin 或者是 bottomMargin,所以我們可以在相關的 Adapter 中這樣修改。

public TestHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.layout_item,parent,false);
        RecyclerView.LayoutParams layoutParams = (RecyclerView.LayoutParams) view.getLayoutParams();
        layoutParams.topMargin = 1;
        view.setLayoutParams(layoutParams);
        TestHolder holder = new TestHolder(view);
        return holder;
}

效果如下:

這裡寫圖片描述

現在我們同樣可以通過給 RecyclerView 新增 ItemDecoration 來實現它。

首先,我們需要自定義一個 ItemDecoration,按照目前的需求,我們只需要實現它的一個方法就可以了。

public class TestDividerItemDecoration extends RecyclerView.ItemDecoration {

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);

//        //如果不是第一個,則設定top的值。
        if (parent.getChildAdapterPosition(view) != 0){
            //這裡直接硬編碼為1px
            outRect.top = 1;
        }
    }
}

然後在 Activity 中新增它到 RecyclerView 就可以了。

mRecyclerView = (RecyclerView) findViewById(R.id.divider_recyclerview);
initDatas();
mAdapter = new TestAdapter(data);
mRecyclerView.setAdapter(mAdapter);
LinearLayoutManager layoutmanager = new LinearLayoutManager(this);
layoutmanager.setOrientation(LinearLayoutManager.VERTICAL);
mRecyclerView.setLayoutManager(layoutmanager);
mRecyclerView.addItemDecoration(new TestDividerItemDecoration());

效果如圖:

這裡寫圖片描述

getItemOffsets()

我們可以看到自定義的 TestDividerItemDeoration 只實現了一個方法 getItemOffsets()。方法裡面有四個引數。

  • Rect outRect
  • View view
  • RecyclerView parent
  • RecyclerView.State state

這四個引數分別幹什麼的呢?我們不妨在 AndroidStudio 中按 Ctrl 鍵點選方法名,就可以到了它被呼叫的位置。

Rect getItemDecorInsetsForChild(View child) {
    final LayoutParams lp = (LayoutParams) child.getLayoutParams();
    if (!lp.mInsetsDirty) {
        return lp.mDecorInsets;
    }

    if (mState.isPreLayout() && (lp.isItemChanged() || lp.isViewInvalid())) {
        // changed/invalid items should not be updated until they are rebound.
        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;
}

我們注意這一行程式碼 java mItemDecorations.get(i).getItemOffsets(mTempRect, child, this, mState); 很容易得知,outRect 是一個全為 0 的 Rect。view 指 RecyclerView 中的 Item。parent 就是 RecyclerView 本身,state 就是一個狀態。

我們可以看下面的這張圖。

這裡寫圖片描述

綠色區域代表 RecyclerView 中的一個 ItemView,而外面橙色區域也就是相應的 outRect,也就是 ItemView 與其它元件的偏移區域,等同於 margin 屬性,通過複寫 getItemOffsets() 方法,然後指定 outRect 中的 top、left、right、bottom 就可以控制各個方向的間隔了。注意的是這些屬性都是偏移量,是指偏移 ItemView 各個方向的數值。在上面的例子中我設定了 outRect.top = 1; 所以每個 ItemView 之間有 1 px 的空隙,而這 1 px 空隙透露了下面背景色,所以看起來就像是分隔線,這實現了簡單的分隔線效果,但這種方法分隔線的效果只能取決於背景色,如果我要定製分割線的顏色呢?這個時候就要講到一個新的方法名 onDraw()。

onDraw()

在 Android 中的每一個 View 中 onDraw() 是很重要的一個方法,用來繪製元件的UI效果,所以在 ItemDecocration 中它自然也是用來繪製外觀的。我們來看它的方法宣告。
java public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state);

可以看到它傳遞了一個 Canvas 引數物件,所以它擁有了繪製的能力。但是怎麼繪製呢?

其實它是配合了前面的 getItemOffsets 方法一起使用的,getItemOffsets 撐開了 ItemView 的上下左右間隔區域,而 onDraw 方法通過計算每個 ItemView 的座標位置與它的 outRect 值來確定它要繪製內容的區間。

假設,我們要設計一個高度為 2 px 的紅色分割線,那麼我們就需要在每個 ItemView top位置上方畫一個 2 px 高度的矩形,然後填充顏色為紅色。
需要注意的一點是 getItemOffsets 是針對每一個 ItemView,而 onDraw 方法卻是針對 RecyclerView 本身,所以在 onDraw 方法中需要遍歷螢幕上可見的 ItemView,分別獲取它們的位置資訊,然後分別的繪製對應的分割線。

我們看下面的這張示意圖
這裡寫圖片描述

為了便於觀察我將第一條分割線的顏色透明化了,我們可以看到每條分割線繪製的區域其實就是 outRect.top 至 ItemView.top 之間的區域,所以我們就需要在當初 getOffsets 方法進行位置偏移時就記錄下每個 itemView 向上的間隔距離,之後的邏輯就是遍歷螢幕上的 View,然後描繪分割線。

public class ColorDividerItemDecoration extends RecyclerView.ItemDecoration {

    private float mDividerHeight;

    private Paint mPaint;

    public ColorDividerItemDecoration() {
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(Color.RED);
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);

//        //第一個ItemView不需要在上面繪製分割線
        if (parent.getChildAdapterPosition(view) != 0){
            //這裡直接硬編碼為1px
            outRect.top = 1;
            mDividerHeight = 1;
        }
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);

        int childCount = parent.getChildCount();

        for ( int i = 0; i < childCount; i++ ) {
            View view = parent.getChildAt(i);

            int index = parent.getChildAdapterPosition(view);
            //第一個ItemView不需要繪製
            if ( index == 0 ) {
                continue;
            }

            float dividerTop = view.getTop() - mDividerHeight;
            float dividerLeft = parent.getPaddingLeft();
            float dividerBottom = view.getTop();
            float dividerRight = parent.getWidth() - parent.getPaddingRight();

            c.drawRect(dividerLeft,dividerTop,dividerRight,dividerBottom,mPaint);
        }
    }
}

然後我們在 Activity 將 ColorDividerItemDecoration 新增到相應的 RecyclerView 中就可以了。

mRecyclerView.addItemDecoration(new ColorDividerItemDecoration());

效果如下圖:
這裡寫圖片描述

至此,紅色的分割線就搞定了。

但一定要注意的是,onDraw 方法可不只能繪製簡單的線條,它可是擁有 Canvas 的,所以畫圓、畫矩形、畫弧形、繪製圖片都不在話下。為了提高本篇程式碼的技術含量,下面我們通過 ItemDecoration 來實現一個時光軸的效果。

通過 ItemDecoration 實現時光軸的效果

編碼的開始先做設計,或者說先思考。思考我們要做什麼,或者說要怎麼做。
這裡寫圖片描述

我們可以看到左邊白色的圖案就大概是我們時光軸要繪製的圖形。我們通過 getItemOffsets 方法來對 ItemView 進行 left 和 top 的間距設定。然後確定好軸線的起始座標,中間軸結點的圖形或者是圖案。我們可以通過 ItemView 將相應的時光軸片斷分解,如下圖。
這裡寫圖片描述

主要是一些引數的確定,例如 DividerHeight,注意這個 DividerHeight 不是指 ItemView 向上的間隔值,而是相應的 ItemDecoration 的高度。中心座標 (centerX,centerY),還有上下兩段軸線的起始座標。有了這些引數後,我們就能輕鬆地編碼了。

public class TimelineItemDecoration extends RecyclerView.ItemDecoration {


    private Paint mPaint;
    //ItemView左邊的間距
    private float mOffsetLeft;
    //ItemView右邊的間距
    private float mOffsetTop;
    //時間軸結點的半徑
    private float mNodeRadius;

    public TimelineItemDecoration(Context context) {
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(Color.RED);

        mOffsetLeft = context.getResources().getDimension(R.dimen.timeline_item_offset_left);
        mNodeRadius = context.getResources().getDimension(R.dimen.timeline_item_node_radius);
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);

//        //第一個ItemView不需要在上面繪製分割線
        if (parent.getChildAdapterPosition(view) != 0){
            //這裡直接硬編碼為1px
            outRect.top = 1;
            mOffsetTop = 1;
        }

        outRect.left = (int) mOffsetLeft;
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);

        int childCount = parent.getChildCount();

        for ( int i = 0; i < childCount; i++ ) {
            View view = parent.getChildAt(i);

            int index = parent.getChildAdapterPosition(view);

            float dividerTop = view.getTop() - mOffsetTop;
            //第一個ItemView 沒有向上方向的間隔
            if ( index == 0 ) {
                dividerTop = view.getTop();
            }

            float dividerLeft = parent.getPaddingLeft();
            float dividerBottom = view.getBottom();
            float dividerRight = parent.getWidth() - parent.getPaddingRight();

            float centerX = dividerLeft + mOffsetLeft / 2;
            float centerY = dividerTop + (dividerBottom - dividerTop) / 2;

            float upLineTopX = centerX;
            float upLineTopY = dividerTop;
            float upLineBottomX = centerX;
            float upLineBottomY = centerY - mNodeRadius;

            //繪製上半部軸線
            c.drawLine(upLineTopX,upLineTopY,upLineBottomX,upLineBottomY,mPaint);

            //繪製時間軸結點
            c.drawCircle(centerX,centerY,mNodeRadius,mPaint);

            float downLineTopX = centerX;
            float downLineTopY = centerY + mNodeRadius;
            float downLineBottomX = centerX;
            float downLineBottomY = dividerBottom;

            //繪製上半部軸線
            c.drawLine(downLineTopX,downLineTopY,downLineBottomX,downLineBottomY,mPaint);
        }
    }
}

然後效果如下圖:

這裡寫圖片描述
感覺不怎麼美觀,我們嘗試將結點的實心圓改成空心圓。

//繪製時間軸結點
mPaint.setStyle(Paint.Style.STROKE);
c.drawCircle(centerX,centerY,mNodeRadius,mPaint);
mPaint.setStyle(Paint.Style.FILL_AND_STROKE);

效果如下:
這裡寫圖片描述

感覺美觀了許多。

同時,我們可以將軸結點用圖示代替圓或者圓圈。
這裡寫圖片描述

上面的圖示感覺更好看了。

需要注意的是 onDraw 方法,ItemDecoration 是在 ItemView 的下方繪製的,也就是 ItemView 可能會覆蓋 ItemDecoration 的內容。我們可以驗證一下,在時間軸與 ItemView 的邊界畫一個完整的圓,觀察它的效果。

     mPaint.setStyle(Paint.Style.STROKE);
    c.drawCircle(view.getLeft(),centerY,mNodeRadius,mPaint);

    mPaint.setStyle(Paint.Style.FILL_AND_STROKE);

這裡寫圖片描述
可以看到,在重合的地方,圓圈確實被 ItemView 內容覆蓋了。

大家可能會想到,ItemDecoration 內容能不能覆蓋在 ItemView 內容之上呢?

答案是肯定的,但不是在 onDraw() 方法實現,而是另外一個方法 onDrawOver()。

onDrawOver 和角標。

現實中的APP或者網站經常有一些排行榜比如下面:

這裡寫圖片描述
或者這樣。

這裡寫圖片描述

這些角標都是繪製在 ItemView 之上的,現在有了 ItemDecoration 我們也可以輕鬆而優雅地實現它。

比如我們要實現一個圖書銷量排行榜。我們有大概的草圖。

這裡寫圖片描述

然後我們就可以編碼了。
佈局檔案:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent" android:layout_height="wrap_content"
    android:background="@android:color/white">

    <TextView
        android:id="@+id/tv_rank_oder"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerVertical="true"
        android:layout_alignParentLeft="true"
        android:layout_marginLeft="24dp"
        android:gravity="center"
        android:textColor="#6c6c6c"/>

    <ImageView
        android:id="@+id/iv_cover"
        android:layout_width="80dp"
        android:layout_height="80dp"
        android:layout_centerVertical="true"
        android:layout_marginLeft="60dp"/>

    <TextView
        android:id="@+id/tv_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_toRightOf="@id/iv_cover"
        android:layout_marginLeft="12dp"
        android:layout_marginTop="12dp"
        android:textColor="@android:color/black"/>
    <TextView
        android:id="@+id/tv_price"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_toRightOf="@id/iv_cover"
        android:layout_below="@id/tv_title"
        android:layout_marginLeft="12dp"
        android:layout_marginTop="12dp"
        android:textColor="@android:color/holo_red_dark"/>
</RelativeLayout>

相應的 Adapter 程式碼:

public class BookRankAdapter extends RecyclerView.Adapter<BookRankAdapter.TestHolder> {

    List<String> data;
    int[] mIconResouces;
    public BookRankAdapter(List<String> data,int[] ids) {
        this.data = data;
        this.mIconResouces = ids;
    }

    public void setData(List<String> data,int[] ids) {
        this.data = data;
        mIconResouces = ids;
        notifyDataSetChanged();
    }

    @Override
    public TestHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.layout_ranklist_item,parent,false);
        TestHolder holder = new TestHolder(view);
        return holder;
    }

    @Override
    public void onBindViewHolder(TestHolder holder, int position) {
        if (data != null && data.size() > 0 ) {
            String text = data.get(position);

            String[] infos = text.split("-");
            holder.tvOrder.setText(position+"");
            holder.tvTitle.setText(infos[0]);
            holder.tvPrice.setText(infos[1]);

            holder.ivCover.setImageResource(mIconResouces[position]);
        }
    }


    @Override
    public int getItemCount() {
        return data == null ? 0 : data.size();
    }

    static class TestHolder extends  RecyclerView.ViewHolder{
        public TextView tvOrder;
        public TextView tvTitle;
        public TextView tvPrice;
        public ImageView ivCover;
        public TestHolder(View itemView) {
            super(itemView);

            tvOrder = (TextView) itemView.findViewById(R.id.tv_rank_oder);
            tvTitle = (TextView) itemView.findViewById(R.id.tv_title);
            tvPrice = (TextView) itemView.findViewById(R.id.tv_price);
            ivCover = (ImageView) itemView.findViewById(R.id.iv_cover);

        }

    }
}

自定義 FlagItemDecoration

public class FlagItemDecoration extends RecyclerView.ItemDecoration {
    private Paint mPaint;
    private Bitmap mIcon;
    private float mFlagLeft;

    public FlagItemDecoration(Context context) {
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(Color.RED);


        mIcon = BitmapFactory.decodeResource(context.getResources(),R.drawable.hotsale);
        mFlagLeft = context.getResources().getDimension(R.dimen.flag_left);
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
      //  super.getItemOffsets(outRect, view, parent, state);

        //第一個ItemView不需要在上面繪製分割線
        if (parent.getChildAdapterPosition(view) == 0){
            outRect.top = 0;
        } else {
            outRect.top = 2;
        }

    }

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);

        int childCount = parent.getChildCount();

        for ( int i = 0; i < childCount; i++ ) {
            View view = parent.getChildAt(i);
            int index = parent.getChildAdapterPosition(view);
            float top = view.getTop();
            if ( index < 3 ) {
                c.drawBitmap(mIcon,mFlagLeft,top,mPaint);
            }

        }
    }
}

然後在 Activity 進行相應的資料處理,這裡的資料都是為了測試用的,所以比較隨意。

public class BookRankActivity extends AppCompatActivity {
    RecyclerView mRecyclerView;
    List<String> data;
    BookRankAdapter mAdapter;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_bookrank);
        mRecyclerView = (RecyclerView) findViewById(R.id.bookrank_recyclerview);


        initDatas();
        int resouces[] = new int[] {R.drawable.book_renmin,R.drawable.book_huochetou,
            R.drawable.book_jieyouzahuodian,R.drawable.book_tensoflow,R.drawable.book_wangyangming
            ,R.drawable.book_renmin,R.drawable.book_huochetou,
                R.drawable.book_jieyouzahuodian,R.drawable.book_tensoflow,R.drawable.book_wangyangming
            ,R.drawable.book_renmin,R.drawable.book_huochetou,
                R.drawable.book_jieyouzahuodian,R.drawable.book_tensoflow,R.drawable.book_wangyangming
        };
        mAdapter = new BookRankAdapter(data,resouces);
        mRecyclerView.setAdapter(mAdapter);
        LinearLayoutManager layoutmanager = new LinearLayoutManager(this);
        layoutmanager.setOrientation(LinearLayoutManager.VERTICAL);
        mRecyclerView.setLayoutManager(layoutmanager);
        mRecyclerView.addItemDecoration(new FlagItemDecoration(this));
    }

    private void initDatas() {
        data = new ArrayList<>();
        data.add("人民的名義- ¥ 33.5");
        data.add("火車頭 - ¥ 27.5");
        data.add("解憂雜貨店- ¥ 19.9");
        data.add("TensorFlow - ¥ 102.5");
        data.add("王陽明心學 - ¥ 60");

        data.add("人民的名義1- ¥ 33.5");
        data.add("火車頭1 - ¥ 27.5");
        data.add("解憂雜貨店1- ¥ 19.9");
        data.add("TensorFlow1 - ¥ 102.5");
        data.add("王陽明心學1 - ¥ 60");

        data.add("人民的名義2 - ¥ 33.5");
        data.add("火車頭2 - ¥ 27.5");
        data.add("解憂雜貨店2- ¥ 19.9");
        data.add("TensorFlow2 - ¥ 102.5");
        data.add("王陽明心學2 - ¥ 60");
    }
}

最終效果如下圖:

這裡寫圖片描述

有人在想,通過 ItemView 中的佈局檔案不就可以完成這樣的操作嗎?是的,確實是可以的,將 Flag 角標定義在每一個 ItemView 佈局檔案中,然後在 Adapter 的 onBindViewHolder 方法中根據 postion 的值來決定是否載入角標。

但是這裡是為了說明 ItemDecoration 中的 onDrawOver 方法,為了說明它確實能讓 ItemDecoration 影象繪製在 ItemView 內容之上。事實上,ItemDecoration 的妙處還有好多好多。

總結

自定義一個 ItemDecoration 通常要根據需要,複寫它的 3 個方法。
* getItemOffsets 撐開 ItemView 上、下、左、右四個方向的空間
* onDraw 在 ItemView 內容之下繪製圖形
* onDrawOver 在 ItemView 內容之上繪製圖形。

提醒

由於文章篇幅,ItemDecoration 最讓我興奮的內容我需要另寫一篇文章,那就是通過 ItemDecoration 自定義 RecyclerView 中的頭部或者是粘性頭部。相信大家對頭部這個概念比較瞭解,現在通過 ItemDecoration 就可以優雅地實現它,記住優雅兩個字,條條大路通羅馬,但是有人就優雅、有人就顯得手忙腳亂。所以,我將文章標題定了一個詞,叫小甜點,吃了讓人舒心,ItemDecoration 用了也讓人舒心。

好了,文章就到這裡結束。