RecyclerView 粘性(懸浮)頭部
感謝
上圖來自於網路,上圖的列表中有一個懸浮的粘性頭部的效果,現在這種效果的需求比較常見了,像通訊錄,展示城市列表,還有一些諮詢類 App 分類時都會見到這種效果。如果用 ListView 來實現,可謂是十分麻煩,而且查了查相關資料並不多,如果用 RecyclerView 的話,實現這種效果簡直是分分鐘的事。
一、ItemDecoration
要實現這種效果,首先我們需要了解一個 RecyclerView 的內部類 ItemDecoration,之前我在學習 RecyclerView 有記錄過,
ItemDecoration 是一個抽象類,字面意思是 Item 的裝飾,我們可以通過內部的繪製方法繪製裝飾,它有三個需要實現的抽象方法(過時的方法不管):
onDraw() :該方法在 Canvas 上繪製內容作為 RecyclerView 的 Item 的裝飾,會在 Item 繪製之前繪製,也就是說,如果該 Decoration 沒有設定偏移的話,Item 的內容會覆蓋該 Decoration。
onDrawOver() :在 Canvas 上繪製內容作為 RecyclerView 的 Item 的裝飾,會在 Item 繪製之後繪製 ,也就是說,如果該 Decoration 沒有設定偏移的話,該 Decoration 會覆蓋 Item 的內容。
getItemOffsets() :為 Decoration 設定偏移。
首先我們寫一個 Decoration,只是一個簡單的分隔線效果,分隔線中間有文字:
public class StickyDecoration extends RecyclerView.ItemDecoration { private int mHeight; private Paint mPaint; private TextPaint mTextPaint; private Rect mTextBounds; public StickyDecoration() { mHeight = 100; mPaint = new Paint(); mPaint.setAntiAlias(true); mPaint.setColor(Color.GRAY); mTextPaint = new TextPaint(); mTextPaint.setAntiAlias(true); mTextPaint.setColor(Color.parseColor("#FF000000")); mTextPaint.setTextSize(48f); mTextBounds = new Rect(); } /** * Description:在 Canvas 上繪製內容作為 RecyclerView 的 Item 的裝飾,會在 Item 繪製之前繪製 * 也就是說,如果該 Decoration 沒有設定偏移的話,Item 的內容會覆蓋該 Decoration。 * Date:2018/9/14 */ @Override public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { super.onDraw(c, parent, state); //Decoration 的左邊位置 } /** * Description:在 Canvas 上繪製內容作為 RecyclerView 的 Item 的裝飾,會在 Item 繪製之後繪製 * 也就是說,如果該 Decoration 沒有設定偏移的話,該 Decoration 會覆蓋 Item 的內容。 * Date:2018/9/14 */ @Override public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { super.onDrawOver(c, parent, state); String stickyHeaderName = "我是分隔線"; int left = parent.getLeft(); //Decoration 的右邊位置 int right = parent.getRight(); //獲取 RecyclerView 的 Item 數量 int childCount = parent.getChildCount(); for (int i = 0; i < childCount; i++) { View childView = parent.getChildAt(i); //Decoration 的底邊位置 int bottom = childView.getTop(); //Decoration 的頂邊位置 int top = bottom - mHeight; c.drawRect(left, top, right, bottom, mPaint); //繪製文字 mTextPaint.getTextBounds(stickyHeaderName, 0, stickyHeaderName.length(), mTextBounds); c.drawText(stickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint); } } /** * Description:為 Decoration 設定偏移 * Date:2018/9/14 */ @Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { super.getItemOffsets(outRect, view, parent, state); //outRect 相當於 Item 的整體繪製區域,設定 left、top、right、bottom 相當於設定左上右下的內間距 //如設定 outRect.top = 5 則相當於設定 paddingTop 為 5px。 outRect.top = mHeight; } }
效果是這樣:
現在就將這個分隔線一步一步實現成粘性頭部。
二、同一組顯示只顯示一個分隔線
什麼分隔線中的文字是寫死的,所以需要提供給外部一個可以設定每一個分隔線文字的方法,然後在繪製分隔線的時候判斷如果繪製的 position 的分割線的文字與上一個 position 的分隔線的文字一樣的話就不繪製。
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
String previousStickyHeaderName = null;
String currentStickyHeaderName = null;
int left = parent.getLeft();
//Decoration 的右邊位置
int right = parent.getRight();
//獲取 RecyclerView 的 Item 數量
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = parent.getChildAt(i);
//判斷上一個 position 粘性頭部的文字與當前 position 的粘性頭部文字是否相同,如果相同則跳過繪製
int position = parent.getChildAdapterPosition(childView);
currentStickyHeaderName = getStickyHeaderName(position);
if (TextUtils.isEmpty(currentStickyHeaderName)) {
continue;
}
if (position == 0) {
//Decoration 的底邊位置
int bottom = childView.getTop();
//Decoration 的頂邊位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//繪製文字
mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
continue;
}
previousStickyHeaderName = getStickyHeaderName(position - 1);
if (!TextUtils.equals(previousStickyHeaderName, currentStickyHeaderName)) {
//Decoration 的底邊位置
int bottom = childView.getTop();
//Decoration 的頂邊位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//繪製文字
mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
}
}
}
/**
* Description:為 Decoration 設定偏移
* Date:2018/9/14
*/
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
//outRect 相當於 Item 的整體繪製區域,設定 left、top、right、bottom 相當於設定左上右下的內間距
//如設定 outRect.top = 5 則相當於設定 paddingTop 為 5px。
int position = parent.getChildAdapterPosition(view);
String stickyHeaderName = getStickyHeaderName(position);
if (TextUtils.isEmpty(stickyHeaderName)) {
return;
}
if (position == 0) {
outRect.top = mHeight;
return;
}
String previousStickyHeaderName = getStickyHeaderName(position - 1);
if (!TextUtils.equals(stickyHeaderName, previousStickyHeaderName)) {
outRect.top = mHeight;
}
}
/**
* author:MrQinshou
* Description:提供給外部設定每一個 position 的粘性頭部的文字的方法
* date:2018/10/14 22:14
* param
* return
*/
public abstract String getStickyHeaderName(int position);
getItemOffsets() 與 onDrawOver() 中的判斷差不多,都是如果當前位置的 stickyHeaderName 為空,則不預留粘性頭部空間和不繪製粘性頭部,然後如果是第 0 個位置,則直接給空間和繪製,在之後的 position 的 Item,需要拿到上一個 position 的粘性頭部的文字與當前 position 的相比,如果不同才繪製。outRect.top 如果不給值的話,預設是 0。下面測試一下:
MainActivity 中就根據填充的資料來設定一下粘性頭部的文字:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final List<String> list = getList(120);
RecyclerView rvTest = (RecyclerView) findViewById(R.id.rv_test);
TestAdapter testAdapter = new TestAdapter();
rvTest.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false));
rvTest.addItemDecoration(new StickyDecoration() {
@Override
public String getStickyHeaderName(int position) {
return list.get(position);
}
});
rvTest.setAdapter(testAdapter);
testAdapter.setDataList(list);
}
private List<String> getList(int size) {
List<String> list = new ArrayList<>();
for (int i = 0; i < 120; i++) {
if (i < size / 3) {
list.add("力量英雄");
} else if (i < size / 3 * 2) {
list.add("敏捷英雄");
} else {
list.add("智力英雄");
}
}
return list;
}
}
效果是這樣的:
三、同一組的一直顯示
接下來只需要將上面的同一組的頭部一直顯示在頂端,形成粘性效果,直到下一組的頭部滑動上來時,才慢慢替換掉上一個頭部,有一個推動效果。這個其實也很簡單,因為 RecyclerView 在滑動時一直在回撥 onDrawOver() 方法,所以我們該方法中繪製每一個 Item 的粘性頭部時不斷計算 Decoration 的位置,使其不會隨著 Item 的滑動一起往上移動,即一直處於 RecyclerView 的位置。
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
String previousStickyHeaderName = null;
String currentStickyHeaderName = null;
int left = parent.getLeft();
//Decoration 的右邊位置
int right = parent.getRight();
//獲取 RecyclerView 的 Item 數量
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = parent.getChildAt(i);
//判斷上一個 position 粘性頭部的文字與當前 position 的粘性頭部文字是否相同,如果相同則跳過繪製
int position = parent.getChildAdapterPosition(childView);
currentStickyHeaderName = getStickyHeaderName(position);
if (TextUtils.isEmpty(currentStickyHeaderName)) {
continue;
}
if (position == 0 || i == 0) {
//Decoration 的底邊位置
int bottom = Math.max(childView.getTop(), mHeight);
//噹噹前 Decoration 的 Bottom 比下一個 View 的 Decoration 的 Top (即下一個 View 的 getTop() - mHeight)大時
//就應該使當前 Decoration 的 Bottom 等於下一個 Decoration 的 Top,形成推動效果
View nextChildView = parent.getChildAt(i + 1);
String nextStickyHeaderName = getStickyHeaderName(position + 1);
if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
bottom = nextChildView.getTop() - mHeight;
}
//Decoration 的頂邊位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//繪製文字
mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
continue;
}
previousStickyHeaderName = getStickyHeaderName(position - 1);
if (!TextUtils.equals(previousStickyHeaderName, currentStickyHeaderName)) {
//Decoration 的底邊位置
int bottom = Math.max(childView.getTop(), mHeight);
//噹噹前 Decoration 的 Bottom 比下一個 View 的 Decoration 的 Top (即下一個 View 的 getTop() - mHeight)大時
//就應該使當前 Decoration 的 Bottom 等於下一個 Decoration 的 Top,形成推動效果
View nextChildView = parent.getChildAt(i + 1);
String nextStickyHeaderName = getStickyHeaderName(position + 1);
if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
bottom = nextChildView.getTop() - mHeight;
}
//Decoration 的頂邊位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//繪製文字
mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
}
}
}
主要是 bottom 的計算,這樣就完成了粘性頭部的效果,而且下一組不同的頭部到來時,也會有一個推動的過渡效果,不會很生硬:
四、GridLayoutManager
上面的粘性頭部 Decoration 只適用 LinearLayoutManager,而且是垂直方向的 LinearLayoutManager,不過考慮到一般水平方向的 LayoutManager 對粘性頭部的需求很少,所以暫時沒去實現它。如果將上面的粘性頭部 Decoration 用在 GridLayoutManager 上會怎樣呢?
MainActivity 中將 LayoutManager 改為 spanCount 為 4 的 GridLayoutManager:
rvTest.setLayoutManager(new GridLayoutManager(this, 4));
可以看到有分隔線的那一行,除了第一個,其他的都往上”移“了。其實並不是 Item 往上移了,只是在 getItemOffsets() 中只給第一個 Item 設定了偏移,所以我們設定偏移的時候不是隻給 position==0 的 Item 設定了,而要給第一行,即 position<spanCount 的 Item 都設定偏移。在其他比較粘性頭部的文字是否相等的地方也不是和上一個 Item 比較了,而是和上一行的比較,說穿了也就是 position-spanCount 的那一個比較。
在 StickyDecoration 中給一個變數 spanCount,預設為 1,當外部使用的是 GridLayoutManager 時可以傳入 GridLayoutManager 的 spanCount 供我們計算 Decoration 的繪製,修改 onDrawOver() 和 getItemOffsets() 方法:
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
String previousStickyHeaderName = null;
String currentStickyHeaderName = null;
int left = parent.getLeft();
//Decoration 的右邊位置
int right = parent.getRight();
//獲取 RecyclerView 的 Item 數量
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = parent.getChildAt(i);
//判斷上一個 position 粘性頭部的文字與當前 position 的粘性頭部文字是否相同,如果相同則跳過繪製
int position = parent.getChildAdapterPosition(childView);
currentStickyHeaderName = getStickyHeaderName(position);
if (TextUtils.isEmpty(currentStickyHeaderName)) {
continue;
}
if (position < mSpanCount || i < mSpanCount) {
//Decoration 的底邊位置
int bottom = Math.max(childView.getTop(), mHeight);
//噹噹前 Decoration 的 Bottom 比下一個 View 的 Decoration 的 Top (即下一個 View 的 getTop() - mHeight)大時
//就應該使當前 Decoration 的 Bottom 等於下一個 Decoration 的 Top,形成推動效果
View nextChildView = parent.getChildAt(i + mSpanCount);
String nextStickyHeaderName = getStickyHeaderName(position + mSpanCount);
if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
bottom = nextChildView.getTop() - mHeight;
}
//Decoration 的頂邊位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//繪製文字
mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
continue;
}
previousStickyHeaderName = getStickyHeaderName(position - mSpanCount);
if (!TextUtils.equals(previousStickyHeaderName, currentStickyHeaderName)) {
//Decoration 的底邊位置
int bottom = Math.max(childView.getTop(), mHeight);
//噹噹前 Decoration 的 Bottom 比下一個 View 的 Decoration 的 Top (即下一個 View 的 getTop() - mHeight)大時
//就應該使當前 Decoration 的 Bottom 等於下一個 Decoration 的 Top,形成推動效果
View nextChildView = parent.getChildAt(i + mSpanCount);
String nextStickyHeaderName = getStickyHeaderName(position + mSpanCount);
if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
bottom = nextChildView.getTop() - mHeight;
}
//Decoration 的頂邊位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//繪製文字
mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
}
}
}
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
//outRect 相當於 Item 的整體繪製區域,設定 left、top、right、bottom 相當於設定左上右下的內間距
//如設定 outRect.top = 5 則相當於設定 paddingTop 為 5px。
int position = parent.getChildAdapterPosition(view);
String stickyHeaderName = getStickyHeaderName(position);
if (TextUtils.isEmpty(stickyHeaderName)) {
return;
}
if (position < mSpanCount) {
outRect.top = mHeight;
return;
}
String previousStickyHeaderName = getStickyHeaderName(position - mSpanCount);
if (!TextUtils.equals(stickyHeaderName, previousStickyHeaderName)) {
outRect.top = mHeight;
}
}
OK,這樣就可以適用於 GridLayoutManager 了,MainActivity 中修改一下測試程式碼:
rvTest.setLayoutManager(new GridLayoutManager(this, 4));
rvTest.addItemDecoration(new StickyDecoration(4) {
@Override
public String getStickyHeaderName(int position) {
return list.get(position);
}
});
效果如下:
五、遺留問題
這樣其實還有一個問題,在 GridLayoutManager 中上面的測試資料都是每一組資料的個數都是 spanCount 的整數倍,如果不是整數倍的時候就會出現分隔線錯亂,這個情況暫時還沒有想到好的辦法解決,如有好的思路還望不吝賜教。
六、github 傳送門