自定义RecyclerView悬浮吸顶效果的LayoutManager

时间过得飞快,距离上次写博客又过了一个半月,想了想不该偷懒下去了,然后就把前段时间写的一个 LayoutManager 拿出来写一下。RecycleView 相信大家一定十分熟悉了,相比 ListView 它具备更多级的缓存以及定向刷新的特性,除此之外也就是本文要说的自定义 LayoutManager 也是十分的靓仔,和传统使用 ViewGroup 布局相比,它增加了回收和复用子 Item 的能力,用起来是真香。

一般在做通讯录的时候,我们会为用户分组,从而绘制的时候也需要绘制各组的头部,如果在使用 RecycleView 来实现这种效果的时候,通常我们会使用 ItemDecoration ,ItemDecoration 在测量时为每个(或指定的)子 View 的 LayoutParams 设置四边的偏移量,然后 LayoutManager 在使用 layoutDecoratedWithMargins() 时会根据相应的偏移量来布局 childView ,我们再根据 childView 之间的留白在 ItemDecoration#onDraw() 时来绘制我们的分组头部,在 ItemDecoration#onDrawOver() 时绘制被移出屏幕的最近一个头部,最终达到我们要的效果。不过,如果我们想要这个头部支持点击怎么办?ItemDecoration 顾名思义就是为 Item 添加装饰物,是不支持点击交互的,那么就需要我们另外想法子了。我的方法是自定义一个 LayoutManager。

先见效果图:

先声明以下 LLM 为 LinearLayoutManager 的简写),FLM 为本文自定义LayoutManger的简称。

自定义 LayoutManager 第一步,返回自定义的 LayoutParams 

@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
    return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
}

 这里我们要做的布局类似于线性布局,所以采用宽高都有view自己决定的测量参数,如果是网格布局或者是交错式网格布局,则会根据方向进行调整。

然后就是第二步,重写 onLayoutChildren 方法,我们先来看一看 LLM 在这里是怎么写的,代码很长,所以简略掉其中各种判断,我们只贴其中的关键行:

    /**
     * {@inheritDoc}
     */
    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        ......

        // 不需要布局,移除并回收所有的view
        if (mPendingSavedState != null || mPendingScrollPosition != RecyclerView.NO_POSITION) {
            if (state.getItemCount() == 0) {
                removeAndRecycleAllViews(recycler);
                return;
            }
        }
        ......

        // 收集计算锚点信息
        ......

        // 预布局动画相关代码
        ......

        // 遍历所有的 child 根据其 ViewHolder 的不同状态将 ViewVolder 添加到不同的缓存集合
        detachAndScrapAttachedViews(recycler);
        ......
        // 根据布局方向填充
        if (mAnchorInfo.mLayoutFromEnd) {
            // fill towards start
            ......
            fill(recycler, mLayoutState, state, false);
            ......
        } else {
            // 另外一个方向
            ......
        }
        // 修正偏差,LinearLayoutManager内部的scrap缓存池未移除的view进行布局(应该是与动画相关)
        ......
    }

然后是 fill() 方法里面的关键行:

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
            RecyclerView.State state, boolean stopOnFocusable) {
        ......
        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
            
            ......
            // 这里进行单个view进行布局
            layoutChunk(recycler, state, layoutState, layoutChunkResult);
            ......
        }
        ......
        return start - layoutState.mAvailable;
    }


