自定义吸顶LayoutManager

吸顶效果

RecyclerView已经成为在Android Native开发过程中的明星组件,出镜率超高,只要需要列表展示的内容,我们第一想到的就是使用RecyclerViewRecyclerView确实是一个很容易上手功能又很强大的组件,通过设置不同的LayoutManager就可以实现不同的显示样式列表、网格等。在日常的开发过程中我经常会遇到“吸顶”这种情况,就是列表中的某些Item在滚动到列表的顶部的时候需要固定住,如上图的效果。要实现这种效果的两种最常见的方案是使用ItemDecoration和组合布局的方式,这两种方案分别有个字的优缺点这里我们简单的分析一下。

1. 使用组合布局





    
          
        
    

大体实现方案如上所示,将要吸顶的ViewHolder(为方便后面的描述我们这里把显示在RecyclerView中的ViewHolder叫真ViewHolder,飘在RecyclerView上面的叫假ViewHolder)的布局放在RecyclerView布局上层,在业务层的代码中通过监听RecyclerView的滚动事件,控制假ViewHolder的显示、隐藏以及移动等,目前市面上大部分App使用的都是这种方案(我是怎么知道的?用AS的ViewTree工具分析一下就知道了),但是这种方案存在以下缺点:

  1. 如果有多种不同的ViewHolder需要吸顶的时候,业务处理的复杂度会呈几何级数上升,这会导致bug层出不穷。
  2. 吸顶的ViewHolder如果是可交互的(例如响应横向滚动,选中等)就需要做真假ViewHolder的数据和状态的双向同步工作,如果吸顶的ViewHolder业务比较复杂,这一定是一个让人心力憔悴的活。
  3. 扩展能力弱,相似的功能复用成本很高,总是要修修补补才能复用。

也许你会问,如果真如你所说有这么多问题,那为什么还有这么多人使用这种方案?呵呵,因为简单啊,这个方案是最容易想到的不是吗?说实话这个方案我也用过,否则我咋知道会有这么多问题。

2. 使用ItemDecoration

class II extends RecyclerView.ItemDecoration{
    @Override
    public void getItemOffsets(@NonNull Rect outRect, 
                               @NonNull View view, 
                               @NonNull RecyclerView parent, 
                               @NonNull RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
    }

    @Override
    public void onDrawOver(@NonNull Canvas c, 
                           @NonNull RecyclerView parent, 
                           @NonNull RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
    }
}

ItemDecoration通常用来实现RecyclerView中item的分割线效果,利用其本身的一些特性也能做出吸顶效果来,大体思路如下:

  1. 通过ItemDecorationgetItemOffsets方法将吸顶区域空出来
  2. 通过View.getDrawingCache()拿到需要吸顶ViewHolder的bitmap
  3. 通过ItemDecorationonDrawOver将吸顶ViewHolder的bitmap绘制在吸顶区域中

该方案跟上面的使用组合布局的方案比起来,通用性要好很多,复用起来也比较方便,但是该方案也有一个致命的缺点,那就是吸顶的ViewHolder不能响应事件,如果需要吸顶的ViewHolder中有动态的内容如Gif或视频等,也不能做到很好的兼容。

3. 自定义LayoutManager

除了这两种方案还有没有别的方案?答案肯定是有的,使用LayoutManager!对,没错!我肯定我不是第一个想到这个方案的人,稍微对RecyclerView有点了解的人都会想到这个解决方案,目前我在网上还没发现(可能有只是我没找到)使用LayoutManager解决这个问题的成熟方案。RecyclerViewLayoutManager大约有1万多行代码,要想从头读到尾确实需要费点时间,我觉得其实我们也没必要从头读到尾把所有的技术细节都弄明白,只要能达到自己的目的就可以了,就拿创建一个自定义LayoutManager这件事来说我们只需要弄明白RecyclerView的缓存策略和布局流程,我觉得就可以了,如果你时间和精力充足要把它扒个底朝天那也很棒,下面我们就简单分析阅读下这两部分的源码。

真爱生命,远离源码☠️

3.1 缓存策略

