RecyclerView ItemDecoration-实现分组/悬浮(粘性)头部【Kotlin】

简单说ItemDecoration就是Item的装饰,在Item的四周,我们可以给它添加上自定义的装饰。

 

ItemDecoration主要就三个方法 : ) 

getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State){}

onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State){}

onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State){}

实现效果:

              sticky-header-decorator gif

 

直接上代码(代码带注释)
1. Activity/Fragment中 : ) 
创建:

private val testRecyclerAdapter by lazy {
    TestRecyclerAdapter()
}
private val linearLayoutManager by lazy {
    LinearLayoutManager(context)
}
private val stickyHeaderDecorator by lazy {
    StickyHeaderDecorator(requireContext())
}

赋值:

with(rv_view) {
    layoutManager = linearLayoutManager
    adapter = testRecyclerAdapter
    addItemDecoration(stickyHeaderDecorator)
}

同步更新数据:

val textData = TextDataUtils().getTestData()
textData.sortBy { it.title }//排序
val list = textData.map { bean -> bean.title }//记录每个item分组标题
stickyHeaderDecorator.setCategoryList(list)//同步分组标题数据Decorator
testRecyclerAdapter.addAllItems(textData)//同步数据至Adapter

 

2. 接下来就是实现StickyHeaderDecorator
直接上代码 : ) 

class StickyHeaderDecorator(context: Context) : RecyclerView.ItemDecoration() {

    var hideCategoryHeader: ((isHide: Boolean) -> Unit)? = null

    var updateCategoryHeader: ((categoryName: String) -> Unit)? = null

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val colorBg = context.resources.getColor(R.color.primary_purple)
    private val colorText = context.resources.getColor(R.color.primary_white)

    private val categoryList = mutableListOf<String>()
    private val categorySet = mutableSetOf<String>()//记录有多少组子标题
    val categoryHeaderMap = mutableMapOf<String, Int>()//记录每组子标题开始的位置
    private var categoryName = ""

    fun setCategoryList(value: List<String>) {
        categoryList.clear()
        categoryList.addAll(value)
        categorySet.clear()
        categorySet.addAll(value)

        //如果分组只有一个的情况,即隐藏粘性标题
        if (categorySet.size > 1) {
            hideCategoryHeader?.invoke(false)
        } else {
            hideCategoryHeader?.invoke(true)
        }
    }

