学习一门技术,要有耐心,不能光看,一定要多动手,遇到问题不要急躁,要保持一颗平静的心。
网上关于自定义LayoutManager的文章有很多,也有很多写的不错的,看了觉得学到了东西。业界有句比较著名的话:不要重复造轮子。既然不要重复造轮子,那我再重复的讲一遍自定义LayoutManager的基础知识,例如说通过getViewForPosition获取一个view,通过removeAndRecycleView回收一个view,也就没什么意思了。因此文本就不再重复介绍这些基础知识了,那如果不讲这些,那还有什么好讲的呢?
相信很多自定义过LayoutManager的人都有一种感觉,就是在看网上自定义LayoutManager文章的时候,明明看的很明白,很有感觉,但是当自己去实现的时候,就是处理不好,甚至连最基本的功能(滑动、复用)都实现不了。没错,我也是其中一个,但是当我亲自动手真的实现了一个定义LayoutManager的基本功能的时候,发现还是有点东西可以写的。因此本文会从另外一个角度出发去讲述如何自定义LayoutManager,主要是讲述自己实现过程中遇到的一些难点以及如何去解决这些难点,一来希望大家看了觉得学到了一些东西,二来也当是自己的一些日志记录吧。
首先总结一下自己觉得自定义LayoutManager的一些难点:
1.没有整体的、清晰的思路
2.边界的判定
3.调试麻烦、log太多,干扰很大
先说第3点:仁者见仁的问题,我喜欢在方法里通过打log观察log的方式调试和定位问题,但是只要我们在屏幕上手指一滑动,scrollVerticallyBy方法就会回调多次,更别提手指快速的滑动了,log太多,干扰很大。
第1点:没有整体的、清晰的思路
滑动的功能,也就是scrollVerticallyBy方法的实现,自定义LayoutManager大部分的工作和难点都在scrollVerticallyBy的实现上了,包括了偏移值dy的修正、子view的填充、子view的偏移、子view的回收四个过程。而这四个过程就是第1点说的要有一个整体的、清晰的思路把握。有时候明明知道手指在屏幕上滑动会调用scrollVerticallyBy方法,明明知道dy是偏移量,明明知道需要调用fillXXX(姑且这么叫吧)方法填充子view,也明明知道要调用offsetChildrenVertical整体移动子view,但是就是没能把他们揉和在一起形成一个整体加深理解。
注:通过在scrollVerticallyBy方法里打log方式输出dy值,会发现当手指滑动的时候会多次回调scrollVerticallyBy方法,我们要把这么多次的方法回调看成一次次的滑动事件,然后我们只分析其中的一个滑动事件,而在每一次的滑动事件中,都会完整的经过dy修正、子view填充、子view移动、子view回收四个过程。这四个过程的顺序是可以调整的,本文采取的顺序为先修正dy,再进行填充,然后进行移动,最后回收。
现在来总结一下一次的滑动事件过程:当手指在屏幕上滑动的时候,会回调scrollVerticallyBy方法,传递给我们视图应该滑动的距离dy,这个dy在内容到达边界的时候需要修正,修正的意思是说当内容到头或者到尾了,子view不能继续滑动或者能滑动的距离比dy要小,这时候dy需要修正为子view能滑动的距离,因为后面子view需要根据这个距离来调用offsetChildrenVertical真正开始移动的;计算出子view们的实际偏移量dy后,接下来就要根据dy开始填充子view,这时候填充子view就不能只填充可见屏幕内的区域了,而要填充可见屏幕区域+dy的区域,因为接下来是要集体移动子view到相对位置为dy的位置的,如果只填充可见屏幕区域,移动后就会留有空白了;填充完后调用offsetChildrenVertical移动子view;最后就是回收子view,移动子view后,有的子view已经移出了屏幕,这时候只要把屏幕外的子view回收掉就OK了。
上面的滑动事件过程总结虽然看起来是理所当然,但是往往我们只关注代码的实现,在深入代码的时候确实有时候会只见树木不见森林,也许是只有我才遇到这个问题(/尴尬),但我是的确在理顺了上面的关系后豁然开朗了许多。
第2点:边界的判定
边界的判断我总结为包括dy的修正、填充子view时的边界、回收子view时的边界。自己在自定义LayoutManager过程中经常遇到的或者网上很多demo都有的现象:快速的手指滑动时,底部或者头部会跑过界而屏幕上留有空白的现象,这些都是因为边界判断不正确引起的,下面我会给出解决办法。甚至一开始我觉得看明白了别人的代码实现,而自己动手时就是处理不好的一个原因就是没有讲解边界的判断(+dy -dy的代码),看懂别人代码是大概,自己实现是细节。当然啦,当你弄懂了滑动事件的整个过程,也就是第1点,其实无论怎么+dy -dy也没什么大问题了。
下面就开始自定义LayoutManager的真正实现了,其实要学习定义LayoutManager,不一定非要找不同于系统自带的三个LayoutManager的功能,因为学的是思路、原理,因此本文要实现的是简单的GridLayoutManager功能。
首先是onLayoutChildren(),这个方法是在RecyclerView初始化的时候回调的,并且会回调两次,我们需要在这个回调方法里布局我们的子view,也就是说在onLayoutChildren方法里也会有fillXXX方法的调用,来看一下简单的代码:
if (getItemCount() == 0) {
detachAndScrapAttachedViews(recycler);
return;
}
if (getChildCount() == 0) {
View scrap = recycler.getViewForPosition(0);
addView(scrap);
measureChildWithMargins(scrap, 0, 0);
mDecorateWidth = getDecoratedMeasurementHorizontal(scrap) / mColumnCount;
mDecorateHeight = getDecoratedMeasurementVertical(scrap);
detachAndScrapView(scrap, recycler);
}
detachAndScrapAttachedViews(recycler);
fill(recycler, state);
代码很简单,主要就是先获取到item view的宽和高(这里的情况是每个itemView的大小都是一样的),保存在mDecorateWidth和mDecorateHeight中。值得提一下的是这里调用了detachAndScrapView和detachAndScrapAttachedViews方法。
接下来就是fill方法了,网上的文章几乎都是所有的地方用的都是同一个fill方法,这对于初学者来说还是有那么一点干扰的,因此我在这里把在onLayoutChildren用到的fill方法和scrollVerticallyBy用的fill方法分开来,但是里面的逻辑都是相似的,等你真正学会了自定义LayoutManager,再把它优化合在一起不是什么难事。
先看看onLayoutChildren的fill:
private void fill(RecyclerView.Recycler recycler, RecyclerView.State state) {
int topOffset = getPaddingTop();
int leftOffset = getPaddingLeft();
//布局子View阶段
for (int i = 0; i <= getItemCount() - 1; i++) {
//计算宽度 包括margin
if (leftOffset + mDecorateWidth > getHorizontalSpace()) {
//当前行排列不下,新起一行
leftOffset = getPaddingLeft();
topOffset += mDecorateHeight;
if (topOffset > getHeight() - getPaddingBottom()) {
break;
}
}
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecoratedWithMargins(child, leftOffset, topOffset,
leftOffset + mDecorateWidth,
topOffset + mDecorateHeight);
leftOffset += mDecorateWidth;
}
}
因为此时没有滑动,不需要考虑偏移量dy,所以这时候的填充还是比较简单的,相信算法逻辑不难理解,只是需要注意一下布局是支持多列的,还有就是只需填充满屏幕可见范围内的就好,屏幕之外的地方就不需要填充了。
下面就到了重头戏,滑动功能。
滑动过程我在文章的开头就总结了一下滑动事件的过程,也是希望大家能够先对滑动有一个整体的了解,因为它真的是挺难的。里面涉及到内容边界的计算、子view的及时填充和子view的及时回收。其中内容边界的计算真的是计算的头破血流!!无数次的遇到这样的一个场景:快速的手指下滑或者上滑,头部或者尾部的内容跑过界了,留下头部或者尾部空白的一片,甚至还有直接跑到屏幕底部或者头部外边去了,留下空白的一片屏幕!!其实网上有很多的自定义LayoutManager的demo都有这种现象,甚至在gitbub上一些star过千的demo都有这种现象!把demo的itemView的高度改小一点,最后快速的滑动,问题就很容易出现。出现这种现象的根本原因是边界的计算方法有问题,下面就来看看怎么解决这个问题。
先把scrollVerticallyBy的实现步骤列一下:
1.偏移值dy修正
2.填充子view
3.偏移子view
4.回收子view final View topView = getChildAt(0);
final View bottomView = getChildAt(getChildCount() - 1);
int delta = 0;
if (dy > 0) {
int bottomOffset = getDecoratedBottom(bottomView);
int bottomPos = getPosition(bottomView);
int rowOfBottom = bottomPos / mColumnCount;
int lastRow = (getItemCount() - 1) / mColumnCount;
int maxDelta = (lastRow - rowOfBottom) * mDecorateHeight +
(bottomOffset - getHeight() + getPaddingBottom());
delta = -(Math.min(Math.abs(dy), Math.abs(maxDelta)));
} else {
int topOffset = getDecoratedTop(topView);
int topPos = getPosition(topView);
int leftRow = topPos / mColumnCount;
int maxDelta = leftRow * mDecorateHeight - topOffset;
delta = Math.min(-dy, maxDelta);
}
网上很多的demo修正dy的办法是利用屏幕上可见的最后一个子view即bottomView是否是最后一个position作比较来判断是否到达底部,到达底部就修正,还没就不修正,这是不对的。试想一下,假设此时bottomView是倒数第二个子view(假设只有一列),我们在屏幕上手指快速的上滑,这时候有个滑动事件到来,偏移值dy可能很大,比2倍的itemView的高度还要大,此时dy>2 * height(itemView),而这个时候子view能移动的最大距离却是小于2倍的itemView的高度的,因为只剩下最后一个子view了,大家想想是不是这样。
maxDelta的计算方法如下(只分析dy>0的情况):其实画个图就一目了然了,只不过图有点挫,请见谅。
int maxDelta = (lastRow - rowOfBottom) * mDecorateHeight +(bottomOffset - getHeight() + getPaddingBottom());
对应图来看:(lastRow - rowOfBottom) * mDecorateHeight 是 3和4的高度之和,而bottomOffset - getHeight() + getPaddingBottom() 则为第2行屏幕之外的高度,加起来就是子view能够移动的最大距离了,这样delta就是修正后的dy了。
接下来第二步就是要开始填充子view了,填充子view会有个时机,不是每次滑动事件到来都需要填充的,对于一个偏移量dy来说,只有当子view移出屏幕或者进入屏幕的时候才需要填充新的子view,甚至子view移出屏幕的时候都不需要填充,只需要回收就好了。想想也在情理之中,子view移出去了直接回收就好了,为什么还要重新填充一遍呢?这时候可能就有人反驳了,子view移出可能还伴随着子view的进入啊,不填充怎么行呢?好,下面就来看看我是怎么处理的
boolean recycleView = false;
int direction = -1;
if (dy > 0) {
//手指向上滑动
int bottom = getDecoratedBottom(bottomView);
if (bottom + delta < getHeight() - getPaddingBottom()) {
//底部有新的view进入屏幕
recycleView = true;
direction = BOTTOM_IN;
fillViewIn(recycler, BOTTOM_IN, inRowCount);
} else if (getDecoratedBottom(topView) + delta < getPaddingTop()) {
//topView移出屏幕
recycleView = true;
direction = TOP_OUT;
}
} else {
//手指向下滑动
int top = getDecoratedTop(topView);
if (top + delta > getPaddingTop()) {
//头部有新的view进入屏幕
recycleView = true;
direction = TOP_IN;
fillViewIn(recycler, TOP_IN, inRowCount);
} else if (getDecoratedTop(bottomView) + delta > getHeight() - getPaddingBottom()) {
//bottomView移出屏幕
recycleView = true;
direction = BOTTOM_OUT;
}
}
变量direction和recycleView是用来收集一些信息给后面回收用的,这里先不考虑。这里只分析手指下滑的情况,上滑的同理,大家可以自己分析。下滑可能伴随着头部有新view进入屏幕或者bottomView移出屏幕,大家可以看到,我只在头部有新view进入屏幕的时候才会调用fillXXX填充子view,bottomView移出屏幕的时候只是收集一些信息给回收的时候用。那怎么保证bottomView移出屏幕的时候不会伴随着有新的view进入屏幕呢?答案就是if...else if....的判断先后顺序,大家想想是不是,如果子view移出屏幕伴随着有子view进入屏幕,其实就会走第一个if的逻辑了,也就是有新view进入屏幕的逻辑,这时候就会调用fillXXX填充子view了。看起来是可行的,这也算是一点的小优化吧。
下面就来看看这个fillViewIn方法的逻辑,这里的fillViewIn和onLayoutChildren的fill逻辑是不一样的,onLayoutChildren的fill是从头到尾填充子view,直到充满整个可见屏幕,屏幕之外的子view就不填充,但是fillViewIn呢是要考虑偏移量dy的,因此屏幕之外的子view都需要填充进来,因为是先填充,后移动,因此我必须填充屏幕之外的子view,因为接下来就要进行子view的整体移动了,如果不填充屏幕外的子view,等到后面移动了屏幕上是要留有空白的。但是fillViewIn不是从头到尾填充子view的,因为是滑动,因此我只考虑填充将进入屏幕的子view,回收的事交给后面的第4步回收子view处理。那需要填充多少个子view呢,这个其实都是在前面修正dy的时候计算好的。
上一段手指下滑时头部有多少子view将要进入屏幕的代码,当然我这里使用的是有多少行子view进入屏幕
int topPos = getPosition(topView);
//剩余行数
int leftRow = topPos / mColumnCount;
//偏移行数
int dyRow = ((-dy + 1) / mDecorateHeight) + 1;
inRowCount = Math.min(leftRow, dyRow);
int maxDelta = leftRow * mDecorateHeight - topOffset;
delta = Math.min(-dy, maxDelta);
其中leftRow代表在当前的topView之前还有多少行子view可以进入屏幕,其中leftRow = topPos / mColumnCount,就是使用topView的position除于列数mColumnCount就是剩余的行数,大家可以在纸上画一下计算一下,有时候比较难理解的计算,在纸上画个图就很容易能理解的;然后计算在偏移量dy下要偏移的行数dyRow = ((-dy + 1) / mDecorateHeight) + 1,-dy只是取个正数而已,因此此时dy是负数的,那为什么要-dy加1呢?主要是防止dy刚好等于itemView高度mDecorateHeight的情况,如果刚好相等,不加1的话计算出来的dyRow就等于2了,明显是不对的。这时候可能就有人有疑问了,假如dy很小,小到topView向下偏移dy后还没有新的子view进入屏幕,但是这时候计算出来的dyRow是1,代表有一行子view进入屏幕,岂不是错误的,但是别忘了,这里只是计算有多少行子view进入屏幕,如果没有新的子view进入屏幕的话是没有填充的,这时候的dyRow自然是忽略的。
上fillViewIn代码,讲述代码前想说明一点的是所有的代码都是我在探索如何实现自己的自定义LayoutManager过程中写的,当时我的关注点是在如果实现上而不是代码最优上,甚至有的地方还有代码冗余也不一定,不过这个的代码优化还是留给大家自己动手的时候去优化吧,要真正学会自定义LayoutManager,还是要有自己的逻辑思考过程的。
private void fillViewIn(RecyclerView.Recycler recycler, int direction, int inRowCount) {
if (getChildCount() == 0) {
return;
}
View topView = getChildAt(0);
View bottomView = getChildAt(getChildCount() - 1);
int topOffset = getDecoratedTop(topView);
int firstVisiPos = 0;
int leftOffset = getDecoratedLeft(topView);
int fillViewCount = 0;
switch (direction) {
case TOP_IN:
topOffset -= mDecorateHeight;
firstVisiPos = getPosition(topView);
firstVisiPos -= 1;
fillViewCount = inRowCount * mColumnCount;
break;
case BOTTOM_IN:
firstVisiPos = getPosition(bottomView);
firstVisiPos += 1;
topOffset = getDecoratedBottom(bottomView);
fillViewCount = (inRowCount - 1) * mColumnCount +
(getItemCount() - firstVisiPos > mColumnCount ? mColumnCount :
getItemCount() - firstVisiPos);
break;
}
if (direction == BOTTOM_IN) {
//底部填充,从上到下填充
for (int i = 0; i < fillViewCount; i++) {
if ((i + firstVisiPos) > getItemCount() - 1) {
break;
}
//计算宽度 包括margin
if (leftOffset + mDecorateWidth > getHorizontalSpace()) {
//当前行排列不下,新起一行
leftOffset = getPaddingLeft();
topOffset += mDecorateHeight;
}
View child = recycler.getViewForPosition(i + firstVisiPos);
layoutChild(child, leftOffset, topOffset,
leftOffset + mDecorateWidth,
topOffset + mDecorateHeight);
leftOffset += mDecorateWidth;
}
} else if (direction == TOP_IN) {
//头部填充,从下到上填充,因为要保持子view的position位置
int rightOffset = getWidth() - getPaddingRight();
for (int i = 0; i < fillViewCount; firstVisiPos--, i++) {
if (firstVisiPos < 0) {
break;
}
//计算宽度 包括margin
if (rightOffset - mDecorateWidth < getPaddingLeft()) {
//当前行排列不下,新起一行
rightOffset = getWidth() - getPaddingRight();
topOffset -= mDecorateHeight;
}
View child = recycler.getViewForPosition(firstVisiPos);
layoutChild(child, rightOffset - mDecorateWidth, topOffset,
rightOffset,
topOffset + mDecorateHeight, 0);
rightOffset -= mDecorateWidth;
}
}
}
代码有点多,但其实也是分了两种情况(头部有新view进入屏幕TOP_IN和底部有新view进入屏幕BOTTOM_IN)的代码处理而已,参数direction代表是TOP_IN还是BOTTOM_IN,inRowCount代表了准备进入屏幕的子view有多少行,下面会根据inRowCount计算出将要进入屏幕的子view个数,TOP_IN的子view个数肯定是inRowCount的整数倍的,BOTTOM_IN的就要考虑是否到了最底部了,只分析BOTTOM_IN的情况。
扣出计算firstVisiPos和fillViewCount的代码
case BOTTOM_IN:
firstVisiPos = getPosition(bottomView);
firstVisiPos += 1;
topOffset = getDecoratedBottom(bottomView);
fillViewCount = (inRowCount - 1) * mColumnCount +
(getItemCount() - firstVisiPos > mColumnCount ? mColumnCount :
getItemCount() - firstVisiPos);
其实前面已经计算出将要进入屏幕的子view有inRowCount行,那么接下来要计算的firstVisiPos和fillViewCount就不是什么难事了,只不过在计算子view个数fillViewCount的时候要考虑一下是否到了最尾部,因为最尾部的子view可能不够一列了,大家还是动手在纸上计算一下吧。
下面就到了第3步,子view移动了,很简单offsetChildrenVertical(delta),delta是修正后的偏移值
最后一部是回收子view,先上代码
if (direction != -1) {
if (direction == TOP_IN || direction == BOTTOM_IN) {
recycleViewIn(dy, recycler);
} else if (direction == TOP_OUT || direction == BOTTOM_OUT) {
recycleViewOut(recycler, direction);
}
}
回收子view我在这里分了两种情况,一种是有旧的子view移出屏幕的时候回收将要移出屏幕,一种是新的子view进入屏幕的时候遍历整个getChildCount个子view,回收越界的子view。
private void recycleViewOut(RecyclerView.Recycler recycler, int direction) {
if (getChildCount() == 0) {
return;
}
if (direction == TOP_OUT) {
for (int i = 0; i < mColumnCount; i++) {
View child = getChildAt(0);
removeAndRecycleView(child, recycler);
}
} else if (direction == BOTTOM_OUT) {
int lastPos = getPosition(getChildAt(getChildCount() - 1));
int removeCount = lastPos % mColumnCount + 1;
for (int i = 0; i < removeCount; i++) {
View child = getChildAt(getChildCount() - 1);
removeAndRecycleView(child, recycler);
}
}
}
代码很简单,因为这里只考虑而且只需要考虑回收头部一行或者尾部一行的代码。为什么就不会出现要回收两行甚至更多行的子view呢?因为如果移出了两行或者更多行的子view,就意味着肯定是会有新的子view进入屏幕的,还记得前面说的吗?如果伴随着有新的view进入屏幕,那么这一次的滑动事件走的就是进入屏幕的代码逻辑了。代码逻辑简单,只是循环调用了removeAndRecyclerView移除并回收不可见的子view,大家自己体会吧。
还有最后就是新的view进入屏幕的回收了,其实这里的逻辑也比较清晰,因为此时屏幕上已经填充好所有的子view,并且子view也集体移动了,因此我们只需要遍历所有的子view,回收在屏幕之外的子view就OK了,不多说,直接上代码
protected void recycleViewIn(int dy, RecyclerView.Recycler recycler) {
int topOffset = getPaddingTop();
//回收越界子View
if (getChildCount() > 0 ) {
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (dy > 0) {
//需要回收当前屏幕,上越界的View
if (getDecoratedBottom(child) < topOffset) {
removeAndRecycleView(child, recycler);
}
} else if (dy < 0) {
//回收当前屏幕,下越界的View
if (getDecoratedTop(child) > getHeight() - getPaddingBottom()) {
removeAndRecycleView(child, recycler);
}
}
}
}
}
以上就是我是如何学会自定义LayoutManager的整个过程了,在最后还想强调一下上面所说的比较重要的两点中的第一点,整体思路的把握,因为在做一件事情之前有一个整体的清晰的思路很重要,不然就只能像个无头苍蝇到处乱撞。我们把一次次的scrollVerticallyBy的回调看成是一次次的滑动事件,然后把其中的一次滑动事件独立出来单独分析,每一次的滑动事件都会完整的经历dy修正、填充子view、子view的移动、子view的回收这一完整的流程。而第二个难点,边界的判定是硬指标。
最后附上一份demo:自定义LayoutManager