在上篇中,我们先将摆好所有要显示的新增item以后,再使用offsetChildrenVertical(-travel)函数来移动屏幕中所有item。很明显,这种方法仅适用于每个item,在移动时,没有特殊效果的情况,当我们在移动item时,同时需要改变item的角度、透明度等情况时,单纯使用offsetChildrenVertical(-travel)来移是不行的。针对这种情况,我们就只有使用第二种方法来实现回收复用了。
在本节中,我们最终实现的效果如下图所示:
从效果图中可以看出,本例中的每个item,在移动时,同时会绕Y轴旋转。
因为大部分的原理与上节中的CustomLayoutManager的实现相同,所以本节中的代码将从4.4中的CustomLayoutManager中改造而成。
一、 初步实现
1.1 实现原理
在这里,我们主要替换掉在上节中移动item所用的offsetChildrenVertical(-travel);函数,既然要将它弃用,那我们就只能自己布局每个item了。很明显,在这里我们主要处理的是滚动的情况,对于onLayoutChildren中的代码是不用改动的。
试想,在滚动dy时,有两种item需要重新布局:
第一种:原来已经在屏幕上的item
第二种:新增的item
所以,这里就涉及到怎么处理已经在屏幕上的item和新增item的重绘问题,我们可以效仿在onLayoutChildren中的处理方式,先调用detachAndScrapAttachedViews(recycler)将屏幕上已经在显示的所有Item离屏,然后再将所有item重绘。
那第二个问题又来了,我们应该从哪个item开始重绘,到哪个item结束呢?
很明显,在向下滚动时,低部Item下移,顶部空出来空白区域。所以我们只需要从当前在显示的Item向前遍历,直到index=0即可。
当向上滚动时,顶部Item上移,底部空出来空白区域。所以我们也只需要从当前在显示的顶部Item向上遍历,直到Item结束为止。
1.2 改造CustomLayoutManager
首先,onLayoutChildren不用改造,只需要改造scrollVerticallyBy即可。原来的到顶、到底判断和回收越界item的代码都不变:
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;
}
}
}
…………
}
在回收越界的holderView之后,我们需要在使用detachAndScrapAttachedViews(recycler);将现在显示的所有item离屏缓存之前,先得到当前在显示的第一个item和最后一个item的索引,因为如果在将所有item从屏幕上离屏缓存以后,利用getChildAt(int position)是拿不到任何值的,会返回null,因为现在屏幕上已经没有View存在了。
View lastView = getChildAt(getChildCount() - 1);
View firstView = getChildAt(0);
detachAndScrapAttachedViews(recycler);
mSumDy += travel;
Rect visibleRect = getVisibleArea();
这里需要注意的是,我们在所有的布局操作前,先将移动距离mSumDy进行了累加。因为后面我们在布局item时,会弃用offsetChildrenVertical(-travel)移动item,而是在布局item时,就直接把item布局在新位置。最后,因为我们已经累加了mSumDy,所以我们需要改造getVisibleArea(),将原来getVisibleArea(int dy)中累加dy的操作去掉:
private Rect getVisibleArea() {
Rect result = new Rect(getPaddingLeft(), getPaddingTop() + mSumDy, getWidth() + getPaddingRight(), getVerticalSpace() + mSumDy);
return result;
}
接下来,就是布局屏幕上的所有item,同样是分情况:
if (travel >= 0) {
int minPos = getPosition(firstView);
for (int i = minPos; i < getItemCount(); 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);
}
}
}
这里需要注意的是,当dy>0时,表示向上滚动(手指由下向上滑),所以我们需要从之前第一个可见的item向下遍历,因为我们不知道在什么情况下遍历结束,所以我们使用最后一个item的索引(getItemCount())做为结束位置。当然大家在这里也可以优化,可以使用下面的语句:
int max = minPos + 50 < getItemCount() ? minPos + 50 : getItemCount();
即从第一个item向后累加50项,如果最后的索引比getItemCount()小,就用minPos+50做为结束位置,否则就用getItemCount()做为结束位置。当然这里的50是随便写的,大家根据自己的项目情况做调整,这里为方便理解起见,就不再修改。
然后在在dy>0时,表示向下滚动(手指由上向下滑):
if (travel >= 0) {
…………
} else {
int maxPos = getPosition(lastView);
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);
measureChildWithMargins(child, 0, 0);
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
}
}
}
因为是向下滚动,所以顶部新增,底部回收,所以我们需要从当前底部可见的最后一个item向上遍历,将每个item布局到新位置,但什么时候截止呢?我们同样可以向上减50:
int min = maxPos - 50 >= 0 ? maxPos - 50 : 0;
这里我为了方便理解,还是一直遍历到索引0;
代码到这里就改造完了,scrollVerticallyBy的核心代码如下(除去到顶、顶底判断和越界回收)
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
//到顶/到底判断
…………
//回收越界子View
…………
View lastView = getChildAt(getChildCount() - 1);
View firstView = getChildAt(0);
detachAndScrapAttachedViews(recycler);
if (travel >= 0) {
int minPos = getPosition(firstView);
for (int i = minPos; i < getItemCount(); 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 {
int maxPos = getPosition(lastView);
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);
measureChildWithMargins(child, 0, 0);
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
}
}
}
return travel;
}
可以看到,在这段代码中,添加item那块非常冗余,在travel>=0时和travel<0时,要写两遍,除了插入位置不同以外,其它都完全相同的,所以我们可以抽出来一个函数来做addView的事情:
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
//到顶/到底判断
…………
//回收越界子View
…………
View lastView = getChildAt(getChildCount() - 1);
View firstView = getChildAt(0);
detachAndScrapAttachedViews(recycler);
if (travel >= 0) {
int minPos = getPosition(firstView);
for (int i = minPos; i < getItemCount(); i++) {
insertView(i,visibleRect,recycler,false);
}
} else {
int maxPos = getPosition(lastView);
for (int i = maxPos; i >= 0; i--) {
insertView(i,visibleRect,recycler,true);
}
}
return travel;
}
private void insertView(int pos, Rect visibleRect, Recycler recycler,boolean firstPos){
Rect rect = mItemRects.get(pos);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(pos);
if (firstPos) {
addView(child, 0);
}else {
addView(child);
}
measureChildWithMargins(child, 0, 0);
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
//在布局item后,修改每个item的旋转度数
child.setRotationY(child.getRotationY()+1);
}
}
在这里将布局时,用到的公共部分抽出来一个函数,命名为insertView,在这个函数中,我们先将这个item布局,然后在布局后,调用child.setRotationY(child.getRotationY()+1);将它的围绕Y轴的旋转度数加1,所以每滚动一次,就会旋转度数加1.这样就实现了开篇的效果了。
再看日志的复用情况:
可以看到回收复用情况不变,这就初步实现了布局每个item的改造,下面我们继续对它进行优化。
二、继续优化:回收时布局
在上部分中,我们通过先使用detachAndScrapAttachedViews(recycler)将所有item离屏缓存,然后通过再重新布局所有item的方法来实现回收复用。
但这里有个问题,就是我们能不能把已经在屏幕上的item直接布局呢?这样就省了先离屏缓存再重新布局原本就可见item的步骤了,性能就能有所提高。
那这个直接布局已经在屏幕上的item的步骤,放在哪里呢?我们知道,我们在回收越界item时,会遍历所有的可见item,所以我们可以把它放在回收越界时,如果越界就回收,如果没越界就重新布局:
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
int position = getPosition(child);
Rect rect = mItemRects.get(position);
if (!Rect.intersects(rect, visibleRect)) {
removeAndRecycleView(child, recycler);
}else {
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
child.setRotationY(child.getRotationY() + 1);
}
}
因为后面我们还需要布局所有Item,很明显,在全部布局时,这些已经布局过的item就需要排除掉,所以我们需要一个变量来保存在这里哪些item已经布局好了:
所以,我们先申请一个成员变量:
private SparseBooleanArray mHasAttachedItems = new SparseBooleanArray();
然后在onLayoutChildren中初始化:
public void onLayoutChildren(Recycler recycler, RecyclerView.State state) {
…………
mHasAttachedItems.clear();
mItemRects.clear();
…………
for (int i = 0; i < getItemCount(); i++) {
Rect rect = new Rect(0, offsetY, mItemWidth, offsetY + mItemHeight);
mItemRects.put(i, rect);
mHasAttachedItems.put(i, false);
offsetY += mItemHeight;
}
…………
}
在onLayoutChildren中,先将它清空,然后在遍历所有item时,把所有item所对应的值设置为false,表示所有item都没有被重新布局。
然后在回收越界holdview时,将已经重新布局的item置为true.将被回收的item,回收时设置为false;
public int scrollVerticallyBy(int dy, Recycler recycler, RecyclerView.State state) {
…………
//回收越界子View
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
int position = getPosition(child);
Rect rect = mItemRects.get(position);
if (!Rect.intersects(rect, visibleRect)) {
removeAndRecycleView(child, recycler);
mHasAttachedItems.put(position,false);
} else {
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
child.setRotationY(child.getRotationY() + 1);
mHasAttachedItems.put(i, true);
}
}
…………
}
最后在布局所有item时,添加判断当前的item是否已经被布局,没布局的item再布局,需要注意的是,在布局后,需要将mHasAttachedItems中对应位置改为true,表示已经在布局中了。
private void insertView(int pos, Rect visibleRect, Recycler recycler, boolean firstPos) {
Rect rect = mItemRects.get(pos);
if (Rect.intersects(visibleRect, rect) && !mHasAttachedItems.get(pos)) {
…………
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
child.setRotationY(child.getRotationY() + 1);
mHasAttachedItems.put(pos,true);
}
}
最后一步,最关键的,不要忘了删除scrollVerticallyBy中的detachAndScrapAttachedViews(recycler);
完整onLayoutChildren和scrollVerticallyBy的代码如下,工程代码请参考源码:
public void onLayoutChildren(Recycler recycler, RecyclerView.State state) {
if (getItemCount() == 0) {//没有Item,界面空着吧
detachAndScrapAttachedViews(recycler);
return;
}
mHasAttachedItems.clear();
mItemRects.clear();
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);
mHasAttachedItems.put(i, false);
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());
}
@Override
public int scrollVerticallyBy(int dy, 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;
}
mSumDy += travel;
Rect visibleRect = getVisibleArea();
//回收越界子View
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
int position = getPosition(child);
Rect rect = mItemRects.get(position);
if (!Rect.intersects(rect, visibleRect)) {
removeAndRecycleView(child, recycler);
mHasAttachedItems.put(position,false);
} else {
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
child.setRotationY(child.getRotationY() + 1);
mHasAttachedItems.put(position, true);
}
}
View lastView = getChildAt(getChildCount() - 1);
View firstView = getChildAt(0);
if (travel >= 0) {
int minPos = getPosition(firstView);
for (int i = minPos; i < getItemCount(); i++) {
insertView(i, visibleRect, recycler, false);
}
} else {
int maxPos = getPosition(lastView);
for (int i = maxPos; i >= 0; i--) {
insertView(i, visibleRect, recycler, true);
}
}
return travel;
}
private void insertView(int pos, Rect visibleRect, Recycler recycler, boolean firstPos) {
Rect rect = mItemRects.get(pos);
if (Rect.intersects(visibleRect, rect) && !mHasAttachedItems.get(pos)) {
View child = recycler.getViewForPosition(pos);
if (firstPos) {
addView(child, 0);
} else {
addView(child);
}
measureChildWithMargins(child, 0, 0);
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
//在布局item后,修改每个item的旋转度数
child.setRotationY(child.getRotationY() + 1);
mHasAttachedItems.put(pos,true);
}
}
此时,大家去打日志来看回收复用情况,也跟LinearLayoutManager是完全相同的,这里就不再截图了。
到这里,自定义LayoutManager的部分就结束了,这两节中,我们主要讲解了一般情况下的回收复用方法和本节的特殊情况下的回收复用方法,不过一般对于优秀特效而言,本节布局回收每个item的方法用的最多。