之前很早的时候已经写过一篇自定义View和ViewGroup的文章,一直说写一篇自定义LayoutManager的到现在才想起来。
首先说一下RecycleView这个特殊的控件,查看源码可以很清楚的看到RecycleView继承自ViewGroup,只不过RecycleView将onMeasure()、onLayout() 交给了 LayoutManager 去处理,因此如果给 RecyclerView 设置不同的 LayoutManager 就可以达到不同的显示效果,故自定义RecycleView就是自定义LayoutManager。
这里还有个误区,我们自定义ViewGroup期间因为ViewGroup是一种静态的layout子View的过程,内部不支持滑动,只需要无脑的layout出所有的子View即可,而自定义LayoutManager不能layout所有的子View,这是错误的做法,丧失了RecycleView最核心的View复用机制,我们在创建RecycleView之后,打印onCreateViewHolder和onBindViewHolder方法,我们很清楚的看到,初始化展示页面时屏幕上有多少个itemView就会执行onCreateViewHolder多少次(可能会多执行一次),向下滑动的过程中还会多执行4次onCreateViewHolder,之后展示的itemView都是通过绑定缓存中ViewHolder显示。
这里为啥是多执行4次呢?
又要引出RecycleView的缓存机制,在这之前简单解释下detach 和recycle以及生效的时机:
一个View只是暂时被清除掉,稍后立刻就要用到,使用detach,会被缓存进scrapCache区域。
一个view不再显示在屏幕上,需要被清除掉,并且下次再显示的时机未知,使用remove,会被以viewType分组,缓存进RecycleViewPool里面。
-
可直接重用的临时缓存:mAttachedScrap,mChangedScrap(预布局下重用);
-
可直接重用的缓存:mCachedViews;
-
需重新绑定数据的缓存:mRecyclerPool.mScrap;
一级缓存:mAttachedScrap缓存的其实就是屏幕上展示的itemView
二级缓存:mCachedViews缓存的是刚刚移出屏幕的itemView,默认的缓存数量为2
三级缓存:用户自定义缓存,一般不用
四级缓存:回收池,默认的缓存数量为5,一旦需要频繁的创建销毁对象时,采用回收池的设计方式,将一定数量的对象保存起来,如果需要用到的时候直接从回收池中获取,不需要创建新的对象;目的在于通过数据保存的方式防止频繁创建对象造成的内存抖动,从而频繁引起GC造成卡顿。
因为mCachedViews的缓存数量为2,所以屏幕外上下总共需要缓存4个,所以create的ViewHolder的总数量是屏幕内显示的itemView的数量 + 4(屏幕外缓存数量)。
说了这么多正式开始自定义LayoutManager,模仿LinearLayoutManager实现一个横向的LinearLayoutManager。
generateDefaultLayoutParams是继承RecyclerView.LayoutManager必须要实现的方法
override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
return RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT,RecyclerView.LayoutParams.WRAP_CONTENT)
}
按需重写isAutoMeasureEnabled()方法,isAutoMeasureEnabled()是自测量模式,给RecyclerView的wrap_content的用的,如果你的LayoutManager要支持wrap_content那就必须重写。
override fun isAutoMeasureEnabled(): Boolean {
return true
}
重写onLayoutChildren()开始填充子View。
override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
//获取recycleView的宽度用来减去itemView的宽度
var totalSpace = width - paddingRight
var currentPosition = 0
var fixOffset = 0
//当childCount != 0时,证明是已经填充过View的,因为有回收
//所以直接赋值为第一个child的position就可以
if (childCount != 0) {
currentPosition = getPosition(getChildAt(0)!!)
fixOffset = getDecoratedLeft(getChildAt(0)!!)
}
if (mPendingPosition != RecyclerView.NO_POSITION) {
currentPosition = mPendingPosition
}
//轻量级的将view移除屏幕
detachAndScrapAttachedViews(recycler)
//开始填充view
var left = 0
var top = 0
var right = 0
var bottom = 0
//模仿LinearLayoutManager的写法,当可用距离足够和要填充的itemView的position在合法范围内才填充View
while (totalSpace > 0 && currentPosition < state.itemCount) {
val view = recycler.getViewForPosition(currentPosition)
addView(view)
measureChild(view, 0, 0)
right = left + getDecoratedMeasuredWidth(view)
bottom = top + getDecoratedMeasuredHeight(view)
layoutDecorated(view, left, top, right, bottom)
currentPosition++
left += getDecoratedMeasuredWidth(view)
//关键点
totalSpace -= getDecoratedMeasuredWidth(view)
}
offsetChildrenHorizontal(fixOffset)
}
重写canScrollHorizontally()方法支持左右滑动,想要支持上下滑动就重写canScrollVertically()方法
override fun canScrollHorizontally(): Boolean {
return true
}
重写scrollHorizontallyBy()方法在滑动的时候填充view和回收view,通过获取滑动的dx来往里面填充itemView,填充的步骤和onLayoutChildren的方法一样,这里最重要的是获取锚点position,就是屏幕里面的itemView数量,往右滑动的锚点position是childCount - 1,往左滑动就是0,还要通过判断滑动的距离和锚点位置的差值和获取的recycleView的width作比较判断是否填充,还要考虑滑动到头部和尾部的时候不给滑动了即手动给dx赋值为0等等情况。
override fun scrollHorizontallyBy(
dx: Int,
recycler: RecyclerView.Recycler,
state: RecyclerView.State
): Int {
if (childCount == 0 || dx == 0) return 0
Log.d("scrollHorizontallyBy", "dx == $dx")
//填充View,consumed就是修复后的移动值
val consumed = fill(dx, recycler)
Log.d("scrollHorizontallyBy", "consumed == $consumed")
//移动View
offsetChildrenHorizontal(-consumed)
//回收View
recycle(consumed, recycler)
//输出children
return consumed
}
private fun fill(dx: Int, recycler: RecyclerView.Recycler): Int {
//将要填充的position
var fillPosition = RecyclerView.NO_POSITION
//可用的空间,和onLayoutChildren中的totalSpace类似
var availableSpace = abs(dx)
//增加一个滑动距离的绝对值,方便计算
val absDelta = abs(dx)
//将要填充的View的左上右下
var left = 0
var top = 0
var right = 0
var bottom = 0
//dx>0就是手指从右滑向左,所以就要填充尾部
if (dx > 0) {
val anchorView = getChildAt(childCount - 1)!!
val anchorPosition = getPosition(anchorView)
val anchorRight = getDecoratedRight(anchorView)
left = anchorRight
//填充尾部,那么下一个position就应该是+1
fillPosition = anchorPosition + 1
//如果要填充的position超过合理范围并且最后一个View的
//right-移动的距离 < 右边缘(width)那就要修正真实能移动的距离
if (fillPosition >= itemCount && anchorRight - absDelta < width) {
val fixScrolled = anchorRight - width
Log.d("scrollHorizontallyBy", "fill == $fixScrolled")
return fixScrolled
}
//如果尾部的锚点位置减去dx还是在屏幕外,就不填充下一个View
if (anchorRight - absDelta > width) {
return dx
}
}
//dx<0就是手指从左滑向右,所以就要填充头部
if (dx < 0) {
val anchorView = getChildAt(0)!!
val anchorPosition = getPosition(anchorView)
val anchorLeft = getDecoratedLeft(anchorView)
right = anchorLeft
//填充头部,那么上一个position就应该是-1
fillPosition = anchorPosition - 1
//如果要填充的position超过合理范围并且第一个View的
//left+移动的距离 > 左边缘(0)那就要修正真实能移动的距离
if (fillPosition < 0 && anchorLeft + absDelta > 0) {
return anchorLeft
}
//如果头部的锚点位置加上dx还是在屏幕外,就不填充上一个View
if (anchorLeft + absDelta < 0) {
return dx
}
}
Log.d("scrollHorizontallyBy", "start fillPosition == $fillPosition")
//根据限定条件,不停地填充View进来
while (availableSpace > 0 && (fillPosition in 0 until itemCount)) {
val itemView = recycler.getViewForPosition(fillPosition)
if (dx > 0) {
addView(itemView)
} else {
addView(itemView, 0)
}
measureChild(itemView, 0, 0)
if (dx > 0) {
right = left + getDecoratedMeasuredWidth(itemView)
} else {
left = right - getDecoratedMeasuredWidth(itemView)
}
bottom = top + getDecoratedMeasuredHeight(itemView)
layoutDecorated(itemView, left, top, right, bottom)
if (dx > 0) {
left += getDecoratedMeasuredWidth(itemView)
fillPosition++
} else {
right -= getDecoratedMeasuredWidth(itemView)
fillPosition--
}
if (fillPosition in 0 until itemCount) {
availableSpace -= getDecoratedMeasuredWidth(itemView)
}
}
Log.d("scrollHorizontallyBy", "end fillPosition == $fillPosition")
Log.d("scrollHorizontallyBy", "availableSpace == $availableSpace")
return dx
}
private fun recycle(
dx: Int,
recycler: RecyclerView.Recycler
) {
//要回收View的集合,暂存
val recycleViews = hashSetOf<View>()
//dx>0就是手指从右滑向左,开始填充后面的View,所以要回收前面的children
if (dx > 0) {
for (i in 0 until childCount) {
val child = getChildAt(i)!!
val right = getDecoratedRight(child)
//itemView的right<0就是要超出屏幕要回收View
if (right >= 0) break
recycleViews.add(child)
}
}
//dx<0就是从左滑向右,开始填充前面的View,所以要回收后面的children
if (dx < 0) {
for (i in childCount - 1 downTo 0) {
val child = getChildAt(i)!!
val left = getDecoratedLeft(child)
//itemView的left>recyclerView.width就是要超出屏幕要回收View
if (left <= width) break
recycleViews.add(child)
}
}
//真正把View移除掉
for (view in recycleViews) {
removeAndRecycleView(view, recycler)
}
recycleViews.clear()
}