彻底理解自定义LayoutManager

1,285 阅读7分钟

前言

先上效果,让大家感受一下:最左侧和最右侧都为卡片都需要折叠

FoldCardListDemo.gif

GitHub地址:github.com/HuichangL/F… 有兴趣可点Star😁

LayoutManager/Recycler/Adapter的关系

在正式开始自定义LayoutManager前,我们看一下LayoutManager/Recycler/Adapter的关系:

暂时无法在飞书文档外展示此内容

  • LayoutManager的作用就是RecyclerView中的布局管理器,负责摆放Item的位置,不管是在初始化还是用户滑动
  • Recycler是RecyclerView的内部类,主要负责提供对应Item的View以及回收View,
  • Adapter负责管理RecyclerView的数据,创建ViewHolder, 并数据绑定到ViewHolder上
  • ViewHolder承载的是每一个Item项的View,需要通过Adapter进行创建

因此,为了各司其职,LayoutManager需要View时,从Recycler中获取,不需要的View丢给Recycler进行回收,即Recycler需要View承载数据时,会通过Adapter进行数据绑定,但不允许Layout Manager和Adapter进行交互

下面来了解一下Recycler View的缓存/回收机制

Recycler View的缓存/回收机制

juejin.cn/post/724118…

RecyclerView四级缓存

通常在RecyclerView中存在着四级缓存,从低到高分别为:

  • 可直接重复使用的临时缓存(mAttachedScrap/mChangedScrap)

    • mAttachedScrap中缓存的是屏幕中可见范围的ViewHolder
    • mChangedScrap只能在预布局状态下重用,因为它里面装的都是即将要放到mRecyclerPool中的Holder,而mAttachedScrap则可以在非预布局状态下重用
  • 可重用的缓存(mCachedViews):缓存滑动时即将与RecyclerView分离的ViewHolder,默认最大2个;

  • 自定义实现的缓存(ViewCacheExtension):通常忽略;

  • 需要重新绑定数据的缓存(RecycledViewPool ):ViewHolder缓存池,可以支持不同的ViewType,返回的ViewHolder需要重新Bind数据;

RecyclerView中存在着四级缓存:临时缓存mAttachedScrap/ 可重用的缓存mCachedViews/ 自定义实现的缓存ViewCacheExtension/ 缓存池RecyclerViewPool

由于绝大多数情况下无需自定义缓存,因此通常我们说RecyclerView有三级缓存

LayoutManager的回收

LayoutManager提供了各种回收方法

detachAndScrapView(View child, Recycler recycler)
detachAndScrapViewAt(int index, Recycler recycler)
detachAndScrapAttachedViews(Recycler recycler)


removeAndRecycleView(View child, Recycler recycler)
removeAndRecycleViewAt(int index, Recycler recycler)
removeAndRecycleAllViews(Recycler recycler)
  • 前三个方法负责将View回收到一级缓存(Recycler.mAttachedMap)中,而一级缓存只是一个临时缓存,用于初始化或者数据集变化时,将所有的View放到临时放到缓存中,即只在布局(调用onLayoutChildren)时才会调用(detachAndScrapAttachedViews) ,detachAndScrapView/detachAndScrapViewAt没有看到有调用的地方)。

  • 后三个方法负责将View回收到二级缓存(mCachedViews)或者四级缓存(RecyclerViewPool)中,mCachedViews默认大小为2(但目前存在mPrefetchMaxCountObserved参数,值为1 ,所以mCachedViews的size大小可能为3)

自定义LayoutManager基本流程

这部分可直接看大佬的文章,写的非常清楚juejin.cn/post/687077… 基本属于搬运

  1. 继承RecyclerView.LayoutManager并实现generateDefaultLayoutParams()方法。

  2. 按需,重写onMeasure()isAutoMeasureEnabled()方法。

  3. 重写onLayoutChildren()开始第一次填充itemView。

  4. 重写canScrollHorizontally()canScrollVertically()方法支持滑动。

  5. 重写scrollHorizontallyBy()scrollVerticallyBy()方法在滑动的时候填充和回收itemView。

  6. 重写scrollToPosition()smoothScrollToPosition()方法支持。

  7. 解决软键盘弹出或收起导致onLayoutChildren()方法被重新调用的问题。

  • 其中,在重写onLayoutChildren方法时需要注意,这个方法会在初始化或者Adapter数据集更新时被调用,重写这个方法时,需要做一下事情:
    1. 在进行布局之前,我们需要调用detachAndScrapAttachedViews方法把屏幕中的Items都分离出来,内部调整好位置和数据后,再把它添加回去(如果需要的话);

    2. 分离了之后,我们就要想办法将分离后的view再添加回去,所以需要通过addView方法来添加,这些View可以通过调用Recycler的getViewForPosition(int position)方法来获取;

    3. 获取到Item并重新添加以后,我们需要对它进行测量,这时候可以调用measureChild或者measureChildWithMargins方法,两个方法不同的地方在于第二个方法会将ItemMargin考虑进来;

    4. 测量完就可以布局,调用layoutDecorated或者layoutDecoratedWithMargins方法;

    5. 自定义ViewGroup中,layout完成后就可以运行看效果了,但是在LayoutManager中还需要对不太需要的Items进行回收,以保证滑动的流畅性

