先上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区域。
|