void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
            LayoutState layoutState, LayoutChunkResult result) {
        View view = layoutState.next(recycler);
        ......

        RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
        if (layoutState.mScrapList == null) {
            if (mShouldReverseLayout == (layoutState.mLayoutDirection
                    == LayoutState.LAYOUT_START)) {
                addView(view);
            } else {
                addView(view, 0);
            }
        } else {
            if (mShouldReverseLayout == (layoutState.mLayoutDirection
                    == LayoutState.LAYOUT_START)) {
                addDisappearingView(view);
            } else {
                addDisappearingView(view, 0);
            }
        }
        measureChildWithMargins(view, 0, 0);
        result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
        int left, top, right, bottom;
        if (mOrientation == VERTICAL) {
            if (isLayoutRTL()) {
                right = getWidth() - getPaddingRight();
                left = right - mOrientationHelper.getDecoratedMeasurementInOther(view);
            } else {
                left = getPaddingLeft();
                right = left + mOrientationHelper.getDecoratedMeasurementInOther(view);
            }
            if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
                bottom = layoutState.mOffset;
                top = layoutState.mOffset - result.mConsumed;
            } else {
                top = layoutState.mOffset;
                bottom = layoutState.mOffset + result.mConsumed;
            }
        } else {
            top = getPaddingTop();
            bottom = top + mOrientationHelper.getDecoratedMeasurementInOther(view);

            if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
                right = layoutState.mOffset;
                left = layoutState.mOffset - result.mConsumed;
            } else {
                left = layoutState.mOffset;
                right = layoutState.mOffset + result.mConsumed;
            }
        }
        // We calculate everything with View's bounding box (which includes decor and margins)
        // To calculate correct layout position, we subtract margins.
        layoutDecoratedWithMargins(view, left, top, right, bottom);
        if (DEBUG) {
            Log.d(TAG, "laid out child at position " + getPosition(view) + ", with l:"
                    + (left + params.leftMargin) + ", t:" + (top + params.topMargin) + ", r:"
                    + (right - params.rightMargin) + ", b:" + (bottom - params.bottomMargin));
        }
        // Consume the available space if the view is not removed OR changed
        if (params.isItemRemoved() || params.isItemChanged()) {
            result.mIgnoreConsumed = true;
        }
        result.mFocusable = view.hasFocusable();
    }

当布局存在剩余空间,并且当前 position 小于最大布局个数时,对当前 position 进行布局,然后

View view = layoutState.next(recycler);

这句代码很关键了,这里是 LLM 获取每一行 View 的实现,首先从内部自己的废料池 scrapList 获取缓存的View,如果找不到再从 Recycle 获取相应 position 的 view:

recycler.getViewForPosition(mCurrentPosition);

这是 RecyclerView 内部的缓存复用实现,依照我自己的理解,

1.如果是预布局的话回收器首先去检查 mChangedScrap 缓存池,如果该缓存池存在同一个 position 的 vh 则返回此 vh 若找不到,假使 Adapter 设置 hasStableIds 为 true ,则再判断 vh 的 itemId 是否相等,若相等,则返回该 vh 此等级返回的vh将加上来源标志位;

2.如果第一步返回的 vh 为空,则回收器再从 mAttachedScrap 缓存池查找缓存,若该缓存池有 vh 满足条件,则返回此 vh,若无再从一级缓存 mCacheViews 是否有对应的ViewHolder,如果有并且可用,那么返回该ViewHolder;

3.第2步返回的 vh 仍然是空(或者不可用被及时回收了)的话,如果 RecyclerView 设置了 mViewCacheExtension (扩展缓存池),将从该扩展缓存池获取 vh;

4.如果第3步返回的 vh 是空的,回收器将根据 itemType 从 recyclerPool 回收池中获取 vh,从回收池中获取的 vh 对象将重置数据(需要重新绑定数据);

5.如果以上4步都没有获取到 vh 的话,就轮到我们的 adapter 出场了,耳熟能详的 adapter.onCreateViewHolder 将为 RecyclerView 创建一个全新的 vh.

6.获取到的 vh 在返回前需要检查是否需要重新绑定数据即 adapter.onBindViewHolder(),根据Flag标志位 未绑定 或 需要刷新数据 或 已经被标记无效 来判定。

通常我们说的 RecyclerView 有四级缓存也就是 mChangedScrap,mAttachedScrap,mCacheViews,recyclerPool 这四个对象。

好了,分析完 LLM 的布局,我们也找出了一些关键代码,接下来就是实战了。

1.返回自定义的 LayoutParams ,同 LLM,略过;

2.按照 LLM 的写法:

@Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (state.getItemCount() == 0) {
            // 空布局,回收所有item
            removeAndRecycleAllViews(recycler);
            return;
        }
        //暂时移除所有的item
        detachAndScrapAttachedViews(recycler);
        //真正的布局
        relayout(recycler, state);
    }

在不考虑回收复用的情况下,以线性布局的布局方式来布局,我们需要先遍历获取每一个 item,然后测量 view 的宽高,最后layout,贴一下代码:

int mTotalHeight;

private void relayout(RecyclerView.Recycler recycler, RecyclerView.State state) {
        int itemCount = state.getItemCount();
        mTotalHeight = 0;
        int paddingLeft = getPaddingLeft();
        int paddingTop = getPaddingTop();
        for (int i = 0; i < itemCount; i++) {
            View view = recycler.getViewForPosition(i);
            addView(view);
            measureChildWithMargins(view, 0, 0);
            int width = getDecoratedMeasuredWidth(view);
            int height = getDecoratedMeasuredHeight(view);
            layoutDecoratedWithMargins(view, paddingLeft, mTotalHeight + paddingTop, paddingLeft + width, mTotalHeight + paddingTop + height);
            mTotalHeight += height;
        }
    }

这样我们就完成了一个线性布局,但是它还不支持滚动,我们需要重写两个方法:

@Override
    public boolean canScrollVertically() {
        return true;
    }

    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        //回收所有的vh
        detachAndScrapAttachedViews(recycler);
        int lastHeight = mTotalHeight - mExtentHeight;
        int consume = dy;
        //边界判断,使最终的垂直滚动距离不小于0也不大于内容高度减去可见高度
        //这里可见内容高度我们暂时定为getHeight() - getPaddingTop() - getPaddingBottom()
        if (verticallyScrollOffset + dy < 0) {
            //上边界超出
            consume = -verticallyScrollOffset;
        } else if (verticallyScrollOffset + dy > lastHeight && mTotalHeight >= mExtentHeight) {
            //下边界超出
            consume = lastHeight - verticallyScrollOffset;
        }
        //计算垂直滚动距离
        verticallyScrollOffset += consume;
        //关键-使每个itemView 偏移指定距离
        offsetChildrenVertical(-consume);
        //重新布局
        relayout(recycler, state);
        return consume;
    }

然后对relayout进行修改:layoutDecoratedWithMargins(view, paddingLeft, mTotalHeight + paddingTop +verticallyScrollOffset , paddingLeft + width, mTotalHeight + paddingTop + height + verticallyScrollOffset );这样才能正确布局到指定位置。

到此我们已经完成了线性布局且支持垂直有边界的滑动,但是每次 relayout 的时候我们都布局了所有的view,那岂不是意味有多少个 itemCount 就有多少个 viewholder ,那 recyclerview 的复用功能岂不是没有用到?咋整?事实上我们每次只需要布局可见部分的那几个itemView就可以了,所以我先创建一个集合维护每个 position 的测量高度,然后根据垂直滚动距离计算第一个可见的 position ,然后从第一个可见 item ,根据可见内容高度填充view,直到最后一个可见的 position,见代码:

private SparseArray viewInfoArray = new SparseArray<>();
private SparseIntArray itemHeightArray = new SparseIntArray();
private int firstVisibleItemPosition = 0;
private int lastVisibleItemPosition = 0;

