Android 学习之路从自定义View,自定义ViewGroup走到现在,难度越来越大,当然也能学到很多东西,而自定义RecyclerView的LayoutManager既是对之前所了解的View绘制机制\事件传递机制的巩固,也是一种提升。
Google自定义了一个ViewGroup叫做RecyclerView,却不走寻常路,把这个ViewGroup的测量和布局交给另一个类完成,为什么要这样做?能带来什么好处?到底它是怎么做到这一点的?疑问那么多,与其上网搜索答案,不如自己动手实现一个LayoutManager,探索其中的原理。
首先对于自定义LayoutManager推荐两篇文章:
自定义LayoutManager实现流式布局
创建一个 RecyclerView的LayoutManager - 第一部分
我也是看了这两篇文章,对自定义LayoutManager有了初步的了解,接下来就对我的自定义之旅做一个记录:
先思考怎么测量、布局:
我的目的是实现一个横竖都可以滚动的RecyclerView,如下图:
我们需要自定义一个SingleLinearLayout,这个ViewGroup非常简单,它不限制子View的大小,也就是它的MeasureMode是UNSPECIFIED,它多高,多宽,全凭子View决定,这样设计的原因在于RecyclerView默认传递给子View的Mode是AT_MOST,Size给的是0,这样如果SingleLinearLayout直接把这些参数传递给TextView,它算出来的宽高就是0,这明显不是我们想要的,我们希望TextView不要有压力,根据内部数据算出多少就是多少。然后根据算出的宽高,把每个TextView都横向layout一排。
图上可以看出,我们的控件实际上比屏幕大得多,因此我们的LayoutManager需要支持横向和纵向的滚动,不过在此之前,我们还需要将每行的SingleLinearLayout给布局到RecyclerView上,这就是==onLayoutChildren方法==要完成的任务了。
要完成LayoutManager是非常复杂的,它的可定制性太高,所以Google强调,只要我们能完成需求即可,不要过度设计。这点查看LinearLayoutManager可看到足有两千行,方方面面都要考虑到。我们为了简化问题,先从最简单的考虑:
如果我们不考虑回收,实现onLayoutChildren非常简单,如同我们自定义一个普通的ViewGroup,把一个一个的子View都竖直摆放就行;但我们没必要布局不在屏幕中显示的View,那就当发现子View摆到了屏幕的末尾,就停止布局过程:
minPos = 0;
lastVisPost = getItemCount() - 1;
offsetTop = 0;
// 填充View
for (int i = minPos; i <= lastVisPos; i++) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChild(child,0,0);
...
if (offsetTop - dy > getHeight()) {
// 到了屏幕的末尾 退出布局
removeAndRecycleView(child,recycler);
lastVisPos = i - 1;
} else {
int w = getDecoratedMeasuredWidth(child);
int h = getDecoratedMeasuredHeight(child);
offsetTop+=h;
...
// 布局到RV上
layoutDecorated(child,aRect.left,aRect.top,aRect.right,aRect.bottom);
}
}
但是RecyclerView还必须能回收掉不在屏幕中的View,这里我们可以参考上面说到的博客,把回收View做成一个独立的过程,虽然会带来一些冗余的计算,但比其它的一些自定义LayoutManager复杂的滑动回收计算简洁很多:
//回收越界子View
if (getChildCount() > 0) {//滑动时进来的
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (dy > 0) {//需要回收当前屏幕,上越界的View
if (getDecoratedBottom(child) < offsetTop) {
removeAndRecycleView(child,recycler);
firstVisPos++;
}
} else if (dy < 0) {//回收当前屏幕,下越界的View
if (getDecoratedTop(child) > getHeight()) {
removeAndRecycleView(child,recycler);
lastVisPos--;
}
}
}
}
回收原理很简单,这里不再赘述。但是LayoutManager的重头戏在于滑动,滑动时候能动态的添加和回收View,如何完成滑动过程?首先滑动一定是一个不断对子View重新布局的过程,所以我们肯定要把onLayoutChildren方法中的代码抽一部分做成函数(fill方法)以供滑动调用,其次我们还必须记录垂直滑动和水平滑动的位移以判断滑动范围,在下滑时对子view的位置信息做一个保存,上滑时利用保存的位置信息恢复View的位置,同时考虑水平位移和垂直位移对位置信息的影响,fill时减去相应的位移。思路就是这样:在scrollVerticallyBy和scrollHorizontallyBy记录位移信息,同时判断是否超过滑动范围,然后在fill()中根据子View的位置和位移距离重新布局。
@Override
public int scrollVerticallyBy(
int dy,RecyclerView.Recycler recycler,RecyclerView.State state) {
if (dy == 0 || getChildCount() == 0) {
return 0;
}
int realOffset = dy;
View topView = getChildAt(0);
View bottomView = getChildAt(getChildCount() - 1);
...
if (verticalOffset + realOffset < 0) {
//下划到了顶部
realOffset = -verticalOffset;
} else if (realOffset > 0) { //是否下滑到了底部
//利用最后一个子View比较修正
if (getPosition(bottomView) == getItemCount() - 1) {
int gap = getHeight() - getPaddingBottom() - getDecoratedBottom(bottomView);
if (gap > 0) {
realOffset = -gap;
} else if (gap == 0) {
realOffset = 0;
} else {
realOffset = Math.min(realOffset,-gap);
}
}
}
//布局 , 并且吃掉多余的滑动
realOffset = fill(recycler,state,realOffset);
verticalOffset += realOffset;
offsetChildrenVertical(-realOffset);
return realOffset;
}
//横向滑动不考虑回收和布局的,简单多了
@Override
public int scrollHorizontallyBy(
int dx,RecyclerView.Recycler recycler,RecyclerView.State state) {
View aView = getChildAt(0);
...
if (horizontalOffset + dx > aViewWidth - getWidth()) {
dx = 0;
} else if (horizontalOffset + dx <= 0) {
dx = 0;
}
horizontalOffset += dx;
offsetChildrenHorizontal(-dx);
return dx;
}
...
//回收View
...
// fill 的部分代码
if (dy >= 0) { // 下滑 或者 初次进入
int minPos = firstVisPos;
lastVisPos = getItemCount() - 1;
if (getChildCount() > 0) { // 下滑
View lastView = getChildAt(getChildCount() - 1);
minPos = getPosition(lastView) + 1; //从最后一个 View + 1 开始
offsetTop = getDecoratedBottom(lastView);
}
// 填充View
for (int i = minPos; i <= lastVisPos; i++) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChild(child,0,0);
if (offsetTop - dy > getHeight()) {
// 到了屏幕的末尾 退出布局
removeAndRecycleView(child,recycler);
lastVisPos = i - 1;
} else {
int w = getDecoratedMeasuredWidth(child);
int h = getDecoratedMeasuredHeight(child);
//记录View的位置信息
Rect aRect = mItemAnchorMap.get(i);
if (aRect == null) {
aRect = new Rect();
}
//注意水平位移影响
aRect.set(-horizontalOffset,offsetTop,-horizontalOffset + w,offsetTop + h);
mItemAnchorMap.put(i,aRect);
offsetTop += h;
// 布局到RV上
layoutDecorated(child,aRect.left,aRect.top,aRect.right,aRect.bottom);
}
}
//添加完后,判断是否已经没有更多的ItemView,并且此时屏幕仍有空白,则需要修正dy
View lastChild = getChildAt(getChildCount() - 1);
if (getPosition(lastChild) == getItemCount() - 1) {
int gap = getHeight() - getPaddingBottom() - getDecoratedBottom(lastChild);
if (gap > 0) {
dy -= gap;
}
}
} else {
//上滑 , 通过mItemAnchorMap 拿到布局信息
int maxPos = getItemCount() - 1;
firstVisPos = 0;
if (getChildCount() > 0) {
View firstView = getChildAt(0);
maxPos = getPosition(firstView) - 1;
}
for (int i = maxPos; i >= firstVisPos; i--) {
Rect aRect = mItemAnchorMap.get(i);
if (aRect != null) {
if (aRect.bottom - verticalOffset - dy < 0) {
firstVisPos = i + 1;
break;
} else {
View child = recycler.getViewForPosition(i);
addView(child,0);
measureChild(child,0,0);
//修正水平、垂直位移影响
layoutDecorated(child,aRect.left - horizontalOffset,
aRect.top - verticalOffset,aRect.right - horizontalOffset,
aRect.bottom - verticalOffset);
}
}
}
}
思路有了代码实现起来就不是很难,到此,这个能容纳大表格的RecyclerView就已经完成了,我们并没有动RecyclerView的任何函数,却通过LayoutManager完成了一个全新的ViewGroup,难怪alibaba开源了一个项目—–vlayout ,只通过自定义LayoutManager就实现了现有的ViewGroup并且有很多扩展。
通过自己的实验,亦对开篇提出的三个问题有了一定的认识:RecyclerView最大的特点就是适宜大量View的展现,回收不必要的View防止内存溢出,为此,拆分回收机制和布局机制,利于解耦,方便理解。正如上面实现的LayoutManager对回收和布局分开处理,也是这一思想的体现。有机会可以再深入阅读下RecyclerView的源码,搞懂preLayout和RecyclerView两次调用onLayoutChildren的原因,顺便附上源码,以及自己查阅源码的过程中,关于auto-measure过程的翻译:
gitHub地址