Android 实现分组标题吸顶效果,支持上下左右padding

先上gif效果图:

技术方案:RecycleView + ItemDecoration

具体实现:

第一步:先实现相关业务代码,让数据加载出来

Activity:

/**
 * 实现吸顶效果 演示
 */
class RecyclerViewActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_recyclerview)
        val data: MutableList<DataBean> = getData()
        val adapter = RVAdapter(data)
        act_recyclerview_rv.adapter = adapter
        act_recyclerview_rv.layoutManager = LinearLayoutManager(this)
        val divider = DividerItemDecoration(this,DividerItemDecoration.VERTICAL)
        divider.setDrawable(getDrawable(R.drawable.shape_divider)!!)
//        act_recyclerview_rv.addItemDecoration(divider)
        //自定义itemDecoration 实现吸顶效果
        act_recyclerview_rv.addItemDecoration(MyItemDecoration())

    }

    private fun getData(): MutableList<DataBean> {
        val data: MutableList<DataBean> = mutableListOf()
        for (i in 0..2) {
            for (j in 0..9) {
                if (i == 0) {
                    data.add(DataBean("曹操$i$j","曹操分组"))
                } else if (i == 1) {
                    data.add(DataBean("刘备$i$j","刘备分组"))
                } else if (i == 2) {
                    data.add(DataBean("孙权$i$j","孙权分组"))
                }
            }
        }
        return data
    }
}
R.layout.activity_recyclerview
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:layout_margin="10dp"
        android:padding="20dp"
        android:id="@+id/act_recyclerview_rv"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</FrameLayout>
RVAdapter
class RVAdapter : RecyclerView.Adapter<RVViewHolder> {
    var data: MutableList<DataBean> = mutableListOf()

    constructor(data: MutableList<DataBean>) {
        this.data = data
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RVViewHolder {
        val view =
            LayoutInflater.from(parent.context).inflate(R.layout.item_act_rec_rv, parent, false)
        view.setOnClickListener {
            ToastUtil.showShortToast("you click ${view.findViewById<TextView>(R.id.act_rec_rv_item_tv).text}")
        }
        return RVViewHolder(view)
    }

    override fun onBindViewHolder(holder: RVViewHolder, position: Int) {
        holder.name!!.text = data[position].name
    }

    override fun getItemCount(): Int {
        return data.size
    }

    /**
     *  判断此位置是否是每一组的第一个view
     */
    fun isFirstGroupView(childLayoutPos: Int): Boolean {
        if (childLayoutPos == 0) {
            return true
        }
        if (data[childLayoutPos].groupName != data[childLayoutPos - 1].groupName) {
            return true
        }
        return false
    }

    fun getGroupName(childLayoutPosition: Int): String {
        return data[childLayoutPosition].groupName
    }

}
RVViewHolder
class RVViewHolder : RecyclerView.ViewHolder {
    var name: TextView? = null

    constructor(view: View) : super(view) {
        name = view.findViewById(R.id.act_rec_rv_item_tv)
    }
}
DataBean
data class DataBean(
    var name: String,
    var groupName: String
)
ZSConstants
object ZSConstants {
    val TITLE_TEXT_SIZE: Int = 18
    val DIVIDER_HEIGHT: Int = 10
    //此变量和布局文件中设置的高度保持一致
    val ITEM_HEIGHT: Int = 60
    val GROUP_HEIGHT: Int = 40
    val GROUP_NAME_MARGIN: Int = 10
}

第二步:利用自定义ItemDecoration来实现吸顶效果,并处理RecycleView的各种padding

相关说明都写在了注释里面,代码如下:

/**
 * 自定义分割线实现分类标题自动吸顶效果
 * 如果 需求是分组标题支持点击的话 当前是不满足的,就得切换实现思路了,思路如下:
 * (1)group标题直接使用item实现并且实现点击事件,这种情况在getItemOffsets里面就没有必要在预留那么大的空间了,因为不需要onDraw来绘制分组信息了
 * (2)吸顶时还是要通过onDrawOver来绘制悬浮到顶部,此时的点击事件比较麻烦,需要通过RecycleView的onTouch事件来根据点击位置来处理
 * 点击时的顶部的这个区域就是当前的吸顶布局了,然后做处理就可以了,(要记录下现在哪个分组在顶部)
 */
class MyItemDecoration : RecyclerView.ItemDecoration {

