天才免不了有障碍,因为障碍会创造天才。——罗曼·罗兰
上一篇博客 https://www.jianshu.com/p/99d8466b8cee
一、View的回收与复用
我们需要知道怎么判断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
。
LinearLayoutManager回收复用情况
我们在我们Demo中的RecyclerAdatper的onCreateViewHolder
和onBindViewHolder
中添加上日志:
private int mCreatedHolder=0;
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
mCreatedHolder++;
Log.d("TAG", "----------onCreateViewHolder num:"+mCreatedHolder);
…………
}
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
Log.d("TAG", "----------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,在创建到一定数量(我手机上是13个)之后,就开始使用回收复用逻辑,把无用的View给复用起来。所以LinearLayoutManager是可以做到回收复用的。
CustomLayoutManager回收复用情况
接下来,我们将LinearLayoutManger改为CustomLayoutManager,来看下在上部分我们写好了CustomLayoutManager会不会自动回收复用:
public class LinearActivity extends AppCompatActivity {
private ArrayList 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会在初始化时一次性创建100个View。而在我们滚动时,即不会调用onCreateViewHolder
也不会调用onBindViewHolder
,这是为什么呢?
因为我们总共有100个数据,所以这里创建了100个View。也就是一次性将所有View创建完成,并加进RecyclerView。正是因为所以的ItemView都已经加进RecyclerView了,所以可以实现滚动功能,但并没有实现回收复用。而且一次性创建所有Item的holderView,极易可能出现ANR。
RecyclerView的回收复用原理
从上面的对比中可以看出,RecyclerView确实是存在回收复用的,但回收复用是需要我们在自定义的LayoutManager中处理的,而不是会自动具有这个功能,那么问题来了,我们要怎么给自定义的LayoutManager添加上回收复用功能呢?
在讲解自定义回收复用之前,我们需要先了解RecyclerView是如何处理回收复用的。
简述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新建。
这里需要注意的是,在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中总共有多少数据要显示,也就是总共有多少个itemint 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);
CustomLayoutManager实现回收复用的原理
从上面的原理中可以看到,回收复用主要有两部分:
第一:在onLayoutChildren初始布局时:
- 使用
detachAndScrapAttachedViews(recycler)
将所有的可见HolderView剥离 - 一屏中能放几个Item就获取几个HolderView,撑满初始化的一屏即可,不要多创
第二:在scrollVerticallyBy滑动时:
- 先判断在滚动dy距离后,哪些ViewHolder需要回收,如果需要回收就调用
removeAndRecycleView(child, recycler)
先将它回收。 - 然后向系统获取ViewHolder对象来填充滚动出来的空白区域
下面我们就利用这个原理来实现CustomLayoutManager的回收复用功能。
二、 给CustomLayoutManager添加回收复用
上面已经提到,在onLayoutChildren中,我们主要做两件事:
使用 detachAndScrapAttachedViews(recycler)将所有的可见HolderView剥离
一屏中能放几个item就获取几个HolderView,撑满初始化的一屏即可,不要多创建
在这里,每个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),因为我们只有测量过以后,系统才知道它的测量的宽高,如果不测量,系统也是不知道它的宽高的。同时,由于我们每个Item的大小都是固定的,为了布局方便,我们在初始化时,利用一个变量来保存在初始化时,在Adapter中每一个Item的位置:
private SparseArray mItemRects = new SparseArray<>();
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, getVerticalSpace());
在上面我们已经从保存了初始化状态下,每个Item的位置,所以在初始化时,直接从mItemRects中取出当前要显示的Item的位置,直接将它摆放在这个位置就可以了。需要注意的是,因为我们在之前已经使用detachAndScrapAttachedViews(recycler);
将所有View从RecyclerView中剥离,所以,我们需要重新通过addView(view)
添加进来。在添加进来以后,需要走一个这个View的测量和layout逻辑,先经过测量,再将它layout到指定位置。如果我们没有测量直接layout,会什么都出不来,因为任何view的layout都是依赖measure出来的位置信息的。
到此,完整的onLayoutChildren的代码如下:
private int mItemWidth, mItemHeight;
private SparseArray 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());
}
处理滚动
接下来,我们就来处理滚动时的情况,根据上面的原理分析,我们知道,我们首先需要回收滚出屏幕的HolderView,然后再填充滚动后的空白区域。因为向上滚动和向下滚动的dy的值是相反的,当向上滚动时(手指由下往上滑),dy>0;当向下滚动时(手指由上往下滑),dy<0;所以,我们分两种情况分别处理。
处理向上滚动
在处理滚动时,我们的处理策略是,先假设滚动了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,然后,找到它之后的一个item:
View lastView = getChildAt(getChildCount() - 1);
int minPos = getPosition(lastView) + 1;
然后从这个Item开始查询,看它和它之后的每个Item是不是都在可见区域内,之后就是判断这个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;
}
}
这个移动距离是不包含最新的移动距离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了,由于我们还没有处理向下滚动,所以此时向下滚动时,仍然是空白的。然后查看日志:
2020-06-19 17:23:38.585 8025-8025/com.example.myrecyclerview D/TAG: -----onCreateViewHolder num:7
2020-06-19 17:23:38.588 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
2020-06-19 17:23:38.590 8025-8025/com.example.myrecyclerview D/TAG: -----onCreateViewHolder num:8
2020-06-19 17:23:38.593 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
2020-06-19 17:23:38.593 8025-8025/com.example.myrecyclerview D/TAG: -----onCreateViewHolder num:9
2020-06-19 17:23:38.595 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
2020-06-19 17:23:40.276 8025-8025/com.example.myrecyclerview D/TAG: -----onCreateViewHolder num:10
2020-06-19 17:23:40.278 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
2020-06-19 17:23:40.327 8025-8025/com.example.myrecyclerview D/TAG: -----onCreateViewHolder num:11
2020-06-19 17:23:40.329 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
2020-06-19 17:23:40.443 8025-8025/com.example.myrecyclerview D/TAG: -----onCreateViewHolder num:12
2020-06-19 17:23:40.447 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
2020-06-19 17:23:40.562 8025-8025/com.example.myrecyclerview D/TAG: -----onCreateViewHolder num:13
2020-06-19 17:23:40.564 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
2020-06-19 17:23:40.712 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
2020-06-19 17:23:40.893 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
2020-06-19 17:23:41.410 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
2020-06-19 17:23:41.543 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
2020-06-19 17:24:13.261 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
2020-06-19 17:24:13.327 8025-8025/com.example.myrecyclerview D/TAG: --------onBindViewHolder
可以看到,在向上滚动时,已经能够实现复用了。
处理向下滚动
向下滚动是指,手指由上向下滑。很明显,此时的回收复用就与上面是完全相反的,我们需要判断底部哪些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可显示的最低部位置。
为滚动后的空白处填充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,如果在显示区域,那么,就将它插在第一的位置,同样,在布局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,但如果我们需要实现下面的这个效果:
很明显,在这个RecyclerView里,虽然同样是通过自定义LayoutManager来实现,并不能通过调用offsetChildrenVertical(-travel)来实现平移,因为在平移时,不光需要改变位置,还需要改变每个item的大小、角度等参数。
所以,下一篇,我们就针对这种情况,来学习第二种回收复用的方法。