自定義控件三部曲視圖篇(七)——RecyclerView系列之四實現回收複用

怕什麼真理無窮,進一寸有一寸的歡喜。 ----------胡適


系列文章: 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的onCreateViewHolderonBindViewHolder中添加上日誌:

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;
其中:

  1. mAttachedScrap不參與回收複用,只保存從在重新佈局時,從RecyclerView中剝離的當前在顯示的HolderView列表。
  2. 所以,mCachedViews、mViewCacheExtension、mRecyclerPool組成了回收複用的三級緩存,當RecyclerView要拿一個複用的HolderView時,獲取優先級是mCachedViews > mViewCacheExtension > mRecyclerPool。由於一般而言我們是不會自定義mViewCacheExtension的。所以獲取順序其實就是mCachedViews > mRecyclerPool,在下面的講述中,我也將不再牽涉mViewCacheExtension,大家這裏知道即可。
  3. 其實,mCachedViews是不參與回收複用的,它的作用就是保存最新被移除的HolderView(通過removeAndRecycleView(view, recycler)方法),它的作用是在需要新的HolderView時,精確匹配是不是剛移除的那個,如果是,就直接返回給RecyclerView展示,如果不是它,那麼即使這裏有HolderView實例,也不會返回給RecyclerView,而是到mRecyclerPool中去找一個HolderView實例,返回給RecyclerView,讓它重新綁定數據使用。
  4. 所以,在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新建。

這裏需要注意的是,在mAttachedScrapmCachedViews中拿到的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初始佈局時:

  1. 使用 detachAndScrapAttachedViews(recycler)將所有的可見HolderView剝離
  2. 一屏中能放幾個item就獲取幾個HolderView,撐滿初始化的一屏即可,不要多創建

第二:在scrollVerticallyBy滑動時:

  1. 先判斷在滾動dy距離後,哪些holderView需要回收,如果需要回收就調用removeAndRecycleView(child, recycler)先將它回收。
  2. 然後向系統獲取HolderView對象來填充滾動出來的空白區域

下面我們就利用這個原理來實現CustomLayoutManager的回收複用功能。

二、 給CustomLayoutManager添加回收復用

2.1 修改onLayoutChildren

上面已經提到,在onLayoutChildren中,我們主要做兩件事:

  1. 使用 detachAndScrapAttachedViews(recycler)將所有的可見HolderView剝離
  2. 一屏中能放幾個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 謝謝

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章