注意事项

  1. 按需正确的重写onMeasure()isAutoMeasureEnabled()方法
  2. onLayoutChildren()时不要直接加载全部Itemview
  3. 需要支持scrollToPosition()smoothScrollToPosition()方法
  4. 注意解决软键盘弹出或收起onLayoutChildren()方法重新调用的问题。

正确的重写onMeasure()或isAutoMeasureEnabled()方法

当你自定义的LayoutManager只支持宽高同时match_parent时,可以不用重写这两个方法,LayoutMangeronMeasure()有默认实现,并且isAutoMeasureEnabled()默认返回的false

,但是什么时候需要重写这两个方法呢?直接参考大佬的结论:

  • 重写onMeasure()的情况极少,除非有特殊要求,比如要设置一个绝对的高度给LayoutManager

  • isAutoMeasureEnabled()是自测量模式,给RecyclerViewwrap_content的用的,如果你的LayoutManager要支持wrap_content那就必须重写,直接返回true

支持scrollToPosition()或smoothScrollToPosition()方法

自定义Layout Manager时,很多时候滚动的距离等都是自己计算,因此最好需要适配这两个方法

未解决软键盘弹出或收起onLayoutChildren()方法重新调用的问题

这个问题我没注意到,还得是大佬

问题出现的根源就是在当EditText获取到焦点导致软键盘弹起或者收起的时候,LayoutManager会重新回调onLayoutChildren()方法。如果一个LayoutManager的onLayoutChildren方法写得不够合理,就会给使用的人带来困扰,详细的内容会放在下面开始自定义LayoutManager再讲。

在LinearLayoutManager的onLayoutChildren方法中有一段代码就是对这种问题的处理

 final View focused = getFocusedChild()
 ...
 else if (focused != null && (mOrientationHelper.getDecoratedStart(focused)
                        >= mOrientationHelper.getEndAfterPadding()
                || mOrientationHelper.getDecoratedEnd(focused)
                <= mOrientationHelper.getStartAfterPadding())) {
      mAnchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
}

常用的API

在自定义LayoutManager前,我们先来了解常用方法的使用和作用

获取一个View

 val view = recycler.getViewForPosition(position)

该方法用于LayoutManager从Recycler中获取指定位置的View,这个View可能从Recycler的四级级缓存中得到,也可能是重新创建的,具体的获取逻辑参考上文讲过的RecyclerView缓存机制。

另外,如果position超过itemCount或小于0,就会直接抛出异常

将View添加到RecyclerView中

addView(View child)
addView(View child, index)

添加View用到的方法绝大多数情况使用addView即可

测量View

measureChild(@NonNull View child, int widthUsed, int heightUsed)
measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed)

这两个方法都是测量View的相关信息,但是第二个会将Item的Margin都考虑在内,而参数widthUsed和heightUsed,一般传0就可以

Layout View

layoutDecorated(@NonNull View child, int left, int top, int right, int bottom)

layoutDecoratedWithMargins(@NonNull View child, int left, int top, int right,
                int bottom)

这两个方法就是对view.layout()的封装

获取View的相关信息

  • 获取View的layout position:
int getPosition(@NonNull View view)
  • 获取View的宽高(考虑ItemDecoration)
int getDecoratedMeasuredWidth(@NonNull View child)
int getDecoratedMeasuredHeight(@NonNull View child)
  • 获取View的left,top,right,bottom距离RecyclerView边缘的距离,同样将ItemDecoration考虑进来
int getDecoratedTop(@NonNull View child)
int getDecoratedLeft(@NonNull View child)
int getDecoratedRight(@NonNull View child)
int getDecoratedBottom(@NonNull View child)