    private val headPaint = Paint()
    private val headPaint2 = Paint()
    private val textPaint = Paint()
    private val groupHeight: Float = DensityUtil.dp2px(ZSConstants.GROUP_HEIGHT).toFloat()
    private val dividerHeight: Float = DensityUtil.dp2px(ZSConstants.DIVIDER_HEIGHT).toFloat()
    private val groupNameMargin: Float = DensityUtil.dp2px(ZSConstants.GROUP_NAME_MARGIN).toFloat()

    constructor() {
        headPaint.color = Color.parseColor("#ff0000")
        headPaint.style = Paint.Style.FILL
        headPaint2.color = Color.parseColor("#00ff00")
        headPaint2.style = Paint.Style.FILL
        textPaint.color = Color.BLACK
        textPaint.isDither = true
        textPaint.isAntiAlias = true
        textPaint.textSize = DensityUtil.dp2px(ZSConstants.TITLE_TEXT_SIZE).toFloat()
    }

    /**
     * 此方法绘制的内容在RecyclerView item下面,因此可能会被item挡住
     */
    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDraw(c, parent, state)
        //在预留出的空间中 绘制分组标题
        val adapter = parent.adapter
        val left: Float = parent.paddingLeft.toFloat()
        val right: Float = parent.width.toFloat() - parent.paddingRight
        if (adapter is RVAdapter) {
            //获取可见view的个数
            val childCount = parent.childCount
            //循环遍历去绘制
            for (i in 0 until childCount) {
                c.save()
                //得到屏幕上显示的view
                val view = parent.getChildAt(i)
                //得到该view在整个列表布局中的位置
                val childLayoutPosition = parent.getChildLayoutPosition(view)
                //判断该位置是否是每组view的第一个
                val isFirstGroupView = adapter.isFirstGroupView(childLayoutPosition)
                if (isFirstGroupView &&
                    //头部屏蔽没有必要的绘制
                    view.top - groupHeight - parent.paddingTop >= 0 &&
                    //底部屏蔽没有必要的绘制
                    view.top <= parent.measuredHeight - parent.paddingBottom + groupHeight
                ) {
                    // 最底部的分割线需要c.clip一下
                    if (view.top.toFloat() > parent.measuredHeight - parent.paddingBottom) {
                        val rect = Rect(
                            left.toInt(),
                            view.top - groupHeight.toInt(),
                            right.toInt(),
                            parent.measuredHeight - parent.paddingBottom
                        )
                        c.clipRect(rect)
                    }
                    //绘制分组矩形背景
                    c.drawRect(
                        left,
                        view.top - groupHeight,
                        right,
                        view.top.toFloat(),
                        headPaint
                    )
                    //绘制标题文本
                    val text: String = adapter.getGroupName(childLayoutPosition)
                    c.drawText(
                        text,
                        left + groupNameMargin,
                        view.top - groupHeight / 2 + abs(textPaint.fontMetrics.ascent) / 2 - textPaint.fontMetrics.descent / 2,
                        textPaint
                    )
                } else if (
                //头部屏蔽没有必要的绘制
                    view.top - groupHeight - parent.paddingTop >= 0 &&
                    //底部屏蔽没有必要的绘制
                    view.top <= parent.measuredHeight - parent.paddingBottom + dividerHeight
                ) {
                    //绘制分割线
                    if (i == childCount - 1) {
                        log("parent height - parent.paddingBottom = ${parent.measuredHeight - parent.paddingBottom} view.top=${view.top}")
                    }
                    //最底部的分割线需要c.clip一下
                    if (view.top.toFloat() > parent.measuredHeight - parent.paddingBottom) {
                        val rect = Rect(
                            left.toInt(),
                            view.top - dividerHeight.toInt(),
                            right.toInt(),
                            parent.measuredHeight - parent.paddingBottom
                        )
                        c.clipRect(rect)
                    }
                    c.drawRect(
                        left,
                        view.top.toFloat() - dividerHeight.toInt(),
                        right,
                        view.top.toFloat(),
                        headPaint
                    )
                }
                c.restore()
            }
        }
    }