RecyclerView的缓存策略一直是RecyclerView的热门知识点,不管你是想斩offer还是吹牛*这个是必备。在RecyclerViewViewHolder复用相关的逻辑都封装在Recycler中,按照顺讯分为四层:

  1. mAttachedScrapmChangedScrap

    有人说这一级缓存是告诉缓存,我就有点纳闷,“高速”是咋体现出来的?我是没看出来!这四层缓存如果按照适用场景来划分我觉得会更容易理解

    • mAttachedScrap -- 当前RecyclerView中已经有ViewHolder填充,RecyclerView又触发onLayoutChildren的时候,当前正在显示的这部分ViewHolder会被回收到mAttachedScrap中,在layoutChunk方法中被重新取出。
    • mChangedScrap -- 只会被用在预布局中

    mAttachedScrapmChangedScrap 只有在onLayoutChildren()方法调用的时候才会用到,在滚动的过程中没用,只有触发requestLayout()的时候才会调用。

  2. mCachedViews

    在滚动过程中滚出屏幕区域而被回收的ViewHolder会被加入到该层缓存,缓存数量支持自定义默认为2,按照先进先出的规则溢出。

  3. mViewCacheExtension

    用户自定义缓存

  4. mRecyclerPool

    该层缓存用于存储从mCachedView缓存中溢出的ViewHolder

RecyclerView缓存的访问顺序存取是保持一致的,回收部分的源码:

private void scrapOrRecycleView(Recycler recycler, int index, View view) {
final ViewHolder viewHolder = getChildViewHolderInt(view);
if (viewHolder.shouldIgnore()) {
    return;
}
if (viewHolder.isInvalid() && !viewHolder.isRemoved()
        && !mRecyclerView.mAdapter.hasStableIds()) {
    removeViewAt(index);
    //回收到mCachedViews或mRecyclerPool中
    recycler.recycleViewHolderInternal(viewHolder);
} else {
    detachViewAt(index);
    //回收到mAttachedScrap 或 mChangedScrap中
    recycler.scrapView(view);
    mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
}

缓存复用最终会调用到tryGetViewHolderForPositionByDeadline方法,这个方法源码巨长省略不相关源码,核心源码如下:

@NonNull
public View getViewForPosition(int position) {
    return getViewForPosition(position, false);
}

View getViewForPosition(int position, boolean dryRun) {
    return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView;
}

@Nullable
ViewHolder tryGetViewHolderForPositionByDeadline(int position,
        boolean dryRun, long deadlineNs) {
    ...
    boolean fromScrapOrHiddenOrCache = false;
    ViewHolder holder = null;
    // 0) If there is a changed scrap, try to find from there
    if (mState.isPreLayout()) {
        holder = getChangedScrapViewForPosition(position);
        fromScrapOrHiddenOrCache = holder != null;
    }
    // 1) Find by position from scrap/hidden list/cache
    if (holder == null) {
        //从 1和2级缓存中取
        holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
        if (holder != null) {
            ...
        }
    }
    if (holder == null) {
        ...
        if (holder == null && mViewCacheExtension != null) {
            // We are NOT sending the offsetPosition because LayoutManager does not
            // know it.
            // 从三级自定义缓存中取
            final View view = mViewCacheExtension
                    .getViewForPositionAndType(this, position, type);
            ....
        }
        if (holder == null) { // fallback to pool
            if (DEBUG) {
                Log.d(TAG, "tryGetViewHolderForPositionByDeadline("
                        + position + ") fetching from shared pool");
            }
            //从四级缓存中取
            holder = getRecycledViewPool().getRecycledView(type);
            if (holder != null) {
                holder.resetInternal();
                if (FORCE_INVALIDATE_DISPLAY_LIST) {
                    invalidateDisplayListInt(holder);
                }
            }
        }
        if (holder == null) {
            ...
            //通过Adapter重新创建新的ViewHolder实例
            holder = mAdapter.createViewHolder(RecyclerView.this, type);
            ...
        }
    }
    ...
    //绑定数据相关逻辑省略
    return holder;
}

3.2 布局流程

RecyclerView的布局分为两部分非别为初始布局和滚动过程中的布局,两者的处理逻辑有所不同。初始布局相关业务逻辑主要由onLayoutChildren()方法承载,滚动过程中的布局相关逻辑主要由scrollVerticallyBy()承载。其中有一个比较核心的方法是fill()方法,该方法是ViewHolder布局的核心方法。

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
    RecyclerView.State state, boolean stopOnFocusable) {
// max offset we should set is mFastScroll + available
final int start = layoutState.mAvailable;
//判断是否产生有效滚动
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
    // TODO ugly bug fix. should not happen
    if (layoutState.mAvailable < 0) {
        layoutState.mScrollingOffset += layoutState.mAvailable;
    }
    //检查时候有需要回收的ViewHolder
    recycleByLayoutState(recycler, layoutState);
}
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
    layoutChunkResult.resetInternal();
    if (RecyclerView.VERBOSE_TRACING) {
        TraceCompat.beginSection("LLM LayoutChunk");
    }
    ...
    //布局ViewHolder
    layoutChunk(recycler, state, layoutState, layoutChunkResult);
    ...
    if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
        layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
        if (layoutState.mAvailable < 0) {
            layoutState.mScrollingOffset += layoutState.mAvailable;
        }
        recycleByLayoutState(recycler, layoutState);
    }
    if (stopOnFocusable && layoutChunkResult.mFocusable) {
        break;
    }
}
if (DEBUG) {
    validateChildOrder();
}
return start - layoutState.mAvailable;

