前言
先上效果,让大家感受一下:最左侧和最右侧都为卡片都需要折叠
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的缓存/回收机制
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… 基本属于搬运
-
继承
RecyclerView.LayoutManager并实现generateDefaultLayoutParams()方法。 -
按需,重写
onMeasure()或isAutoMeasureEnabled()方法。 -
重写
onLayoutChildren()开始第一次填充itemView。 -
重写
canScrollHorizontally()和canScrollVertically()方法支持滑动。 -
重写
scrollHorizontallyBy()和scrollVerticallyBy()方法在滑动的时候填充和回收itemView。 -
重写
scrollToPosition()和smoothScrollToPosition()方法支持。 -
解决软键盘弹出或收起导致
onLayoutChildren()方法被重新调用的问题。
- 其中,在重写
onLayoutChildren方法时需要注意,这个方法会在初始化或者Adapter数据集更新时被调用,重写这个方法时,需要做一下事情: -
-
在进行布局之前,我们需要调用
detachAndScrapAttachedViews方法把屏幕中的Items都分离出来,内部调整好位置和数据后,再把它添加回去(如果需要的话); -
分离了之后,我们就要想办法将分离后的view再添加回去,所以需要通过addView方法来添加,这些View可以通过调用Recycler的
getViewForPosition(int position)方法来获取; -
获取到Item并重新添加以后,我们需要对它进行测量,这时候可以调用
measureChild或者measureChildWithMargins方法,两个方法不同的地方在于第二个方法会将Item的Margin考虑进来; -
测量完就可以布局,调用
layoutDecorated或者layoutDecoratedWithMargins方法; -
自定义
ViewGroup中,layout完成后就可以运行看效果了,但是在LayoutManager中还需要对不太需要的Items进行回收,以保证滑动的流畅性
-
注意事项
- 按需正确的重写
onMeasure()或isAutoMeasureEnabled()方法 onLayoutChildren()时不要直接加载全部Itemview- 需要支持
scrollToPosition()或smoothScrollToPosition()方法 - 注意解决软键盘弹出或收起
onLayoutChildren()方法重新调用的问题。
正确的重写onMeasure()或isAutoMeasureEnabled()方法
当你自定义的LayoutManager只支持宽高同时match_parent时,可以不用重写这两个方法,LayoutManger的onMeasure()有默认实现,并且isAutoMeasureEnabled()默认返回的false
,但是什么时候需要重写这两个方法呢?直接参考大佬的结论:
-
重写
onMeasure()的情况极少,除非有特殊要求,比如要设置一个绝对的高度给LayoutManager -
isAutoMeasureEnabled()是自测量模式,给RecyclerView的wrap_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的过程基本如下:
-
暂时性的回收屏幕中的View:detachAndScrapAttachedViews(recycler)
-
找到屏幕中第一个可见的View及其位置,addView()->measureView()->layoutView()
-
计算剩余空间,按个摆放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
}
}
}
其中,有几个点需要注意:
-
最后一个Item的位置,我们会根据屏幕宽度与每一个子View的宽度进行计算
-
在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 的功能也很明确:
- 找到滑动后页面第一个可见View
- 按顺序布局子View
- 回收子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就完成了。