本文轉自:https://www.jianshu.com/p/26b0911f396f
之前寫過一篇文章《Android開發之仿微博詳情頁(滑動固定頂部欄效果)》,當時采用的解決方案是用一個ScrollView去包裹內容布局,通過監聽滑動狀態,在適當的時候,移入/移出所要固定的布局,這樣雖然可以達到想要的視覺效果,但這種實現方式並不優雅,比如被包裹內容布局中帶有滑動特性的View(ListView,RecyclerView等),這樣做就需要我們額外的去處理滑動沖突,而且這種方式的包裹會使得它們的緩存機制失效,為了一個視覺效果去犧牲它們最具靈動的特性的一面,我不提倡這種做法。
這里介紹另外一種解決方案,可以更加優雅的實現這種視覺效果,而且不會有滑動沖突,也不需要犧牲緩存機制,在文章末尾會給出思路,需要你先看完文章哈~
先來看下今天要實現的效果圖:
要實現這個效果很簡單,只需要一個RecyclerView就可以實現了,不需要多余的布局控件,當然網上也有另外的一些實現方式,比如利用幀布局或者相對布局在RecyclerView上面再蓋上要固定的ViewGroup,通過滑動去判斷是否需要動態的將固定布局移入/移出,其實和上面提到的文章實現思路一樣,這樣做,很明顯的會有幾個缺點,比如增加了布局的深度或者在業務發生變化的時候需要同時去改動至少兩處代碼等,如果中間還耦合着一些業務操作,出錯幾率也會對應的增加。
列表的組成
這是一個帶有分組的列表,我們可以把它拆分成3部分,頭部數據+列表數據+分割線
列表數據:
RecyclerView的基本使用,這里我就不再重復闡述了。
分割線:
要實現分割線,如果是在以前的ListView,我們通過設置divider,dividerHeigh等屬性就可以很輕松的達到目的,或者直接在布局文件中畫上一個帶有高度和背景色的View來實現。到了RecyclerView這里,我們可不再需要這樣做了,官方給我們提供了一個強大的裝飾器ItemDecoration,它可以幫助我們實現分割線的功能,但它可不僅僅只能實現分割線,一會下文會介紹。
頭部數據:
要繪制這個頭部,以前我們在ListView里,可能有些人會這樣做,讓每個Item布局都帶上這個頭部布局,然后根據是否是每組數據的第一個來動態控制頭部布局是顯示還是隱藏,當然這樣做也可以實現我們想要的效果,但卻會多余的去耗費一定的性能,因為明明每組數據只需要繪制一個頭部,而你卻每個Item都去繪制,最終每組卻又只需要一個,所以這里我們依然可以采用官方提供的ItemDecoration來解決這個問題。
什么是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允許給特定的item視圖添加特性的繪制以及布局間隔。它可以用來實現item之間的分割線,高亮,分組邊界等。
public class ItemDecoration extends RecyclerView.ItemDecoration { @Override public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { super.onDraw(c, parent, state); } @Override public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { super.onDrawOver(c, parent, state); } @Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { super.getItemOffsets(outRect, view, parent, state); } }
ItemDecoration是RecyclerView下的抽象方法,我們要使用它只需要繼承它,並實現對應的方法即可,然后讓RecyclerView去調用它:
mRecyclerView.addItemDecoration(new ItemDecoration());
具體來看下這3個方法,順便來一張圖幫助理解:
getItemOffsets:它是用來給Item設置間距的,可以這樣理解在Item外還有一層布局,而這個方法是用來設置布局的Padding。
onDraw:它的繪制和Item之下,它繪制的東西會在Item的下面。
onDrawOver:它的繪制在Item之上,它繪制的東西會覆蓋在Item的上面。
事實上並不是真的有層次之分,這里只是為了方便理解,最根本的原因是因為它們方法的調用方法的順序,又因為都作用於同一個Canvas上,才出現這種覆蓋的層次的效果。
知道了這些方法的作用后,我們配合RecyclerView給我們的一些API方法,要做其它事情容易多了,隨意舉2個例子:
1、如果我們想要繪制分割線,只需要先調用getItemOffsets,讓Item空出一定的間隙,然后再調用onDraw在這個間隙上填充顏色即可。
2、我們經常會遇到一些節假日活動的需求,需要在列表上的邊角處標記“活動”,“特價”等特殊符號,這時候我們只需要調用onDrawOver在Item上繪制即可。
言歸正傳,我們來看下今天我們要實現的效果,帶有吸頂效果的分組列表,上文已經提及了可以分為3部分來看,頭部數據+列表數據+分割線,其中列表數據是最基礎的RecyclerView的使用,這個我們就不說了,我們來看下其它2部分。
為了測試方便,這里我建立了一些本地數據:
數據實體:
public class Bean { private String text; private String groupName; public Bean(String text, String groupName) { this.text = text; this.groupName = groupName; } public String getText() { return text; } public void setText(String text) { this.text = text; } public String getGroupName() { return groupName; } public void setGroupName(String groupName) { this.groupName = groupName; } }
數據集合:
List<Bean> beanList = new ArrayList<>(); for (int i = 0; i < 6; i++) { beanList.add(new Bean(String.format("第一組%d號", i + 1), "第一組")); } for (int i = 0; i < 6; i++) { beanList.add(new Bean(String.format("第二組%d號", i + 1), "第二組")); } for (int i = 0; i < 6; i++) { beanList.add(new Bean(String.format("第三組%d號", i + 1), "第三組")); } for (int i = 0; i < 6; i++) { beanList.add(new Bean(String.format("第四組%d號", i + 1), "第四組")); }
分割線的實現:
首先我們需要在getItemOffsets方法中讓Item間空出空隙:
@Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { outRect.bottom = 1; }
然后我們在onDraw方法中去對這個空隙繪制顏色(繪制一個帶有顏色矩形)
@Override public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { int count = parent.getChildCount(); for (int i = 0; i < count; i++) { View view = parent.getChildAt(i); c.drawRect(0, view.getBottom(), parent.getWidth(), view.getBottom() + 1, mLinePaint); } }
這里需要注意的一個地方是RecyclerView的getChildCount方法只會拿到當前可視區域的Item項,然后我們對Item進行遍歷繪制矩形(分割線)。
就這么簡單,我們的分割線已經畫好了,看下實現效果:
頭部布局的實現:
頭布局的實現和分割線是一樣的,它一樣需要讓Item空出空隙,然后填充顏色,只是空出的空隙距離和顏色不一樣罷了,所以我們需要知道什么時候空出的分割線的空隙,什么時候空出頭部布局的空隙,這個就和我們數據源有關系了,我們寫一個方法來判斷當前position所對應的Item項是不是每組數據的第一項:
/** * 判斷position對應的Item是否是組的第一項 * * @param position * @return */ public boolean isItemHeader(int position) { if (position == 0) { return true; } else { String lastGroupName = mList.get(position - 1).getGroupName(); String currentGroupName = mList.get(position).getGroupName(); //判斷上一個數據的組別和下一個數據的組別是否一致,如果不一致則是不同組,也就是為第一項(頭部) if (lastGroupName.equals(currentGroupName)) { return false; } else { return true; } } }
然后來看下getItemOffsets方法,如果是每組第一項我們空出頭部布局的高度,如果不是,我們則空出分割線的高度:
/** * 設置Item的間距 * * @param outRect * @param view * @param parent * @param state */ @Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { if (parent.getAdapter() instanceof RecyclerViewAdapter) { RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter(); int position = parent.getChildLayoutPosition(view); boolean isHeader = adapter.isItemHeader(position); if (isHeader) { outRect.top = mItemHeaderHeight; } else { outRect.top = 1; } } }
然后一樣的在onDraw方法里繪制背景顏色和文字即可,關於繪制的知識點這邊就不說了,屬於基礎的自定義View需要掌握的知識:
/** * 繪制Item的分割線和組頭 * * @param c * @param parent * @param state */ @Override public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { if (parent.getAdapter() instanceof RecyclerViewAdapter) { RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter(); int count = parent.getChildCount(); for (int i = 0; i < count; i++) { View view = parent.getChildAt(i); int position = parent.getChildLayoutPosition(view); boolean isHeader = adapter.isItemHeader(position); if (isHeader) { c.drawRect(0, view.getTop() - mItemHeaderHeight, parent.getWidth(), view.getTop(), mItemHeaderPaint); mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect); c.drawText(adapter.getGroupName(position), mTextPaddingLeft, (view.getTop() - mItemHeaderHeight) + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint); } else { c.drawRect(0, view.getTop() - 1, parent.getWidth(), view.getTop(), mLinePaint); } } } }
此時我們的頭部布局也畫好了,看下實現效果:
吸頂效果的實現:
關於吸頂的效果,其實只要我們理清楚它的流程就會發現其實並不復雜,50行代碼不到就可以把它完成。
首先我們需要知道以下幾點:
1、當我們滑動列表的時候,第一個頭布局是固定在我們的列表頂部的。
2、通過滑動列表,當下一個頭布局和第一個頭布局相碰的時候,會把第一個布局“頂出去”,當第一個頭布局完全被“頂出去”后,第二個頭布局並替代了第一個頭布局固定在列表頂部。
知道了上面2點后,有時候我們所看到的視覺效果會把我們帶入一個思維誤區,比如這個吸頂效果,有的朋友可能會這樣去考慮,是不是需要在滑動的時候,動態的去改變getItemOffsets的空隙大小和在onDraw的繪制高度。如果真的這樣去做,你會發現實現起來十分困難。
我們換一種思維,既然頂部的布局是固定不動的,是不是可以利用onDrawOver在RecyclerView的上繪制一個和頭部布局一模一樣的布局呢,讓它覆蓋住了第一個頭布局,在視覺上我們是不會有所察覺的,然后當列表滑動的時候,其實“原來的頭布局”早已經滑動走了,留下的其實是我們繪制的固定布局而已,等到下一個頭部布局“碰頭”的時候,讓它隨着滑動的速度慢慢改變布局的高度,當布局高度為0的時候,也就是被頂出去的時候,然后再讓高度改變回來,覆蓋住第二個布局,然后不斷重復以上步驟。
可能說的有點抽象,我們來一張圖看一下,這次我故意把頭布局顏色改成紅色,不清楚的朋友多看幾次就可以理解了。
看下具體代碼,我們先通過findFirstVisibleItemPosition拿到第一個可見的Item的position,那我們就可以根據position+1可以知道下一個Item是否是另一組的頭布局(判斷組名是否發生了變化),如果不是,我們的依舊繪制固定布局即可,如果是,我們根據第一個可見Item的getBottom值的變小,慢慢的改變固定布局的高度,直到被“頂出去”。
/** * 繪制Item的頂部布局(吸頂效果) * * @param c * @param parent * @param state */ @Override public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { if (parent.getAdapter() instanceof RecyclerViewAdapter) { RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter(); int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition(); View view = parent.findViewHolderForAdapterPosition(position).itemView; boolean isHeader = adapter.isItemHeader(position + 1); if (isHeader) { int bottom = Math.min(mItemHeaderHeight, view.getBottom()); c.drawRect(0, view.getTop() - mItemHeaderHeight, parent.getWidth(), bottom, mItemHeaderPaint); mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect); c.drawText(adapter.getGroupName(position), mTextPaddingLeft, mItemHeaderHeight / 2 + mTextRect.height() / 2 - (mItemHeaderHeight - bottom), mTextPaint); } else { c.drawRect(0, 0, parent.getWidth(), mItemHeaderHeight, mItemHeaderPaint); mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect); c.drawText(adapter.getGroupName(position), mTextPaddingLeft, mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint); } } }
吸頂效果就這么簡單的完成了,其實關鍵就在於onDrawOver這個方法。
這里附上完整的ItemDecoration代碼(避免太多參數增加代碼閱讀難度,上面的講解沒有考慮RecyclerView存在Padding的情況,這邊已給出補充):
package com.lcw.view.stickheaderview; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Rect; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.view.View; /** * 自定義裝飾器(實現分組+吸頂效果) * Create by: chenWei.li * Date: 2018/11/2 * Time: 上午1:14 * Email: lichenwei.me@foxmail.com */ public class StickHeaderDecoration extends RecyclerView.ItemDecoration { //頭部的高 private int mItemHeaderHeight; private int mTextPaddingLeft; //畫筆,繪制頭部和分割線 private Paint mItemHeaderPaint; private Paint mTextPaint; private Paint mLinePaint; private Rect mTextRect; public StickHeaderDecoration(Context context) { mItemHeaderHeight = dp2px(context, 40); mTextPaddingLeft = dp2px(context, 6); mTextRect = new Rect(); mItemHeaderPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mItemHeaderPaint.setColor(Color.BLUE); mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mTextPaint.setTextSize(46); mTextPaint.setColor(Color.WHITE); mLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mLinePaint.setColor(Color.GRAY); } /** * 繪制Item的分割線和組頭 * * @param c * @param parent * @param state */ @Override public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { if (parent.getAdapter() instanceof RecyclerViewAdapter) { RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter(); int count = parent.getChildCount();//獲取可見范圍內Item的總數 for (int i = 0; i < count; i++) { View view = parent.getChildAt(i); int position = parent.getChildLayoutPosition(view); boolean isHeader = adapter.isItemHeader(position); int left = parent.getPaddingLeft(); int right = parent.getWidth() - parent.getPaddingRight(); if (isHeader) { c.drawRect(left, view.getTop() - mItemHeaderHeight, right, view.getTop(), mItemHeaderPaint); mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect); c.drawText(adapter.getGroupName(position), left + mTextPaddingLeft, (view.getTop() - mItemHeaderHeight) + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint); } else { c.drawRect(left, view.getTop() - 1, right, view.getTop(), mLinePaint); } } } } /** * 繪制Item的頂部布局(吸頂效果) * * @param c * @param parent * @param state */ @Override public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { if (parent.getAdapter() instanceof RecyclerViewAdapter) { RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter(); int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition(); View view = parent.findViewHolderForAdapterPosition(position).itemView; boolean isHeader = adapter.isItemHeader(position + 1); int top = parent.getPaddingTop(); int left = parent.getPaddingLeft(); int right = parent.getWidth() - parent.getPaddingRight(); if (isHeader) { int bottom = Math.min(mItemHeaderHeight, view.getBottom()); c.drawRect(left, top + view.getTop() - mItemHeaderHeight, right, top + bottom, mItemHeaderPaint); mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect); c.drawText(adapter.getGroupName(position), left + mTextPaddingLeft, top + mItemHeaderHeight / 2 + mTextRect.height() / 2 - (mItemHeaderHeight - bottom), mTextPaint); } else { c.drawRect(left, top, right, top + mItemHeaderHeight, mItemHeaderPaint); mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect); c.drawText(adapter.getGroupName(position), left + mTextPaddingLeft, top + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint); } c.save(); } } /** * 設置Item的間距 * * @param outRect * @param view * @param parent * @param state */ @Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { if (parent.getAdapter() instanceof RecyclerViewAdapter) { RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter(); int position = parent.getChildLayoutPosition(view); boolean isHeader = adapter.isItemHeader(position); if (isHeader) { outRect.top = mItemHeaderHeight; } else { outRect.top = 1; } } } /** * dp轉換成px */ private int dp2px(Context context, float dpValue) { float scale = context.getResources().getDisplayMetrics().density; return (int) (dpValue * scale + 0.5f); } }
<END>