自定义LayoutManager

202 阅读4分钟

之前很早的时候已经写过一篇自定义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()是自测量模式,给RecyclerViewwrap_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()
}