    /**
     * 此方法绘制的内容在RecyclerView item上面,因此会在最外层显示,可以挡住item
     */
    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)
        val adapter = parent.adapter
        val left: Float = parent.paddingLeft.toFloat()
        val top: Float = parent.paddingTop.toFloat()
        val right: Float = parent.width.toFloat() - parent.paddingRight
        if (adapter is RVAdapter) {
            //拿到第一个可见的view
            val firstVisiblePos =
                (parent.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
            val viewHolder = parent.findViewHolderForLayoutPosition(firstVisiblePos)
            val itemView = viewHolder!!.itemView
            //日志打印
//            val textView = itemView.findViewById<TextView>(R.id.act_rec_rv_item_tv)
//            log("${textView.text} firstVisiblePos = $firstVisiblePos")
            //判断当前位置的下一个是否是分组的第一个view
            //为甚是下一个,因为当前的那个被onDrawOver位置的常驻标题挡住了
            //所以如果下一个是分组第一个的话,刚好开始执行推动的效果
            val isFirstGroupView = adapter.isFirstGroupView(firstVisiblePos + 1)
            if (isFirstGroupView) {
                //慢慢往上推动
//                log("${itemView.top} itemView.bottom = ${itemView.bottom}")
//                log("top-$top itemView.top=${itemView.top} itemView.bottom = ${itemView.bottom}")
                val bottom = min(groupHeight, itemView.bottom.toFloat() - top) + top
                c.drawRect(left, top, right, bottom, headPaint2)
                val y =
                    bottom - groupHeight / 2 + abs(textPaint.fontMetrics.ascent) / 2 - textPaint.fontMetrics.descent / 2
                val rect = Rect(0, top.toInt(), right.toInt(), bottom.toInt())
                c.clipRect(rect)
                val text: String = adapter.getGroupName(firstVisiblePos)
                c.drawText(
                    text,
                    left + groupNameMargin,
                    y,
                    textPaint
                )
            } else {
                //标题常驻在顶部
                c.drawRect(left, top, right, top + groupHeight, headPaint2)
                val y =
                    top + groupHeight - groupHeight / 2 + abs(textPaint.fontMetrics.ascent) / 2 - textPaint.fontMetrics.descent / 2
                val text: String = adapter.getGroupName(firstVisiblePos)
                c.drawText(
                    text,
                    left + groupNameMargin,
                    y,
                    textPaint
                )
            }
        }

    }

    /**
     * 通过此方法来设置item的预留区间,进而给ItemDecoration留出位置
     * 只绘制可见部分,滚动到屏幕内的则进行绘制
     */
    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        //拿到对应的adapter
        val adapter = parent.adapter
        if (adapter is RVAdapter) {
            //拿到当前view所在的位置
            val childLayoutPos = parent.getChildLayoutPosition(view)
            //判断此view是否是每一组的第一个view
            if (adapter.isFirstGroupView(childLayoutPos)) {
                outRect.set(0, groupHeight.toInt(), 0, 0)
            } else {
                outRect.set(0, dividerHeight.toInt(), 0, 0)
            }

            //日志打印
            val textView = view.findViewById<TextView>(R.id.act_rec_rv_item_tv)
//            log("${textView.text} childLayoutPos = $childLayoutPos")
        }

    }
}

注意:涉及到具体的尺寸计算,特别是bottom、top之类的要十分细心小心,可以自己画画图来理解,也可以把工程跑起来,根据效果一点一点去理解。

难点就在于两个标题靠在一起时上面的标题慢慢被顶上去,这里的实现思路是在onDrawOver方法里面不断绘制上面的标题空间,让bottom不断减小(减小就是往上走),标题文字的绘制也要跟着往上走,然后还要通过canvas的clipRect方法去裁剪绘制区域,要不然会绘制到RecycleView paddingTop区域。

猜你喜欢

转载自blog.csdn.net/u013347784/article/details/123178052