RecyclerView
已經(jīng)成為在Android Native開發(fā)過程中的明星組件旨袒,出鏡率超高练对,只要需要列表展示的內(nèi)容延旧,我們第一想到的就是使用RecyclerView
。RecyclerView
確實是一個很容易上手功能又很強大的組件噪舀,通過設(shè)置不同的LayoutManager
就可以實現(xiàn)不同的顯示樣式列表夕春、網(wǎng)格等丈攒。在日常的開發(fā)過程中我經(jīng)常會遇到“吸頂”這種情況褥实,就是列表中的某些Item在滾動到列表的頂部的時候需要固定住,如上圖的效果等曼。要實現(xiàn)這種效果的兩種最常見的方案是使用ItemDecoration
和組合布局的方式里烦,這兩種方案分別有個字的優(yōu)缺點這里我們簡單的分析一下。
1. 使用組合布局
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rlv"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!--這是要吸頂ViewHolder的布局-->
<include layout="@layout/section_item_layout" />
</FrameLayout>
大體實現(xiàn)方案如上所示禁谦,將要吸頂?shù)?code>ViewHolder(為方便后面的描述我們這里把顯示在RecyclerView中的ViewHolder
叫真ViewHolder
胁黑,飄在RecyclerView
上面的叫假ViewHolder)的布局放在RecyclerView
布局上層,在業(yè)務(wù)層的代碼中通過監(jiān)聽RecyclerView的滾動事件州泊,控制假ViewHolder
的顯示丧蘸、隱藏以及移動等,目前市面上大部分App使用的都是這種方案(我是怎么知道的遥皂?用AS的ViewTree工具分析一下就知道了??)力喷,但是這種方案存在以下缺點:
- 如果有多種不同的
ViewHolder
需要吸頂?shù)臅r候,業(yè)務(wù)處理的復(fù)雜度會呈幾何級數(shù)上升演训,這會導(dǎo)致bug層出不窮弟孟。 - 吸頂?shù)?code>ViewHolder如果是可交互的(例如響應(yīng)橫向滾動,選中等)就需要做真假
ViewHolder
的數(shù)據(jù)和狀態(tài)的雙向同步工作样悟,如果吸頂?shù)?code>ViewHolder業(yè)務(wù)比較復(fù)雜拂募,這一定是一個讓人心力憔悴的活庭猩。 - 擴展能力弱,相似的功能復(fù)用成本很高陈症,總是要修修補補才能復(fù)用蔼水。
也許你會問,如果真如你所說有這么多問題爬凑,那為什么還有這么多人使用這種方案徙缴?呵呵试伙,因為簡單啊嘁信,這個方案是最容易想到的不是嗎?說實話這個方案我也用過疏叨,否則我咋知道會有這么多問題??潘靖。
2. 使用ItemDecoration
class II extends RecyclerView.ItemDecoration{
@Override
public void getItemOffsets(@NonNull Rect outRect,
@NonNull View view,
@NonNull RecyclerView parent,
@NonNull RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
}
@Override
public void onDrawOver(@NonNull Canvas c,
@NonNull RecyclerView parent,
@NonNull RecyclerView.State state) {
super.onDrawOver(c, parent, state);
}
}
ItemDecoration
通常用來實現(xiàn)RecyclerView
中item的分割線效果,利用其本身的一些特性也能做出吸頂效果來蚤蔓,大體思路如下:
- 通過
ItemDecoration
的getItemOffsets
方法將吸頂區(qū)域空出來 - 通過
View.getDrawingCache()
拿到需要吸頂ViewHolder
的bitmap - 通過
ItemDecoration
的onDrawOver
將吸頂ViewHolder
的bitmap繪制在吸頂區(qū)域中
該方案跟上面的使用組合布局的方案比起來卦溢,通用性要好很多,復(fù)用起來也比較方便秀又,但是該方案也有一個致命的缺點单寂,那就是吸頂?shù)?code>ViewHolder不能響應(yīng)事件,如果需要吸頂?shù)?code>ViewHolder中有動態(tài)的內(nèi)容如Gif或視頻等吐辙,也不能做到很好的兼容宣决。
3. 自定義LayoutManager
除了這兩種方案還有沒有別的方案?答案肯定是有的昏苏,使用LayoutManager
尊沸!對,沒錯贤惯!我肯定我不是第一個想到這個方案的人洼专,稍微對RecyclerView
有點了解的人都會想到這個解決方案,目前我在網(wǎng)上還沒發(fā)現(xiàn)(可能有只是我沒找到)使用LayoutManager解決這個問題的成熟方案孵构。RecyclerView
加LayoutManager
大約有1萬多行代碼屁商,要想從頭讀到尾確實需要費點時間,我覺得其實我們也沒必要從頭讀到尾把所有的技術(shù)細(xì)節(jié)都弄明白颈墅,只要能達(dá)到自己的目的就可以了蜡镶,就拿創(chuàng)建一個自定義LayoutManager
這件事來說我們只需要弄明白RecyclerView
的緩存策略和布局流程,我覺得就可以了精盅,如果你時間和精力充足要把它扒個底朝天那也很棒帽哑,下面我們就簡單分析閱讀下這兩部分的源碼。
真愛生命叹俏,遠(yuǎn)離源碼??
3.1 緩存策略
RecyclerView
的緩存策略一直是RecyclerView
的熱門知識點妻枕,不管你是想斬offer還是吹牛*這個是必備。在RecyclerView
中ViewHolder
復(fù)用相關(guān)的邏輯都封裝在Recycler
中,按照順訊分為四層:
-
mAttachedScrap
和mChangedScrap
有人說這一級緩存是告訴緩存屡谐,我就有點納悶述么,“高速”是咋體現(xiàn)出來的?我是沒看出來愕掏!這四層緩存如果按照適用場景來劃分我覺得會更容易理解
-
mAttachedScrap
-- 當(dāng)前RecyclerView
中已經(jīng)有ViewHolder
填充度秘,RecyclerView
又觸發(fā)onLayoutChildren
的時候,當(dāng)前正在顯示的這部分ViewHolder
會被回收到mAttachedScrap
中饵撑,在layoutChunk
方法中被重新取出剑梳。 -
mChangedScrap
-- 只會被用在預(yù)布局中
mAttachedScrap
和mChangedScrap
只有在onLayoutChildren()
方法調(diào)用的時候才會用到,在滾動的過程中沒用滑潘,只有觸發(fā)requestLayout()
的時候才會調(diào)用垢乙。 -
-
mCachedViews
在滾動過程中滾出屏幕區(qū)域而被回收的
ViewHolder
會被加入到該層緩存,緩存數(shù)量支持自定義默認(rèn)為2语卤,按照先進(jìn)先出的規(guī)則溢出追逮。 -
mViewCacheExtension
用戶自定義緩存
-
mRecyclerPool
該層緩存用于存儲從
mCachedView
緩存中溢出的ViewHolder
。
RecyclerView緩存的訪問順序存取是保持一致的粹舵,回收部分的源碼:
private void scrapOrRecycleView(Recycler recycler, int index, View view) {
final ViewHolder viewHolder = getChildViewHolderInt(view);
if (viewHolder.shouldIgnore()) {
return;
}
if (viewHolder.isInvalid() && !viewHolder.isRemoved()
&& !mRecyclerView.mAdapter.hasStableIds()) {
removeViewAt(index);
//回收到mCachedViews或mRecyclerPool中
recycler.recycleViewHolderInternal(viewHolder);
} else {
detachViewAt(index);
//回收到mAttachedScrap 或 mChangedScrap中
recycler.scrapView(view);
mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
}
緩存復(fù)用最終會調(diào)用到tryGetViewHolderForPositionByDeadline
方法钮孵,這個方法源碼巨長省略不相關(guān)源碼,核心源碼如下:
@NonNull
public View getViewForPosition(int position) {
return getViewForPosition(position, false);
}
View getViewForPosition(int position, boolean dryRun) {
return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView;
}
@Nullable
ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs) {
...
boolean fromScrapOrHiddenOrCache = false;
ViewHolder holder = null;
// 0) If there is a changed scrap, try to find from there
if (mState.isPreLayout()) {
holder = getChangedScrapViewForPosition(position);
fromScrapOrHiddenOrCache = holder != null;
}
// 1) Find by position from scrap/hidden list/cache
if (holder == null) {
//從 1和2級緩存中取
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
if (holder != null) {
...
}
}
if (holder == null) {
...
if (holder == null && mViewCacheExtension != null) {
// We are NOT sending the offsetPosition because LayoutManager does not
// know it.
// 從三級自定義緩存中取
final View view = mViewCacheExtension
.getViewForPositionAndType(this, position, type);
....
}
if (holder == null) { // fallback to pool
if (DEBUG) {
Log.d(TAG, "tryGetViewHolderForPositionByDeadline("
+ position + ") fetching from shared pool");
}
//從四級緩存中取
holder = getRecycledViewPool().getRecycledView(type);
if (holder != null) {
holder.resetInternal();
if (FORCE_INVALIDATE_DISPLAY_LIST) {
invalidateDisplayListInt(holder);
}
}
}
if (holder == null) {
...
//通過Adapter重新創(chuàng)建新的ViewHolder實例
holder = mAdapter.createViewHolder(RecyclerView.this, type);
...
}
}
...
//綁定數(shù)據(jù)相關(guān)邏輯省略
return holder;
}
3.2 布局流程
RecyclerView
的布局分為兩部分非別為初始布局和滾動過程中的布局眼滤,兩者的處理邏輯有所不同巴席。初始布局相關(guān)業(yè)務(wù)邏輯主要由onLayoutChildren()
方法承載,滾動過程中的布局相關(guān)邏輯主要由scrollVerticallyBy()
承載柠偶。其中有一個比較核心的方法是fill()
方法情妖,該方法是ViewHolder
布局的核心方法。
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable) {
// max offset we should set is mFastScroll + available
final int start = layoutState.mAvailable;
//判斷是否產(chǎn)生有效滾動
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
// TODO ugly bug fix. should not happen
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
//檢查時候有需要回收的ViewHolder
recycleByLayoutState(recycler, layoutState);
}
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
if (RecyclerView.VERBOSE_TRACING) {
TraceCompat.beginSection("LLM LayoutChunk");
}
...
//布局ViewHolder
layoutChunk(recycler, state, layoutState, layoutChunkResult);
...
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
recycleByLayoutState(recycler, layoutState);
}
if (stopOnFocusable && layoutChunkResult.mFocusable) {
break;
}
}
if (DEBUG) {
validateChildOrder();
}
return start - layoutState.mAvailable;
注意:
RecyclerView
在滾動布局過程中如果沒有新的ViewHolder
產(chǎn)生的時候是不會掉用fill()
方法的诱担。
3.3 實現(xiàn)方案
有了上面那西基礎(chǔ)做鋪墊我們就可以開始動手寫一個LayoutManager
了毡证,整體思路如下:
- 在
RecyclerView
現(xiàn)有的四層緩存之上,再創(chuàng)建一層緩存蔫仙,用于緩存吸頂?shù)?code>ViewHolder - 篩選出需要吸頂?shù)腣iewHolder加入自定義緩存
- 向上滾動(手指上滑)的過程中料睛,在目標(biāo)
ViewHolder
到達(dá)上邊緣的位置的吸頂位置時候阻止其繼續(xù)滾動,將目標(biāo)ViewHolder
強制繪制在屏幕的上部摇邦,并將其加入吸頂ViewHolder
緩存(止其進(jìn)入RecyclerView
的內(nèi)部回收機制)恤煞。 - 向下滾動(手指下滑)的過程中,在目標(biāo)
ViewHolder
離開吸頂區(qū)域后施籍,將其從吸頂緩存中移除居扒,并將其重新放回到RecyclerView
內(nèi)部的緩存中。
總結(jié)起來就兩句話:吸頂?shù)?code>ViewHolder加到新增的自定義緩存中丑慎,將LinearLayoutManager
排完的ViewHolder
重新排列一下喜喂。
3.3.1 吸頂協(xié)議
整體的開發(fā)思路我們已經(jīng)確定瓤摧,首先我們要解決的問題就是如何將要吸頂?shù)?code>ViewHolder篩選出來呢?這里我的方案是定義一個協(xié)議接口Section
玉吁,通過檢測該ViewHolder
是否實現(xiàn)該接口判斷該ViewHolder
是否需要被吸頂照弥。
/**
* 協(xié)議接口所有實現(xiàn)該接口的`ViewHolder`在滾動的過程中都會被吸頂
* @author Rango on 2020/11/6
*/
public interface Section {
}
public class SectionViewHolder extends RecyclerView.ViewHolder implements Section {
public TextView tv;
public SectionViewHolder(@NonNull View v) {
super(v);
}
}
3.3.2 自定義緩存
因為一次只有一個ViewHolder
吸頂,當(dāng)列表中有多個可以吸頂?shù)?code>ViewHolder的時候进副,在向上滾動的時候新出現(xiàn)的吸頂ViewHolder
會將當(dāng)前正在吸頂?shù)?code>ViewHolder頂上去这揣,我們需要將這些被頂上去的ViewHolder
保存起來(阻止進(jìn)入系統(tǒng)緩存),這樣在向下滾動的時候這些ViewHolder
重新顯示的時候才會保持之前的狀態(tài)影斑,否則會進(jìn)入系統(tǒng)緩存被重新綁定數(shù)據(jù)给赞,導(dǎo)致之前的狀態(tài)丟失。所以我們需要創(chuàng)建一個緩存棧(后進(jìn)先出)用于保存吸頂?shù)?code>ViewHolder鸥昏,在列表向上滾動的過程中塞俱,有符合條件的ViewHolder
出現(xiàn)的時候我們就將其入棧姐帚,在列表向下滾動的過程中如果吸頂ViewHolder
離開吸頂位置的時候我們就將其出棧吏垮。這個緩存棧就是我們新加的自定義緩存,棧頂?shù)?code>ViewHolder就是當(dāng)前吸頂?shù)?code>ViewHolder罐旗,代碼如下:
/**
* 吸頂ViewHolder的緩存
*
* @author Rango on 2020/11/17
*/
public class SectionCache extends Stack<RecyclerView.ViewHolder> {
private Map<Integer, RecyclerView.ViewHolder>
filterMap = new HashMap<>(16, 64);
@Override
public RecyclerView.ViewHolder push(RecyclerView.ViewHolder item) {
if (item == null) {
return null;
}
int position = item.getLayoutPosition();
//避免存在重復(fù)的Value
if (filterMap.containsKey(position)) {
//返回null說明沒有添加成功
return null;
}
filterMap.put(position, item);
return super.push(item);
}
@Override
public synchronized RecyclerView.ViewHolder peek() {
if (size() == 0) {
return null;
}
return super.peek();
}
/**
* 棧頂清理膳汪,在快速滾動的情境下可能會出現(xiàn)一次多個吸頂?shù)腣iewHolder出棧的情況,這個時候需要
* 根據(jù)LayoutPosition清理棧頂九秀,保證棧內(nèi)ViewHolder和列表當(dāng)前的狀態(tài)一致遗嗽。
*
* @param layoutPosition 大于position的內(nèi)容會被清理
*/
public List<RecyclerView.ViewHolder> clearTop(int layoutPosition) {
List<RecyclerView.ViewHolder> removedViewHolders = new LinkedList<>();
Iterator<RecyclerView.ViewHolder> it = iterator();
while (it.hasNext()) {
RecyclerView.ViewHolder top = it.next();
if (top.getLayoutPosition() > layoutPosition) {
it.remove();
filterMap.remove(top.getLayoutPosition());
removedViewHolders.add(top);
}
}
return removedViewHolders;
}
}
3.3.3 過濾ViewHolder
這里我們需要把當(dāng)前正在顯示的目標(biāo)ViewHolder過濾出來并根據(jù)當(dāng)前的dy判斷是否會滾動到吸頂位置,不幸的是LayoutManager并沒有提供獲取ViewHolder的api鼓蜒,只提供了獲取childView()
的方法痹换。查閱源碼發(fā)現(xiàn)ViewHolder中有這樣一個api
childView對應(yīng)的
ViewHolder
會保存在其LayoutParams.mViewHolder
中,通過這個方案我們可以把當(dāng)前正在顯示的ViewHolder
過濾出來都弹。
for (int i = 0; i < getChildCount(); i++) {
View itemView = getChildAt(i);
RecyclerView.ViewHolder vh = getViewHolderByView(itemView);
if (!(vh instanceof Section) || sectionCache.peek() == vh) {
continue;
}
if (dy > 0 && vh.itemView.getTop() < dy) {
sectionCache.push(vh);
} else {
break;
}
}
注意并不是說所有顯示出來的需要吸頂?shù)腣iewHolder都需要立即加入到我們的自定義緩存中娇豫,只有向上滾動到吸頂位置的吸頂ViewHolder加入緩存棧。
假設(shè)A和E是兩個可以吸頂?shù)腣iewHolder畅厢,當(dāng)前屏幕正在向上滾動冯痢,此時A需要加入緩存棧,但是E不需要加入緩存隊列框杜。E只有持續(xù)向上滾動到A所在的位置的時候才會被加入我們自定義的緩存棧浦楣。
3.3.4 攔截
在列表的滾動過程中我們除了要將這些需要吸頂?shù)腣iewHolder加入到我們自定義的緩存棧中,我們還要阻止其進(jìn)入RecylverView的緩存中咪辱,否則列表繼續(xù)向上滾動ViewHolder A就會滾出屏幕振劳,如下圖所示,這個時候ViewHolder A就會被Recycler回收油狂,放入第二層緩存(mCachedViews)中历恐,再有吸頂ViewHolder滾動出來的時候之前回收的RecyclerView就會被復(fù)用和重新綁定數(shù)據(jù)庐杨,之前的ViewHolder A的狀態(tài)就會丟失。
在列表上滑過程中圖三是我們所期望的結(jié)果夹供,ViewHolder A在上滑到頂部的時候我們需要將其固定在RecyclerView的頂部灵份。
RecyclerView
滾動相關(guān)的業(yè)務(wù)邏輯主要是在scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state)
方法中,該方法有三個參數(shù)作用如下:
-
dy
-- 本次滾動的距離哮洽,dy > 0是向上滾動(手指上滑)填渠,反之下滑 -
recycler
-- 緩存器,定義了四層緩存策略 -
state
-- 用于傳遞數(shù)據(jù)信息鸟辅,例如是否是預(yù)布局等
在LinearLayoutManager
中該方法的源碼如下
/**
* {@inheritDoc}
*/
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
RecyclerView.State state) {
if (mOrientation == HORIZONTAL) {
return 0;
}
return scrollBy(dy, recycler, state);
}
這里我們要做的就是在scollBy()之前插入我們的回收代碼氛什,在之后加入我們的重新布局代碼。因為要兼容LienarLayoutManager所以我們不對scrollBy內(nèi)部的內(nèi)容進(jìn)行修改匪凉,這樣我們就可以保證兼容性枪眉。
我們分析RecyclerView四層緩存的時候我們已經(jīng)了解了內(nèi)部實現(xiàn)的一些細(xì)節(jié)問題緩存復(fù)用和滾動處理等等,scrollBy()
是包訪問權(quán)限再层,我們無法對其進(jìn)行重載贸铜,所以我們只能從scrollVerticallyBy()
方法下手了,其實我們也沒必要關(guān)心scrollBy()
方法內(nèi)被
for (RecyclerView.ViewHolder viewHolder : sectionCache) {
removeView(viewHolder.itemView);
}
在scrollBy()
方法調(diào)用之前我們把吸頂?shù)?code>ViewHolder remove掉就可以阻止其進(jìn)入Recycler的緩存中聂受,因為ViewHolder相關(guān)的信息保存在itemView.layoutParams
中蒿秦,移除View就可以阻止其回收。就這么簡單蛋济?對就這么簡單棍鳖!
3.3.5 重新布局
如果現(xiàn)在使用我們自定義的LayoutManager
應(yīng)該是 圖四
這種效果,當(dāng)吸頂ViewHodler
進(jìn)入吸頂位置后就會變成空白碗旅。
我們需要將Remove掉的ViewHolder
重新加回到RecyclerView中并將其布局在合適的位置渡处,這里有幾個關(guān)鍵點需要注意下:
dy
可能大于一個ViewHolder
的高度如果當(dāng)前吸頂位置已經(jīng)有吸頂
ViewHolder
占據(jù)的時候,后來的吸頂ViewHolder
需要將其頂上去在向下滾動(手指下滑)的時候祟辟,由于吸頂?shù)?code>ViewHolder都沒有進(jìn)入
Recycler
的緩存医瘫,所以在向下滾動的時候RecyclerView
會重新創(chuàng)建ViewHolder
實例,我們需要將其替換為我們自定義緩存中保存的實例川尖。
具體實現(xiàn)代碼如下:
//檢查棧頂
RecyclerView.ViewHolder vh = getViewHolderByView(getChildAt(0));
RecyclerView.ViewHolder attachedSection = sectionCache.peek();
if ((vh instanceof Section)
&& attachedSection != null
&& attachedSection.getLayoutPosition() == vh.getLayoutPosition()) {
removeViewAt(0);
}
// 處理向下滾動
for (RecyclerView.ViewHolder removedViewHolder : sectionCache.clearTop(findFirstVisibleItemPosition())) {
Log.i(tag, "移除ViewHolder:" + removedViewHolder.toString());
for (int i = 0; i < getChildCount(); i++) {
RecyclerView.ViewHolder attachedViewHolder = getViewHolderByView(getChildAt(i));
if (removedViewHolder.getLayoutPosition() == attachedViewHolder.getLayoutPosition()) {
View attachedItemView = attachedViewHolder.itemView;
int left = attachedItemView.getLeft();
int top = attachedItemView.getTop();
int bottom = attachedItemView.getBottom();
int right = attachedItemView.getRight();
//這里的remvoe 和 add 是為了重新布局
removeView(attachedItemView);
addView(removedViewHolder.itemView, i);
removedViewHolder.itemView.layout(left, top, right, bottom);
break;
}
}
}
//重新布局
RecyclerView.ViewHolder section = sectionCache.peek();
if (section != null) {
View itemView = section.itemView;
if (!itemView.isAttachedToWindow()) {
addView(itemView);
}
View subItem = getChildAt(1);
if (getViewHolderByView(subItem) instanceof Section) {
int h = itemView.getMeasuredHeight();
int top = Math.min(0, -(h - subItem.getTop()));
int bottom = Math.min(h, subItem.getTop());
itemView.layout(0, top, itemView.getMeasuredWidth(), bottom);
} else {
itemView.layout(0, 0, itemView.getMeasuredWidth(), itemView.getMeasuredHeight());
}
}
每段代碼的作用已經(jīng)用注釋描述登下,這里不再贅述,效果如下:
源碼地址
如有錯誤或意見歡迎在評論區(qū)討論叮喳。
作為一個碼農(nóng)被芳,腦袋偷懶身體受苦 --- 但是領(lǐng)導(dǎo)總是喜歡那些不動腦筋拼命加班的人。馍悟。畔濒。