注意:RecyclerView在滚动布局过程中如果没有新的ViewHolder产生的时候是不会掉用fill()方法的。

3.3 实现方案

有了上面那西基础做铺垫我们就可以开始动手写一个LayoutManager了,整体思路如下:

  1. RecyclerView现有的四层缓存之上,再创建一层缓存,用于缓存吸顶的ViewHolder
  2. 筛选出需要吸顶的ViewHolder加入自定义缓存
  3. 向上滚动(手指上滑)的过程中,在目标ViewHolder到达上边缘的位置的吸顶位置时候阻止其继续滚动,将目标ViewHolder强制绘制在屏幕的上部,并将其加入吸顶ViewHolder缓存(止其进入RecyclerView的内部回收机制)。
  4. 向下滚动(手指下滑)的过程中,在目标ViewHolder离开吸顶区域后,将其从吸顶缓存中移除,并将其重新放回到RecyclerView内部的缓存中。

总结起来就两句话:吸顶的ViewHolder加到新增的自定义缓存中,将LinearLayoutManager排完的ViewHolder重新排列一下。

3.3.1 吸顶协议

整体的开发思路我们已经确定,首先我们要解决的问题就是如何将要吸顶的ViewHolder筛选出来呢?这里我的方案是定义一个协议接口Section,通过检测该ViewHolder是否实现该接口判断该ViewHolder是否需要被吸顶。

/**
 * 协议接口所有实现该接口的`ViewHolder`在滚动的过程中都会被吸顶
 * @author Rango on 2020/11/6
 */
public interface Section {
}

public class SectionViewHolder extends RecyclerView.ViewHolder implements Section {
    public TextView tv;

    public SectionViewHolder(@NonNull View v) {
        super(v);
    }
}
3.3.2 自定义缓存

因为一次只有一个ViewHolder吸顶,当列表中有多个可以吸顶的ViewHolder的时候,在向上滚动的时候新出现的吸顶ViewHolder会将当前正在吸顶的ViewHolder顶上去,我们需要将这些被顶上去的ViewHolder保存起来(阻止进入系统缓存),这样在向下滚动的时候这些ViewHolder重新显示的时候才会保持之前的状态,否则会进入系统缓存被重新绑定数据,导致之前的状态丢失。所以我们需要创建一个缓存栈(后进先出)用于保存吸顶的ViewHolder,在列表向上滚动的过程中,有符合条件的ViewHolder出现的时候我们就将其入栈,在列表向下滚动的过程中如果吸顶ViewHolder离开吸顶位置的时候我们就将其出栈。这个缓存栈就是我们新加的自定义缓存,栈顶的ViewHolder就是当前吸顶的ViewHolder,代码如下:

/**
 * 吸顶ViewHolder的缓存
 *
 * @author Rango on 2020/11/17
 */
public class SectionCache extends Stack {

    private Map 
            filterMap = new HashMap<>(16, 64);

    @Override
    public RecyclerView.ViewHolder push(RecyclerView.ViewHolder item) {
        if (item == null) {
            return null;
        }
        int position = item.getLayoutPosition();
        //避免存在重复的Value
        if (filterMap.containsKey(position)) {
            //返回null说明没有添加成功
            return null;
        }
        filterMap.put(position, item);
        return super.push(item);
    }

    @Override
    public synchronized RecyclerView.ViewHolder peek() {
        if (size() == 0) {
            return null;
        }
        return super.peek();
    }

    /**
     * 栈顶清理,在快速滚动的情境下可能会出现一次多个吸顶的ViewHolder出栈的情况,这个时候需要
     * 根据LayoutPosition清理栈顶,保证栈内ViewHolder和列表当前的状态一致。
     *
     * @param layoutPosition 大于position的内容会被清理
     */
    public List clearTop(int layoutPosition) {
        List removedViewHolders = new LinkedList<>();
        Iterator it = iterator();
        while (it.hasNext()) {
            RecyclerView.ViewHolder top = it.next();
            if (top.getLayoutPosition() > layoutPosition) {
                it.remove();
                filterMap.remove(top.getLayoutPosition());
                removedViewHolders.add(top);
            }
        }
        return removedViewHolders;
    }
}
3.3.3 过滤ViewHolder

这里我们需要把当前正在显示的目标ViewHolder过滤出来并根据当前的dy判断是否会滚动到吸顶位置,不幸的是LayoutManager并没有提供获取ViewHolder的api,只提供了获取childView()的方法。查阅源码发现ViewHolder中有这样一个api

getChildViewHolderInt

childView对应的ViewHolder会保存在其LayoutParams.mViewHolder中,通过这个方案我们可以把当前正在显示的ViewHolder过滤出来。

for (int i = 0; i < getChildCount(); i++) {
    View itemView = getChildAt(i);
    RecyclerView.ViewHolder vh = getViewHolderByView(itemView);
    if (!(vh instanceof Section) || sectionCache.peek() == vh) {
        continue;
    }
    if (dy > 0 && vh.itemView.getTop() < dy) {
        sectionCache.push(vh);
    } else {
        break;
    }
}

注意并不是说所有显示出来的需要吸顶的ViewHolder都需要立即加入到我们的自定义缓存中,只有向上滚动到吸顶位置的吸顶ViewHolder加入缓存栈。

image.png

假设A和E是两个可以吸顶的ViewHolder,当前屏幕正在向上滚动,此时A需要加入缓存栈,但是E不需要加入缓存队列。E只有持续向上滚动到A所在的位置的时候才会被加入我们自定义的缓存栈。

3.3.4 拦截

在列表的滚动过程中我们除了要将这些需要吸顶的ViewHolder加入到我们自定义的缓存栈中,我们还要阻止其进入RecylverView的缓存中,否则列表继续向上滚动ViewHolder A就会滚出屏幕,如下图所示,这个时候ViewHolder A就会被Recycler回收,放入第二层缓存(mCachedViews)中,再有吸顶ViewHolder滚动出来的时候之前回收的RecyclerView就会被复用和重新绑定数据,之前的ViewHolder A的状态就会丢失。

图二

在列表上滑过程中图三是我们所期望的结果,ViewHolder A在上滑到顶部的时候我们需要将其固定在RecyclerView的顶部。

图三

RecyclerView滚动相关的业务逻辑主要是在scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state)方法中,该方法有三个参数作用如下:

  • dy -- 本次滚动的距离,dy > 0是向上滚动(手指上滑),反之下滑
  • recycler -- 缓存器,定义了四层缓存策略
  • state -- 用于传递数据信息,例如是否是预布局等

LinearLayoutManager中该方法的源码如下

/**
     * {@inheritDoc}
     */
    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
            RecyclerView.State state) {
        if (mOrientation == HORIZONTAL) {
            return 0;
        }
        return scrollBy(dy, recycler, state);
    }

