实现一个长表格控件 ---- 自定义LayoutManager学习

Android 学习之路从自定义View,自定义ViewGroup走到现在,难度越来越大,当然也能学到很多东西,而自定义RecyclerView的LayoutManager既是对之前所了解的View绘制机制\事件传递机制的巩固,也是一种提升。

Google自定义了一个ViewGroup叫做RecyclerView,却不走寻常路,把这个ViewGroup的测量和布局交给另一个类完成,为什么要这样做?能带来什么好处?到底它是怎么做到这一点的?疑问那么多,与其上网搜索答案,不如自己动手实现一个LayoutManager,探索其中的原理。

首先对于自定义LayoutManager推荐两篇文章:

自定义LayoutManager实现流式布局

创建一个 RecyclerView的LayoutManager - 第一部分

我也是看了这两篇文章,对自定义LayoutManager有了初步的了解,接下来就对我的自定义之旅做一个记录:

先思考怎么测量、布局:
我的目的是实现一个横竖都可以滚动的RecyclerView,如下图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-otmGTUzZ-1631676335536)(http://note.youdao.com/yws/api/personal/file/1084212577CB4B5D92E715561ABFC815?method=download&shareKey=27505de4065957d0d3be828b268e2587)]

我们需要自定义一个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地址


自动绘制的工作流程如下:

  1. LayoutManager应该调用setAutoMeasureEnabled(true)来开启它,注意所有framework中的LayoutManager都使用了自动绘制。
  2. 当RecyclerView的onMeasure被调用时,如果提供给它的measureMode是Exactly模式,RecyclerView将只会调用LayoutManager的onMeasure方法并不做任何布局计算直接返回。
  3. 如果RV的layoutwidth或layoutHeight中的一个measureMode不是Exactly,RecyclerView将开启layout过程的onMeasure(),它将处理所有挂起的Adapter的数据更新并且决定是否运行pre-layout过程。如果RecyclerView决定这样做,它首先调用onLayoutChilden(),参数中的state.isPreLayout将返回True。在这个阶段,getWidth()和getHeight()将仍返回RecyclerView最后一次的layout计算过程得到的宽高。
  4. 完成了pre-layout过程后,RecyclerView将调用onLayoutChildren(),此时isMeasuring返回true,isPreLayout返回false。LayoutManager此时能访问getWidth()/getHeight()和getWidthMode()/getHeightMode()。在layout计算完成后,RV利用children和自己的padding设置自己的绘制宽高,LayoutManager能重写setMeasuerdDimension()来处理不同的情况。例如:GridLayoutManager重写这个方法去处理这种case: 如果它是垂直布局且有三列,但是实际只有2列,它仍然以3列测量它。
  5. 在测量过程之后,RV将运行onLayoutChildren且isMeasuring为true,isPreLayout为false.RV将处理每个view的实际添加、删除、移动、改变动画,所以LM不应该担心怎么在onLayoutChildren调用的最后一次时去处理它们。
  6. 当测量完成,RecyclerView的onLayout将会被调用,RecyclerView检查布局计算是否在测量阶段已经完成,如果完成,他将重用那些信息,它可能仍然决定调用onLayoutChildren(),这种情况出现在最后一次的measureSpec不同于最后的dimensions或者在测量和布局过程中adapter的内容有所改变,最后,动画将被计算和运行。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值