自定义控件三部曲视图篇(七)——RecyclerView系列之四实现回收复用

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/harvic880925/article/details/84866486

怕什么真理无穷,进一寸有一寸的欢喜。 ----------胡适


系列文章: 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 谢谢

猜你喜欢

转载自blog.csdn.net/harvic880925/article/details/84866486