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資源:源碼下載

有好想法,我們一起探討~
 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章