小甜點,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 用了也讓人舒心。
好了,文章就到這裡結束。