背景
安卓开发中,recycleview是我们经常会使用到的一个控件,他的功能强大,可以实现各种各样的列表效果;事实上,列表中的item怎么摆放,是依赖于LayoutManager的。比如我们经常使用的LinearLayoutManager,这个LayoutManager可以让我们的item横向或者竖向的摆放。GridLayoutManager则会让item成网格摆放。但是官方库里面的LayoutManager始终是有限的,有时候我们需要让item呈现一些特别的摆放方式,这种时候,我们只能选择自定义LayoutManager。
成果展示
我们先看看最后的成果,如下图:
可以说效果还是比较好的,那么下面开始说代码。
RecyclerView.LayoutManager()类
我们先点进上面提到的LinearLayoutManager的源码,看看人家的实现。一点进来,首先就看到LinearLayoutManager的继承关系:
public class LinearLayoutManager extends RecyclerView.LayoutManager implements
ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider
ItemTouchHelper可以处理recycleview的item的滑动事件,比如侧滑、拖拽等等,而ViewDropHandler接口则是为了让ItemTouchHelper可以和LayoutManager更好的集成,在这篇文章里并不是主角。RecyclerView.SmoothScroller.ScrollVectorProvider翻看源码注释解释为提供item位置的接口,这个接口在这篇文章中同样不重要。而今天我们的主角是RecyclerView.LayoutManager。要想实验自定义LayoutManager就必须实现这个类。我们先集成这个类,看一看有什么必须要我们重写的函数:
class LogLayoutManager(context: Context) : RecyclerView.LayoutManager() {
override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams? {
}
}
只强制实现一个函数,有过自定义view经验的小伙伴大概能从这个函数的名字猜出这个函数与控件的布局大小等有关,我们没有特殊需求,直接抄LinearLayoutManager的代码即可:
override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
return RecyclerView.LayoutParams(
RecyclerView.LayoutParams.WRAP_CONTENT,
RecyclerView.LayoutParams.WRAP_CONTENT
)
}
具体实现
上面的准备工作做好之后,我们不磨唧,直接上源码:
class LogLayoutManager(context: Context) : RecyclerView.LayoutManager() {
private val screenWidth: Int
private var mHorizontalOffset: Long = 0
private var arriveEnd = false
private val list= listOf(R.drawable.color_1,R.drawable.color_2,R.drawable.color_3,R.drawable.color_4)
init {
// 获取屏幕的宽高,这里获取屏幕的宽高是为了可以根据不同的手机屏幕动态的改变内部子item的大小
val metrics = context.resources.displayMetrics
screenWidth = metrics.widthPixels
}
override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
return RecyclerView.LayoutParams(
RecyclerView.LayoutParams.WRAP_CONTENT,
RecyclerView.LayoutParams.WRAP_CONTENT
)
}
//下面两个函数的作用是根据item所在的位置来计算此时item应该使用的缩放大小和旋转角度。
//这里详细说一下,根据上面的gif可以看出来,其实这种由远到近的效果就是缩放,美术里面有个词叫:透视。可能就是这个意思。
//然后我们再配合上旋转,即可,为什么不使用动画,因为onLayoutChildren的调用是非常密集的,变化了一个像素都会回调,所以可以直接使用布局
//来改变缩放和旋转,使用动画是可行的,我也尝试过,但在这么密集的调用下,并不好说哪个性能更好(即便动画专门做了硬件级别的优化)
private fun getScale(x: Float, offset: Float): Float {
val t = 1f - (0.05f / offset) * x
if (t > 1f) {
return 1f
}
return t
}
private fun getRotation(x: Float, offset: Float): Float {
val t = -4 - (4f / offset) * x
if (t > 0) {
return 0f
}
return t
}
override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
if (state.itemCount == 0) {
removeAndRecycleAllViews(recycler)
return
}
if (childCount == 0 && state.itemCount > 0) {
fill(recycler, 0)
}
}
private fun fill(recycler: RecyclerView.Recycler, dx: Int) {
val offset = screenWidth / 6.0f
var offsetX = offset - screenWidth - mHorizontalOffset
val startItem = if (mHorizontalOffset > 0) (mHorizontalOffset / offset).toInt() else 0
offsetX += offset * startItem
val endItem = min(startItem + (screenWidth / offset).toInt() + 4, itemCount)
recycleChildren(recycler, dx, startItem, endItem)
detachAndScrapAttachedViews(recycler)
for (i in startItem until endItem) {
val itemView = recycler.getViewForPosition(i)
addView(itemView)
measureChildWithMargins(itemView, 0, 0)
val width = getDecoratedMeasuredWidth(itemView)
val height = getDecoratedMeasuredHeight(itemView)
layoutDecorated(itemView, offsetX.toInt(), 0, width + offsetX.toInt(), height)
offsetX += width / 6
itemView.translationZ = (itemCount + 1.toFloat() * 10 - i)
val p = itemView.x + itemView.width - offset
val scale = getScale(p, offset)
val rotation = getRotation(p, offset)
itemView.scaleX = scale
itemView.scaleY = scale
itemView.rotation = rotation
itemView.background= itemView.resources.getDrawable(list[i%4],null)
itemView.alpha=0.95f
}
}
private fun recycleChildren(
recycler: RecyclerView.Recycler,
dx: Int,
startItem: Int,
endItem: Int
) {
// 用于跟踪需要回收的视图
val scrapList = mutableListOf<View>()
if (dx > 0) {
for (i in 0 until childCount) {
val child = getChildAt(i) ?: continue
val position = getPosition(child)
if (position < startItem) {
scrapList.add(child)
}
}
} else if (dx < 0) {
for (i in 0 until childCount) {
val child = getChildAt(i) ?: continue
val position = getPosition(child)
if (position >= endItem) {
scrapList.add(child)
}
}
}
for (child in scrapList) {
removeAndRecycleView(child, recycler)
}
}
//这个函数的返回值就会成为recycleview下次滑动的距离
override fun scrollHorizontallyBy(
dx: Int,
recycler: RecyclerView.Recycler,
state: RecyclerView.State
): Int {
//下面一系列的if都是在判断是否滑动到了边界,是的话就直接返回0,不准滑动了
if (dx == 0 || childCount == 0) {
return 0
}
val realDx = dx / 1.0f
if (abs(realDx) < 0.00000001f) {
return 0
}
mHorizontalOffset += dx
if (dx < 0 && mHorizontalOffset < 0) {
mHorizontalOffset = 0
}
if (dx < 0 && arriveEnd) {
mHorizontalOffset =
(itemCount * screenWidth / 6 - (screenWidth / (screenWidth / 6)) * (screenWidth / 6)).toLong()
arriveEnd = false
}
if (mHorizontalOffset > itemCount * screenWidth / 6 - (screenWidth / (screenWidth / 6)) * (screenWidth / 6)) {
mHorizontalOffset =
(itemCount * screenWidth / 6 - (screenWidth / (screenWidth / 6)) * (screenWidth / 6)).toLong() + 1
arriveEnd = true
fill(recycler, dx)
return 0
}
//这里调用fill函数,我们在最初调用fill函数只绘制了屏幕中能装下的item,这里调用fill函数,以便绘制滑动后会出现在屏幕中的item
fill(recycler, dx)
return dx
}
//这个函数是告诉recycleview滑动方向的,跟他对应的是canScrollVertically(),允许垂直滑动。
override fun canScrollHorizontally(): Boolean {
return true
}
}
代码并不长,其中一些比较细枝末节的地方我直接在其中使用注释讲解。下面我们说真正重要的地方:
告诉recycleview怎么排列子item–onLayoutChildren函数
override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
if (state.itemCount == 0) {
removeAndRecycleAllViews(recycler)
return
}
if (childCount == 0 && state.itemCount > 0) {
fill(recycler, 0)
}
}
我们单独来看源码,这里先统计了item的个数,如果没有,就直接回收所有的item,removeAndRecycleAllViews(recycler)这个函数提供了分离和回收子item的功能。否则就调用fill函数来绘制布局item:
private fun fill(recycler: RecyclerView.Recycler, dx: Int) {
//下面5行代码大意是定义了子item的初始位置,然后为了性能,我们计算一下屏幕可以装下多少item,然后只绘制这几个item,保证性能
val offset = screenWidth / 6.0f
var offsetX = offset - screenWidth - mHorizontalOffset
val startItem = if (mHorizontalOffset > 0) (mHorizontalOffset / offset).toInt() else 0
offsetX += offset * startItem
val endItem = min(startItem + (screenWidth / offset).toInt() + 4, itemCount)
//下面两行很重要
recycleChildren(recycler, dx, startItem, endItem)
detachAndScrapAttachedViews(recycler)
//下面就是简单的测量和放置view
for (i in startItem until endItem) {
//这里直接从getViewForPosition中得到子view,这个函数接受的值是item在recycleview中的index值,然后recycleview会去缓存池里面找
//有没有合适的,可以复用的view项,有就返回复用,没有就调用onCreateViewHolder搞个新的
val itemView = recycler.getViewForPosition(i)
addView(itemView)
//这里测量子view的大小和边距等,这一行必不可少
measureChildWithMargins(itemView, 0, 0)
val width = getDecoratedMeasuredWidth(itemView)
val height = getDecoratedMeasuredHeight(itemView)
//正式绘制
layoutDecorated(itemView, offsetX.toInt(), 0, width + offsetX.toInt(), height)
//保证偏移,不然绘制在一起了
offsetX += width / 6
//下面所有的代码大意为将view放置后,根据view位置计算旋转和缩放后,应用到子view上面
itemView.translationZ = (itemCount + 1.toFloat() * 10 - i)
val p = itemView.x + itemView.width - offset
val scale = getScale(p, offset)
val rotation = getRotation(p, offset)
itemView.scaleX = scale
itemView.scaleY = scale
itemView.rotation = rotation
itemView.background= itemView.resources.getDrawable(list[i%4],null)
itemView.alpha=0.95f
}
}
代码中细枝末节的地方在注释中解释了,我们着重看子view回收部分。
recycleview存在的理由–view复用
为了会出现recycleview而不是继续使用listview,原因就在于recycleview可以复用子view,只要item的viewholder是同一个,就算你有上万个,上亿个item,recycleview也只会循环利用固定的几个view,所以recycleview的性能非常爆炸,从而干掉了listview。而该回收哪些view,这个工作其实是交给我们今天的主角,LayoutManager的。所以我们既然自定义了LayoutManager,那我们也应该做好这个工作。我们废话也不多说,直接看回收相关的代码:
private fun recycleChildren(
recycler: RecyclerView.Recycler,
dx: Int,
startItem: Int,
endItem: Int
) {
// 用于跟踪需要回收的视图
val scrapList = mutableListOf<View>()
if (dx > 0) {
for (i in 0 until childCount) {
val child = getChildAt(i) ?: continue
val position = getPosition(child)
if (position < startItem) {
scrapList.add(child)
}
}
} else if (dx < 0) {
for (i in 0 until childCount) {
val child = getChildAt(i) ?: continue
val position = getPosition(child)
if (position >= endItem) {
scrapList.add(child)
}
}
}
for (child in scrapList) {
removeAndRecycleView(child, recycler)
}
}
就是这个函数,代码其实并不多,判断了dx的正负,来判断滑动的方向。比如说,如果item向左边移动,那左边的item就会不断的跑出屏幕外,我们就看不到了,那就直接加入回收的list里,最后统一回收,向右滑动同理。
总结
代码完成后,就可以像普通的LayoutManager一样直接使用。这就是我们实现的所有内容,其实代码并不长,也不难。掌握了自定义LayoutManager后就可以实现很多官方没有提供的效果,比如循环轮播图,限制你的就只剩下你的想象力。