怕什麼真理無窮,進一寸有一寸的歡喜。 ----------胡適
系列文章: Android自定義控件三部曲文章索引: http://blog.csdn.net/harvic880925/article/details/50995268
一、View的回收與複用
1.1 RecyclerView是否會自動回收複用
想必大家都聽說RecyclerView是可以回收複用的,但它會自動複用嗎?我們上面寫的例子會不會複用呢?
1.1.1 如何判斷是否複用
首先,我們需要知道怎麼判斷RecyclerView是不是複用了View。我們知道在Adapter中有兩個函數:
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
…………
}
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
…………
}
其中onCreateViewHolder
會在創建一個新View的時候調用,而onBindViewHolder
會在已經存在View,綁定數據時調用。所以,如果是新創建的View,則會先調用onCreateViewHolder
來創建View,然後調用onBindViewHolder
來綁定數據,如果是複用的View,就只會調用onBindViewHolder
而不會調用onCreateViewHolder
。
1.1.2 對比LinearLayoutManager與CustomLayoutManager
一、LinearLayoutManager回收複用情況
首先,我們在我們Demo中的RecyclerAdatper的onCreateViewHolder
和onBindViewHolder
中添加上日誌:
private int mCreatedHolder=0;
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
mCreatedHolder++;
Log.d("qijian", "onCreateViewHolder num:"+mCreatedHolder);
…………
}
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
Log.d("qijian", "onBindViewHolder");
…………
}
在打日誌的同時,用mCreatedHolder變量標識當前總共創建了多少個View.然後將LayoutManager設置爲LinearLayoutManager:
public class LinearActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_linear);
…………
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
linearLayoutManager.setOrientation(LinearLayoutManager.VERTICAL);
mRecyclerView.setLayoutManager(linearLayoutManager);
…………
}
…………
}
操作步驟如下圖所示:在剛啓動後,然後下滑幾個Item,然後再上滑幾個Item,邊操作邊看日誌情況:
所對應的日誌情況如下:
從日誌中可以看到,在頁面出現時,由於頁面初始化是空白的,所以此時都是通過onCreateViewHolder
來創建View。在滑動之後,會發現,並不會再走onCreateViewHolder
了,只會通過onBindViewHolder
來綁定數據了。這就說明:在初始化時,是創建的View,在創建到一定數量(我手機上是23個)之後,就開始使用回收複用邏輯,把無用的View給複用起來。所以LinearLayoutManager是可以做到回收複用的。
二、CustomLayoutManager回收複用情況
接下來,我們將LinearLayoutManger改爲CustomLayoutManager,來看下在上部分我們寫好了CustomLayoutManager會不會自動回收複用:
public class LinearActivity extends AppCompatActivity {
private ArrayList<String> mDatas = new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_linear);
…………
RecyclerView mRecyclerView = (RecyclerView) findViewById(R.id.linear_recycler_view);
mRecyclerView.setLayoutManager(new CustomLayoutManager());
…………
}
…………
}
同樣的滑動方法,來看下日誌:
可以看到,CustomLayoutManager會在初始化時一次性創建200個View。而在我們滾動時,即不會調用onCreateViewHolder
也不會調用onBindViewHolder
,這是爲什麼呢?
因爲我們總共有200個數據,所以這裏創建了200個View。也就是一次性將所有View創建完成,並加進RecyclerView.
正是因爲所以的ItemView都已經加進RecyclerView了,所以可以實現滾動功能,但並沒有實現回收複用。而且一次性創建所有Item的holderView,極易可能出現ANR。
1.2 RecyclerView的回收複用原理
從上面的對比中可以看出,RecyclerView確實是存在回收複用的,但回收複用是需要我們在自定義的LayoutManager中處理的,而不是會自動具有這個功能,那麼問題來了,我們要怎麼給自定義的LayoutManager添加上回收復用功能呢?
在講解自定義回收複用之前,我們需要先了解RecyclerView是如何處理回收複用的。
1.2.1 簡述RecyclerView的回收複用原理
其實RecyclerView內部已經爲我們實現了回收複用所必備的所有條件,但在LayoutManager中,我們需要寫代碼來標識每個holderView是否繼續可用,還是要把它放在回收池裏面去。很明顯,我們在上面的實例代碼中,我們只是通過layoutDecorated(……)
來佈局Item,而對已經滾出屏幕的HolderView沒有做任何處理,更別說給他們添加已經被移除的標識了。所以我們寫的CustomLayoutManager不能複用HolderView的原因也在這。下面我們來看看RecyclerView給我們已經做好了哪方面準備,我們先來整體理解下RecyclerView的回收複用原理,然後再寫代碼使我們的CustomLayoutManager具有回收複用功能。
1、RecyclerView的回收原則
從上面的講述中,可以知道,我們在自定義的LayoutManager中只需要告訴RecyclerView哪些HolderView已經不用了即可(使用removeAndRecycleView(view, recycler)
函數)。然後RecyclerView中用兩級緩存(mCachedViews和mRecyclerPool)來保存這些已經被廢棄(Removed)的HolderView。這兩個緩存的區別是:mCachedViews是第一級緩存,它的size爲2,只能保存兩個HolderView。這裏保存的始終是最新鮮被移除的HolderView,當mCachedViews滿了以後,會利用先進先出原則,把老的HolderView存放在mRecyclerPool中。在mRecyclerPool中,它的默認size是5。這就是RecyclerView的回收原則。
2、Detach與Scrap
除了回收複用,有些同學在看自定義LayoutManager時,會經常在layoutChildren函數中看到一個函數:detachAndScrapAttachedViews(recycler);
它又是來幹嘛的呢?
試想一種場景,當我們插入了條Item或者刪除了條Item又或者打亂Item順序,怎麼重新佈局這些Item呢?這些情況都涉及到,如何將現有的屏幕上的Item佈局到新位置的問題。最簡單的方法,就是把每個item的HolderView先從屏幕上拿下來,然後再像排列積木一樣,按照最新的位置要求,重新排列。
detachAndScrapAttachedViews(recycler);
的作用就是把當前屏幕上所有的HolderView與屏幕分離,將它們從RecyclerView的佈局中拿下來,然後存放在一個列表中,在重新佈局時,像搭積木一樣,把這些HolderView重新一個個放在新位置上去。將屏幕上的HolderView從RecyclerView的佈局中拿下來後,存放的列表叫mAttachedScrap,它依然是一個List,就是用來保存從RecyclerView的佈局中拿下來的HolderView列表。所以,大家可以查看所有自定義的LayoutManager,detachAndScrapAttachedViews(recycler);
只會被用在onLayoutChildren函數中。就是因爲onLayoutChildren函數是用來佈局新的Item的,只有在佈局時,纔會先把HolderView detach掉然後再add進來重新佈局。但大家需要注意的是mAttachedScrap中存儲的就是新佈局前從RecyclerView中剝離下來的當前在顯示的Item的holderView。這些holderView並不參與回收複用。單純只是爲了先從RecyclerView中拿下來,再重新佈局上去。對於新佈局中沒有用到的HolderView,會從mAttachedScrap移到mCachedViews中,讓它參與複用。
3、RecyclerView的複用原則
至此,已經有了個三個存放RecyclerView的池子:mAttachedScrap、mCachedViews、mRecyclerPool。其實,除了系統提供的這三個池子,RecyclerView也允許我們自己擴展回收池,並給它預留了一個變量:mViewCacheExtension,不過我們一般不會用到,使用系統自帶的回收池即可。
所以,在RecyclerView中,總共有四個池子:mAttachedScrap、mCachedViews、mViewCacheExtension、mRecyclerPool;
其中:
- mAttachedScrap不參與回收複用,只保存從在重新佈局時,從RecyclerView中剝離的當前在顯示的HolderView列表。
- 所以,mCachedViews、mViewCacheExtension、mRecyclerPool組成了回收複用的三級緩存,當RecyclerView要拿一個複用的HolderView時,獲取優先級是mCachedViews > mViewCacheExtension > mRecyclerPool。由於一般而言我們是不會自定義mViewCacheExtension的。所以獲取順序其實就是mCachedViews > mRecyclerPool,在下面的講述中,我也將不再牽涉mViewCacheExtension,大家這裏知道即可。
- 其實,mCachedViews是不參與回收複用的,它的作用就是保存最新被移除的HolderView(通過
removeAndRecycleView(view, recycler)
方法),它的作用是在需要新的HolderView時,精確匹配是不是剛移除的那個,如果是,就直接返回給RecyclerView展示,如果不是它,那麼即使這裏有HolderView實例,也不會返回給RecyclerView,而是到mRecyclerPool中去找一個HolderView實例,返回給RecyclerView,讓它重新綁定數據使用。 - 所以,在mAttachedScrap、mCachedViews中的holderView都是精確匹配的,真正被標識爲廢棄的是存放在mRecyclerPool中的holderView,當我們向RecyclerView申請一個HolderView來使用的時,如果在mAttachedScrap、mCachedViews精確匹配不到,即使他們中有HolderView也不會返回給我們使用,而是會到mRecyclerPool中去拿一個廢棄的HolderView返回給我們。
4、RecyclerView的複用完整過程
上面簡單講解了幾個池子的作用以後,我們再重新看下在RecyclerView需要一個HolderView的過程:
要從RecyclerView中拿到一個HolderView用來佈局,我們一般是使用recycler.getViewForPosition(int position)
,它的意思就是給指定位置獲取一個HolderView實例。recycler.getViewForPosition(int position)
獲取過程就比較有意思,它會先在mAttachedScrap中找,看要的View是不是剛剛剝離的,如果是就直接返回使用,如果不是,先在mCachedViews中查找,因爲在mCachedViews中精確匹配,如果匹配到,就說明這個HolderView是剛剛被移除的,也直接返回,如果匹配不到就會最終到mRecyclerPool找,如果mRecyclerPool有現成的holderView實例,這時候就不再是精確匹配了,只要有現成的holderView實例就返回給我們使用,只有在mRecyclerPool爲空時,纔會調用onCreateViewHolder
新建。
這裏需要注意的是,在mAttachedScrap
和mCachedViews
中拿到的HolderView,因爲都是精確匹配的,所以都是直接使用,不會調用onBindViewHolder重新綁定數據,只有在mRecyclerPool中拿到的HolderView纔會重新綁定數據。正是有mCachedViews的存在,所以只有在RecyclerView來回滾動時,池子的使用效率最高,因爲凡是從mCachedViews中取的HolderView是直接使用的,不需要重新綁定數據。
RecyclerView的回收複用簡要過程就是上面的內容了,過程初理解起來還是比較費勁的,大家需要多讀幾遍。下面我們將通過代碼來講解自定義CustomLayout的回收複用過程。
5、幾個函數
-
public void detachAndScrapAttachedViews(Recycler recycler)
僅用於onLayoutChildren中,在佈局前,將所有在顯示的HolderView從RecyclerView中剝離,將其放在mAttachedScrap中,以供重新佈局時使用 -
View view = recycler.getViewForPosition(position)
用於向RecyclerView申請一個HolderView,至於這個HolderView是從四個池子中的哪個池子裏拿的,我們不需要關心,這些都是recycler.getViewForPosition(position)函數自己判斷的,非常方便有沒有,正是這個函數能爲我們實現複用。 -
removeAndRecycleView(child, recycler)
這個函數僅用於滾動的時候,在滾動時,我們需要把滾出屏幕的HolderView標記爲Removed,這個函數的作用就是把已經不需要的HolderView標記爲Removed。,想必大家在理解了上面的回收複用原理以後,也知道在我們把它標記爲Removed以後,系統做了什麼事了。在我們標記爲Removed以爲,會把這個HolderView移到mCachedViews中,如果mCachedViews已滿,就利用先進先出原則,將mCachedViews中老的holderView移到mRecyclerPool中,然後再把新的HolderView加入到mCachedViews中。
可以看到,正是這三個函數的使用,可以讓我們自定義的LayoutManager具有複用功能。
另外,還有幾個常用,但經常出錯的函數:
-
int getItemCount()
得到的是Adapter中總共有多少數據要顯示,也就是總共有多少個item -
int getChildCount()
得到的是當前RecyclerView在顯示的item的個數,所以這就是getChildCount()
與getItemCount()
的區別 -
View getChildAt(int position)
獲取某個可見位置的View,需要非常注意的是,它的位置索引並不是Adapter中的位置索引,而是當前在屏幕上的位置的索引。也就是說,要獲取當前屏幕上在顯示的第一個item的View,應該用getChidAt(0)
,同樣,如果要得到當前屏幕上在顯示的最後一個item的View,應該用getChildAt(getChildCount()-1)
-
int getPosition(View view)
這個函數用於得到某個View在Adapter中的索引位置,我們經常將它與getChildAt(int position)聯合使用,得到某個當前屏幕上在顯示的View在Adapter中的位置,比如我們要拿到屏幕上在顯示的最後一個View在Adapter中的索引:
View lastView = getChildAt(getChildCount() - 1);
int pos = getPosition(lastView);
1.2.2 CustomLayoutManager實現回收複用的原理
從上面的原理中可以看到,回收複用主要有兩部分:
第一:在onLayoutChildren初始佈局時:
- 使用
detachAndScrapAttachedViews(recycler)
將所有的可見HolderView剝離 - 一屏中能放幾個item就獲取幾個HolderView,撐滿初始化的一屏即可,不要多創建
第二:在scrollVerticallyBy滑動時:
- 先判斷在滾動dy距離後,哪些holderView需要回收,如果需要回收就調用
removeAndRecycleView(child, recycler)
先將它回收。 - 然後向系統獲取HolderView對象來填充滾動出來的空白區域
下面我們就利用這個原理來實現CustomLayoutManager的回收複用功能。
二、 給CustomLayoutManager添加回收復用
2.1 修改onLayoutChildren
上面已經提到,在onLayoutChildren中,我們主要做兩件事:
- 使用
detachAndScrapAttachedViews(recycler)
將所有的可見HolderView剝離 - 一屏中能放幾個item就獲取幾個HolderView,撐滿初始化的一屏即可,不要多創建
關鍵問題在於,我們怎麼知道在初始化時撐滿一屏需要多少個item呢?
在這裏,每個item的高度都是一致的,所以,只需要用RecyclerView的高度除以每個item的高度,就得到了能顯示多少個item了。
所以,此時代碼應該是:
private int mItemWidth,mItemHeight;
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() == 0) {//沒有Item,界面空着吧
detachAndScrapAttachedViews(recycler);
return;
}
detachAndScrapAttachedViews(recycler);
View childView = recycler.getViewForPosition(0);
measureChildWithMargins(childView, 0, 0);
mItemWidth = getDecoratedMeasuredWidth(childView);
mItemHeight = getDecoratedMeasuredHeight(childView);
int visibleCount = getVerticalSpace() / mItemHeight;
…………
}
//其中 getVerticalSpace()在上面已經提到,得到的是RecyclerView用於顯示的高度,它的定義是:
private int getVerticalSpace() {
return getHeight() - getPaddingBottom() - getPaddingTop();
}
接下來對這段代碼進行講解:
首先,做一下容錯處理,在Adapter中沒有數據的時候,直接將當前所有的Item從屏幕上剝離,將當前屏幕清空:
if (getItemCount() == 0) {
detachAndScrapAttachedViews(recycler);
return;
}
然後,就是隨便向系統申請一個HolderView,然後測量它的寬度、高度,並計算可見的Item數:
View childView = recycler.getViewForPosition(0);
measureChildWithMargins(childView, 0, 0);
mItemWidth = getDecoratedMeasuredWidth(childView);
mItemHeight = getDecoratedMeasuredHeight(childView);
int visibleCount = getVerticalSpace() / mItemHeight;
有些同學可能會有疑問,爲什麼要在getDecoratedMeasuredWidth(childView)
前調用measureChildWithMargins(childView, 0, 0)
,因爲我們只有測量過以後,系統才知道它的測量的寬高,如果不測量,系統也是不知道它的寬高的,大家可以嘗試,如果把measureChildWithMargins(childView, 0, 0)
去掉,getDecoratedMeasuredWidth(childView)
得到值就是0;
同時,由於我們每個Item的大小都是固定的,爲了佈局方便,我們在初始化時,利用一個變量來保存在初始化時,在Adapter中每一個item的位置:
int offsetY = 0;
for (int i = 0; i < getItemCount(); i++) {
Rect rect = new Rect(0, offsetY, mItemWidth, offsetY + mItemHeight);
mItemRects.put(i, rect);
offsetY += mItemHeight;
}
注意,這裏使用的是getItemCount(),所以會遍歷Adapter中所有Item,記錄下在初始化時,從上到下的所有Item的位置。
接下來就是改造原來CustomLayoutManager中的佈局代碼,只將可見的Item顯示出來,不可見的就不再佈局。
for (int i = 0; i < visibleCount; i++) {
Rect rect = mItemRects.get(i);
View view = recycler.getViewForPosition(i);
addView(view);
//addView後一定要measure,先measure再layout
measureChildWithMargins(view, 0, 0);
layoutDecorated(view, rect.left, rect.top, rect.right, rect.bottom);
}
mTotalHeight = Math.max(offsetY, getVerticalVisibleHeight());
因爲,在上面我們已經從保存了初始化狀態下,每個Item的位置,所以在初始化時,直接從mItemRects中取出當前要顯示的Item的位置,直接將它擺放在這個位置就可以了。需要注意的是,因爲我們在之前已經使用detachAndScrapAttachedViews(recycler);
將所有view從RecyclerView中剝離,所以,我們需要重新通過addView(view)添加進來。在添加進來以後,需要走一個這個View的測量和layout邏輯,先經過測量,再將它layout到指定位置。如果我們沒有測量直接layout,會什麼都出不來,因爲任何view的layout都是依賴measure出來的位置信息的。
到此,完整的onLayoutChildren的代碼如下:
private int mItemWidth, mItemHeight;
private SparseArray<Rect> mItemRects = new SparseArray<>();;
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() == 0) {//沒有Item,界面空着吧
detachAndScrapAttachedViews(recycler);
return;
}
detachAndScrapAttachedViews(recycler);
//將item的位置存儲起來
View childView = recycler.getViewForPosition(0);
measureChildWithMargins(childView, 0, 0);
mItemWidth = getDecoratedMeasuredWidth(childView);
mItemHeight = getDecoratedMeasuredHeight(childView);
int visibleCount = getVerticalSpace() / mItemHeight;
//定義豎直方向的偏移量
int offsetY = 0;
for (int i = 0; i < getItemCount(); i++) {
Rect rect = new Rect(0, offsetY, mItemWidth, offsetY + mItemHeight);
mItemRects.put(i, rect);
offsetY += mItemHeight;
}
for (int i = 0; i < visibleCount; i++) {
Rect rect = mItemRects.get(i);
View view = recycler.getViewForPosition(i);
addView(view);
//addView後一定要measure,先measure再layout
measureChildWithMargins(view, 0, 0);
layoutDecorated(view, rect.left, rect.top, rect.right, rect.bottom);
}
//如果所有子View的高度和沒有填滿RecyclerView的高度,
// 則將高度設置爲RecyclerView的高度
mTotalHeight = Math.max(offsetY, getVerticalSpace());
}
2.2 處理滾動
接下來,我們就來處理滾動時的情況,根據上面的原理分析,我們知道,我們首先需要回收滾出屏幕的HolderView,然後再填充滾動後的空白區域。因爲向上滾動和向下滾動的dy的值是相反的,當向上滾動時(手指由下往上滑),dy>0;當向下滾動時(手指由上往下滑),dy<0;所以,我們分兩種情況分別處理。
2.2.1 處理向上滾動
在處理滾動時,我們的處理策略是,先假設滾動了dy,然後看需要回收哪些Item,需要新增顯示哪些Item,之後再調用offsetChildrenVertical(-dy)
實現滾動。
因爲在開始移動前,由於我們已經對dy做了到頂/到底判斷並校正了dy的值:
int travel = dy;
//如果滑動到最頂部
if (mSumDy + dy < 0) {
travel = -mSumDy;
} else if (mSumDy + dy > mTotalHeight - getVerticalSpace()) {
//如果滑動到最底部
travel = mTotalHeight - getVerticalSpace() - mSumDy;
}
所以真正移動時,移動距離其實是travel。
1、判斷回收的Item
在判斷要回收哪些越界的Item時,我們需要遍歷當前所有在顯示的item,讓它們模擬移動travel距離後,看是不是還在屏幕範圍內。當travel>0時,說明是從下向上滾動,自然是會將頂部的item移除,所以我們只需要判斷,當前的item是不是超過了上邊界(y=0)即可,代碼如下:
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (travel > 0) {//需要回收當前屏幕,上越界的View
if (getDecoratedBottom(child) - travel< 0) {
removeAndRecycleView(child, recycler);
continue;
}
}
}
- 首先是遍歷所有當前在顯示的item,所以
getChildCount() - 1
就表示當前在顯示的item的最後一個索引。 getDecoratedBottom(child) - travel
表示將這個item上移以後,它的下邊界的位置,當下邊界的位置小於當前的可顯示區域的上邊界(此時爲0)時,就需要將它移除。- 在滾動時,所有移除的View都是使用
removeAndRecycleView(child, recycler)
,千萬不要將它與detachAndScrapAttachedViews(recycler)
搞混了。在滾動時,已經超出邊界的HolderView是需要被回收的,而不是被detach。detach的意思是暫時存放,立馬使用。很顯然,我們這裏在越界之後,立馬使用的可能性不大,所以必須回收。如果立馬使用,它會從mCachedViews中去取。大家也可以簡單的記憶,在onLayoutChildren函數中(佈局時),就使用detachAndScrapAttachedViews(recycler)
,在scrollVerticallyBy函數中(滾動時),就使用removeAndRecycleView(child, recycler)
,當然能理解就更好啦。
2、爲滾動後的空白處填充Item
我們主要看看如何在滾動了travel距離後,需要增加顯示哪些Item的問題,大家先看下面的這張圖:
在這張圖中,綠色框表示屏幕,左邊表示初始化狀態,右邊表示移動了travel後的情況,因爲我們在初始化時,記錄了每個item在初始化的位置,所以我們使用移動屏幕位置的方法來計算當前需要顯示哪些item。
很明顯,在新增移動travel時,當前屏幕的位置應該是:
private Rect getVisibleArea(int travel) {
Rect result = new Rect(getPaddingLeft(), getPaddingTop() + mSumDy + travel, getWidth() + getPaddingRight(), getVerticalSpace() + mSumDy + travel);
return result;
}
其中mSumDy表示上次的移動距離,travel表示這次的移動距離,所以mSumDy + travel表示這次移動後的屏幕位置。
在拿到移動後的屏幕以後,我們只需要跟初始化的item的位置對比,只要有交集,就說明在顯示區域,如果不在交集就不在顯示區域。
那麼問題來了,我們應該從哪個item開始查詢呢?因爲在向上滾動時,底部Item肯定是會空出來空白區域的,
很明顯,應該從當前屏幕上最後一個item的下一個開始查詢即可,如果在顯示區域,就加進來。那什麼時候結束呢?我們只需要一下向下查詢,直到找到不在顯示區域的item,那麼它之後的就不必要再查了。就直接退出循環即可,代碼如下:
Rect visibleRect = getVisibleArea(travel);
//佈局子View階段
if (travel >= 0) {
View lastView = getChildAt(getChildCount() - 1);
int minPos = getPosition(lastView) + 1;//從最後一個View+1開始吧\
//順序addChildView
for (int i = minPos; i <= getItemCount() - 1; i++) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
}
mSumDy += travel;
// 平移容器內的item
offsetChildrenVertical(-travel);
我們來看看上面的代碼,首先,我們拿到屏幕移動後的可見區域:
Rect visibleRect = getVisibleArea(travel);
然後,找到移動前最後一個可見的view:
View lastView = getChildAt(getChildCount() - 1);
然後,找到它之後的一個item:
int minPos = getPosition(lastView) + 1;
然後從這個item開始查詢,看它和它之後的每個item是不是都在可見區域內:
for (int i = minPos; i <= getItemCount() - 1; i++) {
之後就是判斷這個item是不是在顯示區域,如果在就加進來並且佈局,如果不在就退出循環:
for (int i = minPos; i <= getItemCount() - 1; i++) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
需要注意的是,我們的item的位置rect是包含有滾動距離的,而在layout到屏幕上時,屏幕座標是從(0,0)開始的,所以我們需要把高度減去移動距離。需要注意的是,這個移動距離是不包含最新的移動距離travel的,雖然我們在判斷哪些item是新增的顯示的,是假設已經移動了travel,但這只是識別哪些item將要顯示出來的策略,到目前爲止,所有的item並未真正的移動,所以我們在佈局時,仍然需要按上次的移動距離來進行佈局,所以這裏在佈局時使用是layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy)
,單純只是減去了mSumDy,並沒有同時減去mSumDy和travel,最後才調用offsetChildrenVertical(-travel)
來整體移動佈局好的item。這時纔會把我們剛纔新增佈局上的item顯示出來。
所以,此時完整的scrollVerticallyBy的代碼如下:
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getChildCount() <= 0) {
return dy;
}
int travel = dy;
//如果滑動到最頂部
if (mSumDy + dy < 0) {
travel = -mSumDy;
} else if (mSumDy + dy > mTotalHeight - getVerticalSpace()) {
//如果滑動到最底部
travel = mTotalHeight - getVerticalSpace() - mSumDy;
}
//回收越界子View
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (travel > 0) {//需要回收當前屏幕,上越界的View
if (getDecoratedBottom(child) - travel < 0) {
removeAndRecycleView(child, recycler);
continue;
}
}
}
Rect visibleRect = getVisibleArea(travel);
//佈局子View階段
if (travel >= 0) {
View lastView = getChildAt(getChildCount() - 1);
int minPos = getPosition(lastView) + 1;//從最後一個View+1開始吧
//順序addChildView
for (int i = minPos; i <= getItemCount() - 1; i++) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
}
mSumDy += travel;
// 平移容器內的item
offsetChildrenVertical(-travel);
return travel;
}
此時的效果圖如下:
可以看到,向下滾動時,已經能夠正常展示新增的Item了,由於我們還沒有處理向上滾動,所以此時向上滾動時,仍然是空白的。然後查看日誌:
可以看到,在向下滾動時,已經能夠實現複用了。
2.2.2 處理向下滾動
向下滾動是指,手指由上向下滑。很明顯,此時的回收複用就與上面是完全相反的,我們需要判斷底部哪些item被回收了,然後判斷頂部的空白區域需要由哪些填充。
1、判斷回收的Item
同樣,我們還是先回收再佈局Item,很明顯,這裏需要先找到底部哪些Item被移出屏幕了:
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (travel > 0) {//需要回收當前屏幕,上越界的View
…………
}else if (travel < 0) {//回收當前屏幕,下越界的View
if (getDecoratedTop(child) - travel > getHeight() - getPaddingBottom()) {
removeAndRecycleView(child, recycler);
continue;
}
}
}
利用getDecoratedTop(child) - travel
得到在移動travel距離後,這個item的頂部位置,如果這個頂部位置在屏幕的下方,那麼它就是不可見的。getHeight() - getPaddingBottom()
得到的是RecyclerView可顯示的最低部位置.
2、爲滾動後的空白處填充Item
在填充時,我們應該從當前可見的item的上一個item向上遍歷,直接遍歷到第一個Item爲止,如果當前item可見,那就繼續遍歷,如果這個item不可見,那說明它之前的item也是不可見的,就結束遍歷:
Rect visibleRect = getVisibleArea(travel);
//佈局子View階段
if (travel >= 0) {
…………
} else {
View firstView = getChildAt(0);
int maxPos = getPosition(firstView) - 1;
for (int i = maxPos; i >= 0; i--) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child, 0);//將View添加至RecyclerView中,childIndex爲1,但是View的位置還是由layout的位置決定
measureChildWithMargins(child, 0, 0);
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
}
下面來看看這段代碼:
在這裏,先得到在滾動前顯示的第一個item的前一個item:
View firstView = getChildAt(0);
int maxPos = getPosition(firstView) - 1;
如果在顯示區域,那麼,就將它插在第一的位置:
addView(child, 0);
同樣,在佈局Item時,由於還沒有移動,所以在佈局時並不考慮travel的事:layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy)
其它的代碼都很好理解了,這裏就不再講了。
這樣就完整實現了滾動的回收和複用功能了,完整的scrollVerticallyBy代碼如下:
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getChildCount() <= 0) {
return dy;
}
int travel = dy;
//如果滑動到最頂部
if (mSumDy + dy < 0) {
travel = -mSumDy;
} else if (mSumDy + dy > mTotalHeight - getVerticalSpace()) {
//如果滑動到最底部
travel = mTotalHeight - getVerticalSpace() - mSumDy;
}
//回收越界子View
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (travel > 0) {//需要回收當前屏幕,上越界的View
if (getDecoratedBottom(child) - travel < 0) {
removeAndRecycleView(child, recycler);
continue;
}
} else if (travel < 0) {//回收當前屏幕,下越界的View
if (getDecoratedTop(child) - travel > getHeight() - getPaddingBottom()) {
removeAndRecycleView(child, recycler);
continue;
}
}
}
Rect visibleRect = getVisibleArea(travel);
//佈局子View階段
if (travel >= 0) {
View lastView = getChildAt(getChildCount() - 1);
int minPos = getPosition(lastView) + 1;//從最後一個View+1開始吧
//順序addChildView
for (int i = minPos; i <= getItemCount() - 1; i++) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
} else {
View firstView = getChildAt(0);
int maxPos = getPosition(firstView) - 1;
for (int i = maxPos; i >= 0; i--) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child, 0);//將View添加至RecyclerView中,childIndex爲1,但是View的位置還是由layout的位置決定
measureChildWithMargins(child, 0, 0);
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
}
mSumDy += travel;
// 平移容器內的item
offsetChildrenVertical(-travel);
return travel;
}
此時的效果圖如下:
這裏不再打印日誌了,這裏的日誌輸出是與LinearLayoutManager完全相同的,到這裏,我們就實現了爲自定義的CustomLayoutManager添加回收復用的功能。可以看到,其實添加回收復用還是比較有難度的,網上很多的demo,說是能實現回收複用,80%都不行,根本沒辦法和LinearLayoutManager的複用情況保持一致。
這篇文章中,我們雖然實現了自定義LayoutManager的回收複用,但是這裏用了很多取巧的辦法,比如,我們直接使用offsetChildrenVertical(-travel)
來平移item,但如果我們需要實現下面的這個效果:
咳咳,是不是很酷,VIVO遊戲空間的控件,俺寫的……,哈哈
很明顯,在這個RecyclerView裏,雖然同樣是通過自定義LayoutManager來實現,並不能通過調用offsetChildrenVertical(-travel)
來實現平移,因爲在平移時,不光需要改變位置,還需要改變每個item的大小、角度等參數。
所以,下一篇,我們就針對這種情況,來學習第二種回收複用的方法。
如果本文有幫到你,記得加關注哦
CSDN源碼現在不能零分下載了,必須強制最低一分,我設置爲了最低分,如果沒分的同學,可以從github上下載。
源碼地址:https://download.csdn.net/download/harvic880925/10835860
github代碼地址:https://github.com/harvic/harvic_blg_share 位於RecylcerView(四)
轉載請標明出處,https://blog.csdn.net/harvic880925/article/details/84866486 謝謝