这里我们要做的就是在scollBy()之前插入我们的回收代码,在之后加入我们的重新布局代码。因为要兼容LienarLayoutManager所以我们不对scrollBy内部的内容进行修改,这样我们就可以保证兼容性。

我们分析RecyclerView四层缓存的时候我们已经了解了内部实现的一些细节问题缓存复用和滚动处理等等,scrollBy()是包访问权限,我们无法对其进行重载,所以我们只能从scrollVerticallyBy()方法下手了,其实我们也没必要关心scrollBy()方法内被

for (RecyclerView.ViewHolder viewHolder : sectionCache) {
    removeView(viewHolder.itemView);
}

scrollBy()方法调用之前我们把吸顶的ViewHolder remove掉就可以阻止其进入Recycler的缓存中,因为ViewHolder相关的信息保存在itemView.layoutParams中,移除View就可以阻止其回收。就这么简单?对就这么简单!

3.3.5 重新布局

如果现在使用我们自定义的LayoutManager应该是 图四 这种效果,当吸顶ViewHodler进入吸顶位置后就会变成空白。

image-20201123101155909.png

我们需要将Remove掉的ViewHolder重新加回到RecyclerView中并将其布局在合适的位置,这里有几个关键点需要注意下:

  1. dy可能大于一个ViewHolder的高度

  2. 如果当前吸顶位置已经有吸顶ViewHolder占据的时候,后来的吸顶ViewHolder需要将其顶上去

  3. 在向下滚动(手指下滑)的时候,由于吸顶的ViewHolder都没有进入Recycler的缓存,所以在向下滚动的时候RecyclerView会重新创建ViewHolder实例,我们需要将其替换为我们自定义缓存中保存的实例。

具体实现代码如下:

//检查栈顶
RecyclerView.ViewHolder vh = getViewHolderByView(getChildAt(0));
RecyclerView.ViewHolder attachedSection = sectionCache.peek();
if ((vh instanceof Section)
        && attachedSection != null
        && attachedSection.getLayoutPosition() == vh.getLayoutPosition()) {
    removeViewAt(0);
}

// 处理向下滚动
for (RecyclerView.ViewHolder removedViewHolder : sectionCache.clearTop(findFirstVisibleItemPosition())) {
    Log.i(tag, "移除ViewHolder:" + removedViewHolder.toString());
    for (int i = 0; i < getChildCount(); i++) {
        RecyclerView.ViewHolder attachedViewHolder = getViewHolderByView(getChildAt(i));
        if (removedViewHolder.getLayoutPosition() == attachedViewHolder.getLayoutPosition()) {
            View attachedItemView = attachedViewHolder.itemView;
            int left = attachedItemView.getLeft();
            int top = attachedItemView.getTop();
            int bottom = attachedItemView.getBottom();
            int right = attachedItemView.getRight();
                        //这里的remvoe 和 add 是为了重新布局
            removeView(attachedItemView);
            addView(removedViewHolder.itemView, i);
            removedViewHolder.itemView.layout(left, top, right, bottom);
            break;
        }
    }
}

//重新布局
RecyclerView.ViewHolder section = sectionCache.peek();
if (section != null) {
    View itemView = section.itemView;
    if (!itemView.isAttachedToWindow()) {
        addView(itemView);
    }
    View subItem = getChildAt(1);
    if (getViewHolderByView(subItem) instanceof Section) {
        int h = itemView.getMeasuredHeight();
        int top = Math.min(0, -(h - subItem.getTop()));
        int bottom = Math.min(h, subItem.getTop());
        itemView.layout(0, top, itemView.getMeasuredWidth(), bottom);
    } else {
        itemView.layout(0, 0, itemView.getMeasuredWidth(), itemView.getMeasuredHeight());
    }
}

每段代码的作用已经用注释描述,这里不再赘述,效果如下:

未命名.gif

源码地址
如有错误或意见欢迎在评论区讨论。

作为一个码农,脑袋偷懒身体受苦 --- 但是领导总是喜欢那些不动脑筋拼命加班的人。。。

你可能感兴趣的:(自定义吸顶LayoutManager)