    //设置文字属性
    private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
        color = colorText
        textSize = 18.toSp()
    }

    private val headerMarginStart = 36.toDp() //子标题内容与左侧的距离
    private val headerSpaceHeight = 60.toDp() //为每个子标题对应最后一个item添加空隙高度
    private val headerBackgroundHeight = 40.toDp()//子标题背景高度
    private val headerBackgroundRadius = 10.toDp()//为子标题背景设置圆角

    //简单的理解
    // 设置item布局间隙(留空间给draw方法绘制)
    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        if (isHideInventoryHeader()) return
        val adapterPosition = parent.getChildAdapterPosition(view)
        if (adapterPosition == RecyclerView.NO_POSITION) {
            return
        }
        //Top 头部
        if (isFirstOfGroup(adapterPosition)) {
            outRect.top = headerBackgroundHeight.toInt()
            categoryHeaderMap[categoryList[adapterPosition]] = adapterPosition
        }
        //Bottom 底部
        if (isEndOfGroup(adapterPosition)) {
            outRect.bottom = headerSpaceHeight.toInt()
        }
    }

    //可在此方法中绘制背景
    override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        if (isHideInventoryHeader()) return
        val count = parent.childCount
        if (count == 0) {
            return
        }
        for (i in 0 until parent.childCount) {
            val child = parent.getChildAt(i)
            val adapterPosition = parent.getChildAdapterPosition(child)
            if (isFirstOfGroup(adapterPosition)) {
                val left = child.left.toFloat()
                val right = child.right.toFloat()
                val top = child.top.toFloat() - headerBackgroundHeight
                val bottom = child.top.toFloat()
                val radius = headerBackgroundRadius
                paint.color = colorBg
                //绘制背景
                canvas.drawRoundRect(
                    left, top, right, bottom, radius,
                    radius, paint
                )
            }
        }
    }

    //留的空间给draw方法绘制内容/粘性标题也在此设置
    override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        if (isHideInventoryHeader()) return
        val count = parent.childCount
        if (count == 0) {
            return
        }
        //在每个背景上绘制文字
        drawHeaderTextIndex(canvas, parent)

        //绘制粘性标题
        drawStickyTimestampIndex(canvas, parent)
    }

    private fun drawHeaderTextIndex(canvas: Canvas, parent: RecyclerView) {
        for (i in 0 until parent.childCount) {
            val child = parent.getChildAt(i)
            val adapterPosition = parent.getChildAdapterPosition(child)
            if (adapterPosition == RecyclerView.NO_POSITION) {
                return
            }
            if (isFirstOfGroup(adapterPosition)) {
                val categoryName = categoryList[adapterPosition]
                val start = child.left + headerMarginStart
                val fontMetrics = textPaint.fontMetrics
                //计算文字自身高度
                val fontHeight = fontMetrics.bottom - fontMetrics.top
                val baseline =
                    child.top.toFloat() - (headerBackgroundHeight - fontHeight) / 2 - fontMetrics.bottom
                canvas.drawText(categoryName.toUpperCase(), start, baseline, textPaint)
            }
        }
    }

    private fun drawStickyTimestampIndex(canvas: Canvas, parent: RecyclerView) {
        val layoutManager = parent.layoutManager as LinearLayoutManager
        val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
        if (firstVisiblePosition != RecyclerView.NO_POSITION) {
            val firstVisibleChildView =
                parent.findViewHolderForAdapterPosition(firstVisiblePosition)?.itemView
            firstVisibleChildView?.let { child ->
                val firstChild = parent.getChildAt(0)
                val left = firstChild.left.toFloat()
                val right = firstChild.right.toFloat()
                val top = 0.toFloat()
                val bottom = headerBackgroundHeight
                val radius = headerBackgroundRadius
                paint.color = colorBg

                val name = categoryList[firstVisiblePosition]
                if (categoryName != name) {
                    categoryName = name
                    // 监听当前滚动到的标题
                    categoryName?.let { name ->
                        updateCategoryHeader?.invoke(name)
                    }
                }
                val start = child.left + headerMarginStart
                //计算文字高度
                val fontMetrics = textPaint.fontMetrics
                val fontHeight = fontMetrics.bottom - fontMetrics.top
                val baseline =
                    headerBackgroundHeight - (headerBackgroundHeight - fontHeight) / 2 - fontMetrics.bottom

                var upwardBottom = bottom
                var upwardBaseline = baseline
                // 下一个组马上到达顶部
                if (isFirstOfGroup(firstVisiblePosition + 1)) {
                    upwardBottom = min(child.bottom.toFloat() + headerSpaceHeight, bottom)
                    if (child.bottom.toFloat() + headerSpaceHeight < headerBackgroundHeight) {
                        upwardBaseline = baseline * (child.bottom.toFloat() + headerSpaceHeight)/headerBackgroundHeight
                    }
                }
                //绘制粘性标题背景
                canvas.drawRoundRect(left, top, right, upwardBottom, radius, radius, paint)
                //绘制粘性标题
                canvas.drawText(categoryName.toUpperCase(), start, upwardBaseline, textPaint)
            }
        }
    }

    //判断是不是每组的第一个item
    private fun isFirstOfGroup(adapterPosition: Int): Boolean {
        return adapterPosition == 0 || categoryList[adapterPosition] != categoryList[adapterPosition - 1]
    }

    //判断是不是每组的最后一个item
    private fun isEndOfGroup(adapterPosition: Int): Boolean {
        if (adapterPosition + 1 == categoryList.size) return true
        return categoryList[adapterPosition] != categoryList[adapterPosition + 1]
    }

    //如果分组只有一个的情况,即隐藏标题
    private fun isHideInventoryHeader(): Boolean {
        return categorySet.size <= 1 || categoryList.isNullOrEmpty()
    }
}

 

3.  RecyclerAdapter 我还是贴一下代码,就正常写:)

class TestRecyclerAdapter : RecyclerView.Adapter<TextViewHolder>() {

    private val textBeans: MutableList<TextBean> = mutableListOf()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TextViewHolder {
        return TextViewHolder(parent.inflate(R.layout.rv_test_item))
    }

    override fun getItemCount()= textBeans.size

    override fun onBindViewHolder(holder: TextViewHolder, position: Int) {
        holder.bind(textBeans[position])
    }

    fun addAllItems(items: List<TextBean>) {
        textBeans.clear()
        textBeans.addAll(items)
        notifyDataSetChanged()
    }
}

 ViewHolder:)

class TextViewHolder(view: View): RecyclerView.ViewHolder(view){

    open fun bind(testText: TextBean) {
        with(itemView) {
            item_text.text = testText.desc
        }

        itemView.setOnClickListener {
            //TODO
        }
    }
}

cc: 因为是用 Kotlin实现,里面带有Kotlin的扩展方法,我再补上:)

fun ViewGroup.inflate(@LayoutRes id: Int): View {
    return LayoutInflater.from(this.context).inflate(id, this, false)
}

fun Int.toDp(): Float = (this * Resources.getSystem().displayMetrics.density)

fun Int.toSp(): Float = (this * Resources.getSystem().displayMetrics.scaledDensity)


Git地址:StickyHeaderDecoratorDemo
CSDN资源:源码下载

有好想法,我们一起探讨~
 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值