移动View

offsetChildrenHorizontal(@Px int dx)
offsetChildrenVertical(@Px int dy)

水平或垂直方向的移动全部子View

回收View

detachAndScrapAttachedViews(@NonNull Recycler recycler)
detachAndScrapView(@NonNull View child, @NonNull Recycler recycler)
detachAndScrapViewAt(int index, @NonNull Recycler recycler)
  
removeAndRecycleAllViews(@NonNull Recycler recycler)
removeAndRecycleView(@NonNull View child, @NonNull Recycler recycler)
removeAndRecycleViewAt(int index, @NonNull Recycler recycler)

在上文的缓存/回收机制中已经提过,不再赘述

自定义LayoutManager示例

自定义LayoutManager的前置工作都讲完了,接下来我们就正式开干,我们这里的示例是为了实现一个左右堆叠的横向RecycleView,具体效果在前言

继承LayoutManager,重写generateDefaultLayoutParams与isAutoMeasureEnabled

class FoldLinearLayoutManager(context: Context?): RecyclerView.LayoutManager() {

    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(
            RecyclerView.LayoutParams.WRAP_CONTENT,
            RecyclerView.LayoutParams.MATCH_PARENT
        )
    }

    override fun isAutoMeasureEnabled(): Boolean {
        return true
    }

}

我们这里要实现一个横向列表,因此LayoutParams的宽度WRAP_CONTENT即可

重写onLayoutChildren,填充子View

通常来讲,onLayoutChildren的过程基本如下:

  1. 暂时性的回收屏幕中的View:detachAndScrapAttachedViews(recycler)

  2. 找到屏幕中第一个可见的View及其位置,addView()->measureView()->layoutView()

  3. 计算剩余空间,按个摆放View,即addView()->measureView()->layoutView(),直至屏幕撑满

简易的伪代码如下:

override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {

        // 轻量级回收 分离全部已有的view 放入临时缓存  mAttachedScrap 集合中
        detachAndScrapAttachedViews(recycler)
        
        // 计算第一个可见View的位置
        findFirstPosition()
        
        //----------------开始布局-----------------
        for (i in mFirstVisiPos..mLastVisiPos) {
        
            // 获取child
            var item = recycler.getViewForPosition(i)
        
            // add child
            val focusPosition = (abs(mHorizontalOffset) / (childWidth + itemSpace)).toInt()
            if (i <= focusPosition) {
                addView(item)
            } else {
                addView(item, 1)
            }
            
            // measure child
            measureChildWithMargins(item, 0, 0)
            
            // layout child
            layoutChunk(item, i)
        
            if (mLastVisiPos == i) {
                break
            }
        }
}

其中,有几个点需要注意:

  1. 最后一个Item的位置,我们会根据屏幕宽度与每一个子View的宽度进行计算

  2. 在add child的时候,我们使用了addView(item) 和addView(item, index)两个API,这是因为我们最右侧的卡片要被堆叠在其前一个卡片的下面,因此需要使用addView(item, index)方法来完成

Layout Child

我们封装了一个函数layoutChunk来对子View进行布局(by the way, 这个函数名也是和LinearLayoutManager中一致):

private fun layoutChunk(view: View, position: Int) {
    var left = mFillStartX
    var top: Int = paddingTop
    var right = left + getDecoratedMeasurementHorizontal(view)
    var bottom: Int = top + getDecoratedMeasurementVertical(view)

    // 缩放子view
    val minScale = 0.3f
    var currentScale = 0f
    // 最左侧卡片隐藏时需要缩放
    if (position == mFirstVisiPos && left < paddingStart) {
        val scale = minScale * abs(left - paddingStart) / (childWidth * 1.0f)
        currentScale = 1 - scale
        left = (left + (itemSpace / 2 + childWidth / 2) * scale / minScale).toInt()
        right = left + getDecoratedMeasurementHorizontal(view)
    } else {
        if (right < width)) {
            currentScale = 1.0f
            view.alpha = 1.0f
        } else {
            val fractionScale = minScale * abs(right - width) / (childWidth * 1.0f)
            currentScale = 1 - fractionScale
            left -= abs(right - width)
            right = left + getDecoratedMeasurementHorizontal(view)
            view.pivotX = if(view.width == 0) getDecoratedMeasuredWidth(view).toFloat() else view.width.toFloat()
            view.pivotY = if (view.height == 0) (getDecoratedMeasuredHeight(view) / 2).toFloat() else (view.height / 2).toFloat()
            view.alpha = 0.6f
        }
    }
    view.scaleX = currentScale
    view.scaleY = currentScale

    layoutDecoratedWithMargins(view, left, top, right, bottom)
    
    mFillStartX += childWidth + itemSpace
    if (mFillStartX > width - paddingRight) {
        mLastVisiPos = position
    }
}