private void relayout(RecyclerView.Recycler recycler, RecyclerView.State state) {
        int itemCount = state.getItemCount();
        int top = 0;
        int paddingLeft = getPaddingLeft();
        int paddingTop = getPaddingTop();
        int position = 0;
        //循环计算第一个可见的position 以及该 position 的top
        while (itemHeightArray.size() != 0 && position < itemCount - 1) {
            int height = itemHeightArray.get(position);
            top += height;
            position++;
            if (top - verticallyScrollOffset >= 0) {
                top -= height;
                position--;
                break;
            }
        }
        firstVisibleItemPosition = position;
        int visibleHeight = getHeight() - getPaddingTop() - getPaddingBottom();
        //循环计算并收集可见 item 的布局信息
        while (true) {
            View view = recycler.getViewForPosition(position);
            measureChildWithMargins(view, 0, 0);
            int width = getDecoratedMeasuredWidth(view);
            int height = getDecoratedMeasuredHeight(view);
            //这里维护每个position 对应的高度
            itemHeightArray.put(position, height);
            ViewInfo viewInfo = viewInfoArray.get(position);
            if (viewInfo == null) {
                viewInfo = new ViewInfo();
            }
            int t = top + paddingTop - verticallyScrollOffset;
            viewInfo.rect.set(paddingLeft, t, paddingLeft + width, t + height);
            viewInfo.position = position;
            viewInfo.view = view;
            viewInfo.measureTop = t;
            viewInfoArray.put(position, viewInfo);
            top += height;
            if (top > visibleHeight + verticallyScrollOffset || position == itemCount) {
                position--;
                break;
            }
        }
        lastVisibleItemPosition = position;
        //遍历可见position 根据收集的布局信息进行布局
        for (int i = firstVisibleItemPosition; i <= lastVisibleItemPosition; i++) {
            ViewInfo viewInfo = viewInfoArray.get(i);
            //TODO layout visible item 
        }
    }

到这一步我们就可以发现列表滑动十分丝滑了,BUT:

01/06 补充:

使用场景:adapter 数量都为100个,item 行高一致(50dp),可见数目11~13个,

LLM 静态展示12个 Item,缓慢滚动时新创建ViewHolder 4个,快速滚动创建1个 ViewHolder,最多17个;

FLM 静态展示12个 Item,缓慢滚动时新创建ViewHolder 4个,快速滚动时偶尔创建 n 个 ViewHolder,最多无上限;

问题出现了,讲道理,FLM 单纯拿来使用已经没有什么问题,回收和复用利用起来了,滚动也十分流畅,但是它和 LLM 的差距在哪里,为什么快速滚动的时候 FLM 还会再去创建 ViewHolder 呢?阅读 LLM 的 scrollB() 之后我发现了第一个问题:

1.LLM 这里它并没有调用 detachAndScrapAttachedViews ,而是根据滑动的距离,移除不可见的之后,再填充空白的区域,这样 LLM 每次只布局了 空白处的 item,然后再 offset 其余不需要再次布局的 item,相对于我们的 FLM 来说它减少了布局次数,速度那肯定更快了。

2.根据前面的问题,我改动了代码

public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
    //根据边界计算滑动距离
    //......
    //移除不可见item
    recycleUnVisibleItem(recycler);
    //填充空白
    fillBlankSpace(recycler,state);
    //偏移可见的item
    offsetChildren(dy);
    return dy;
}

 但这并不是 FLM 快速滑动时重新创建ViewHolder的原因,继续阅读源码后我又发现了问题, LLM 是在 layoutChunk 中先回收超出屏幕的无效 Item,然后依次从最后可见的 position 填充 Item,然后再检查该复用的 Item 是否需要回收,Why?为什么layout之后又需要回收呢?假设快速滚动单次距离超过了一屏,这时上一次最后可见的 Item 已经在屏幕外了,依次填充下一个Item时,这个 Item 也有可能也是不可见的,所以 LLM 在对这个 Item 布局之后又检测了是否需要回收。

3.根据以上问题,再次修改代码,这次改动的地方在填充 Item 的地方,每次填充后判断该 Item 是否超出屏幕范围:

private void fillBlankSpace(RecyclerView.Recycler recycler){
    //计算空白区域需要布局的Item范围
    ......
    for(...){
        layoutItem(recycler,i);
    }
}

private void layoutItem(RecyclerView.Recycler recycler,int position){
    ViewInfo viewInfo = viewInfoArray.get(position);
    //布局
    ......

    if(viewInfo.rect.top >= getHeight() || viewInfo.rect.bottom <= 0){
        removeAndRecycleView(viewInfo.view, recycler);
    }
}

