在 Android 开发中,RecyclerView 已经成为展示列表数据的首选组件。为了提升用户体验,经常需要实现一些高级效果,例如粘性头部。本文将深入探讨如何使用 RecyclerView 实现类似微信账单列表的粘性头部效果,即月份标题能够吸附在顶部,并在下一个月份标题出现时平滑过渡。
问题场景重现
用户浏览账单时,期望快速定位到特定月份的账单数据。如果仅仅是简单的列表,用户需要手动滑动才能找到对应月份。而粘性头部可以始终显示当前浏览的月份,极大地提升了用户体验,特别是当列表很长时。
底层原理深度剖析
实现粘性头部效果的核心在于:
- 数据源处理:需要对数据源进行预处理,将列表数据按照月份进行分组,并为每个月份创建一个 Header 数据项。
- RecyclerView.ItemDecoration:利用
ItemDecoration可以在 RecyclerView 的 item 绘制之前或之后进行绘制,我们可以利用它来绘制粘性头部。 - Canvas 绘制:在
ItemDecoration的onDrawOver()方法中,根据当前 RecyclerView 的滑动状态,动态计算 Header 的位置并进行绘制。
这个过程中,性能优化至关重要。频繁的 onDrawOver 调用可能导致卡顿,需要避免不必要的绘制操作。同时,合理的缓存机制可以减少重复计算,提升性能。
代码实现
1. 数据源准备
首先,定义 Header 数据类:
data class HeaderItem(val month: String)
然后,将原始数据转换为包含 HeaderItem 的列表:
fun prepareData(originalList: List<Bill>): List<Any> {
val result = mutableListOf<Any>()
var currentMonth: String? = null
originalList.forEach { bill ->
val month = bill.date.substring(0, 7) // 假设日期格式为 yyyy-MM-dd
if (month != currentMonth) {
result.add(HeaderItem(month))
currentMonth = month
}
result.add(bill)
}
return result
}
2. RecyclerView.Adapter 实现
Adapter 需要处理两种类型的 item:HeaderItem 和 Bill。
class BillAdapter(private val dataList: List<Any>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_BILL = 1
}
override fun getItemViewType(position: Int): Int {
return when (dataList[position]) {
is HeaderItem -> TYPE_HEADER
is Bill -> TYPE_BILL
else -> throw IllegalArgumentException("Invalid item type")
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
TYPE_HEADER -> HeaderViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_header, parent, false))
TYPE_BILL -> BillViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_bill, parent, false))
else -> throw IllegalArgumentException("Invalid view type")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderViewHolder -> {
val headerItem = dataList[position] as HeaderItem
holder.bind(headerItem)
}
is BillViewHolder -> {
val bill = dataList[position] as Bill
holder.bind(bill)
}
}
}
override fun getItemCount(): Int {
return dataList.size
}
class HeaderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(headerItem: HeaderItem) {
// 绑定 Header 数据
}
}
class BillViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(bill: Bill) {
// 绑定 Bill 数据
}
}
}
3. ItemDecoration 实现
class StickyHeaderItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() {
private val headerHeight: Int = context.resources.getDimensionPixelSize(R.dimen.header_height)
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state)
val topChild = parent.getChildAt(0) ?: return
val topChildPosition = parent.getChildAdapterPosition(topChild)
if (topChildPosition == RecyclerView.NO_POSITION) {
return
}
val headerView = getHeaderView(parent, topChildPosition) ?: return
// 计算 Header 位置
val nextHeaderPosition = getNextHeaderPosition(parent, topChildPosition)
var translationY = 0f
if (nextHeaderPosition != -1 && topChild.top <= headerHeight) {
translationY = topChild.top - headerHeight.toFloat()
}
// 绘制 Header
c.save()
c.translate(0f, translationY)
headerView.draw(c)
c.restore()
}
private fun getHeaderView(parent: RecyclerView, position: Int): View? {
val adapter = parent.adapter ?: return null
if (adapter.getItemViewType(position) != BillAdapter.TYPE_HEADER) {
return null
}
val header = (adapter as BillAdapter).dataList[position] as HeaderItem
// 创建 Header View 并绑定数据
val headerView = LayoutInflater.from(context).inflate(R.layout.item_header, parent, false)
// 绑定 Header 数据到 headerView
return headerView.apply {
measure(View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED))
layout(0, 0, measuredWidth, measuredHeight)
}
}
private fun getNextHeaderPosition(parent: RecyclerView, currentPosition: Int): Int {
val adapter = parent.adapter ?: return -1
for (i in currentPosition + 1 until adapter.itemCount) {
if (adapter.getItemViewType(i) == BillAdapter.TYPE_HEADER) {
return i
}
}
return -1
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
val position = parent.getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) {
return
}
val adapter = parent.adapter ?: return
if (adapter.getItemViewType(position) == BillAdapter.TYPE_HEADER) {
outRect.top = headerHeight
}
}
}
4. RecyclerView 初始化
recyclerView.adapter = BillAdapter(dataList)
recyclerView.addItemDecoration(StickyHeaderItemDecoration(this))
recyclerView.layoutManager = LinearLayoutManager(this)
实战避坑经验总结
- 性能优化:
onDrawOver()方法会频繁调用,务必避免在此方法中进行耗时操作。例如,可以缓存 HeaderView,避免每次都重新 inflate。可以使用View.MeasureSpec.makeMeasureSpec()配合View.layout()手动测量和布局 headerView,避免每次都进行addView()操作。 - 数据源更新:当数据源发生变化时,需要重新计算 Header 的位置,并刷新 RecyclerView。
- 多类型 Item 处理:如果 RecyclerView 中存在多种类型的 Item,需要在
getItemViewType()方法中进行区分,并在onDrawOver()方法中进行相应的处理。 - 滑动冲突:在嵌套 RecyclerView 的场景下,需要处理好滑动冲突,确保粘性头部效果能够正常显示。
类似微信账单列表的 RecyclerView 粘性头部效果,通过自定义 ItemDecoration 实现,能显著提升用户体验。核心是准确计算 Header 的位置并进行绘制,同时关注性能优化。在实际开发中,可结合自己的业务场景进行调整和扩展,例如添加点击事件、动画效果等。
冠军资讯
CoderPunk