我们为了能够让列表最左侧与最右侧卡片折叠,需要根据子View的位置来计算他的缩放比例

重写canScrollHorizontally支持横向滑动

override fun canScrollHorizontally(): Boolean {
    return true
}

override fun canScrollVertically(): Boolean {
    return false
}

因为我们的列表是要横向滑动,纵向不需要滑动,因此canScrollVertically直接返回false即可

重写scrollHorizontallyBy()方法,支持滑动时的View布局处理

override fun scrollHorizontallyBy(
    delta: Int,
    recycler: RecyclerView.Recycler,
    state: RecyclerView.State
): Int {
    var dx = delta
    
    // 计算滑动距离
    mHorizontalOffset += dx.toLong()
        
    // 在fill 中完成和onLayoutChildren中类似的操作,但是会多一个回收View的操作
    dx = fill(recycler, state, dx)
    } catch (e: Exception) {
        e.printStackTrace()
    }
    return dx
}

重点在fill 函数():

//伪代码
private fun fill(recycler: RecyclerView.Recycler, state: RecyclerView.State, dx: Int): Int {
    // 计算第一个可见子View的位置
    findFirstPosition()
    
    // 布局子View
   for (i in mFirstVisiPos..mLastVisiPos) {
        
            // 获取child
            var item = recycler.getViewForPosition(i)
        
            // add child
            val focusPosition = (abs(mHorizontalOffset) / (childWidth + itemSpace)).toInt()
            if (i <= focusPosition) {
                addView(item)
            } else {
                addView(item, 1)
            }
            
            // measure child
            measureChildWithMargins(item, 0, 0)
            
            // layout child
            layoutChunk(item, i)
        
            if (mLastVisiPos == i) {
                break
            }
        }
        
    // 回收子View
    recycleChildren(recycler)
    return resultDelta
}

可以看到,fill 的功能也很明确:

  1. 找到滑动后页面第一个可见View
  2. 按顺序布局子View
  3. 回收子View

这里需要的操作和onLayoutChildren中的操作有很多重合,因此可以重用代码,实际在代码中也是这么干的

scrollToPosition()和smoothScrollToPosition()方法支持

scrollToPosition()

override fun scrollToPosition(position: Int) {
    if (position < 0 || position >= itemCount) return
    cancelAnimator()
    mPendingPosition = position
    requestLayout()
}

这里我们借鉴了LinearLayoutManager的做法, 定义了一个mPendingPosition,根据这个值是否不等于初始值来决定屏幕中第一个可见Item的position:

private fun findFirstPosition() {
    if (mPendingPosition != RecyclerView.NO_POSITION) {
        mFirstVisiPos = mPendingPosition
        return
    }
    if (childWidth in 1..mHorizontalOffset) {
        mFillStartX = paddingStart + itemSpace
        onceCompleteScrollLength = childWidth + itemSpace
        mFirstVisiPos =
            floor((abs(mHorizontalOffset - childWidth) / onceCompleteScrollLength).toDouble())
                .toInt() + 1
        mFraction =
            abs(mHorizontalOffset - childWidth) % onceCompleteScrollLength / (onceCompleteScrollLength * 1.0f)
    } else {
        mFirstVisiPos = 0
        mFillStartX = minOffset
        onceCompleteScrollLength = childWidth
        mFraction =
            abs(mHorizontalOffset) % onceCompleteScrollLength / (onceCompleteScrollLength * 1.0f)
    }
}

smoothScrollToPosition

/**
 * 平滑滚动到某个位置
 *
 * @param position 目标Item索引
 */
fun smoothScrollToPosition(position: Int, listener: OnStackListener?, duration: Long? = null) {
    if (position > -1 && position < itemCount) {
        startValueAnimator(position, listener, duration)
    }
}

为了实现平滑与柔和的滚动效果,我们定义了一个动画来支持smoothScrollToPosition,感兴趣的可以在GitHub上拉代码查看,顺便点个star, 哈哈

至此,我们的自定义LayoutManager就完成了。

参考文档

juejin.cn/post/687077…