因为本例子单页可见 Item 数量约12个,按照单次滚动距离超一屏的情况,我们每次填充的个数可能超过12个达到14个或15个,加上以上代码之后我们可以保证单次 layout 的个数不超过可见数量了,但是,它还是出现了,onCreateViewHolder 还是被调用了,于是我在回收不可见 Item 的代码之后加上了日志,检测各级缓存的大小,终于找到了问题根源,RecyclerViewPool 缓存池单个 viewType 最大缓存个数默认值为5,而快速滚动时单次回收 view 的个数可能为全部的 View,导致 RecyclerViewPool 缓存溢出,该次回收的 Viewholder 被丢弃了。要解决这个问题,其实设置 RecyclerViewPool 的最大个数也能解决问题,但是精益求精嘛,我们看看 LLM 是怎么做,再次阅读源码,嚼了嚼,emm,LLM 应该是在每移除1个不可见的Item 时就往空白区域布局一个回收池里的 Item,这样缓存池可以做到一边进一边出的平缓回收复用,而像 FLM 这样每次回收好几个甚至所有的 Item 然后再从缓存池取出大量的 Item,就可能会导致缓存不够用进而再次创建 ViewHolder的情况,显得缓存抖动十分严重。

4.总算发现了问题的根源,所以再次修改代码:

public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
    //根据边界计算滑动距离
    //......
    while(true){
        //移除单个不可见item
        boolean remove = recycleUnVisibleItem(recycler);
        //填充空白处单个item
        boolean fill = fillBlankSpace(recycler,state);
        //没有可移除或者没有可添加的Item时跳出循环
        if(!remove && !fill){
            break;
        }
    }
    //偏移可见的item
    offsetChildren(dy);
    return dy;
}

现在我们再看一下效果,录屏的手机尺寸比较大,所以可见个数为13个: 

可以看到滑动丝滑并且复用到了极致,媲美官方的 LinearLayoutManager,当然在方向以及动画等方面是不如 LLM 了。

好了,回到本主题,打造悬浮吸顶的 LayoutManager 我们继续下一步,悬浮 item,比如我们想要让 position = 1 的 view 作为悬浮头部,那么我们在 layout 时需要做如下判断:

1.如果第一个可见的 position 小于指定悬浮 position 那么不需要做悬浮特殊处理,它还是依然处于它原来的位置;

2.如果第一个可见正好是指定悬浮头部,那么需要对该头部的 top 重新计算使其能贴合头部边缘完整展示,然后因为 view 层级关系,必须在最后 addView(),使其位于其他 view 上层;

3.如果第一个可见 position 大于悬浮头部 position ,那么需要补充测量悬浮头部,因为根据之前的测量逻辑,可见的 position 是不包含悬浮 position 的,测量完毕后,同理,需要在最后 addView();

按照之前的写法,我们在滑动的回调里面执行和 onLayoutChildren 类似的逻辑我们就能达到悬浮吸顶的效果了,但是,我们要有所追求,在达到平缓使用缓存池的前提下,完成悬浮吸顶才是我们的目标~~ GO ON~~

写在最后:

快过年放假了,这几天事情也比较多,所以现在才来把后面的补充上,经过实践已经完成了平缓使用缓存池的代码,不过仍然存在相关问题:不支持定向刷新,移除和新增,只支持 notifyDataSetChanged;由于缓存的Item一直处于可见,本次自定义 LM 对悬浮的 Item 再次进行缓存,所以悬浮的 Item 第一次数据绑定之后不再走onBingViewHolder 除非重新设置数据集合,建议对悬浮 Item 的 itemType 进行区别返回,避免和复用的 itemType 重复。悬浮的 Item 数量多的情况下,若顶部没有复用底部回收的 Item (悬浮Item占用了定不可见区域),也可能导致缓存池溢出。

适用场景:数据集变化少,需要悬停某一个 Item 时。源码见 GitHub。

 

你可能感兴趣的:(自定义RecyclerView悬浮吸顶效果的LayoutManager)