RecyclerView动态头部实现问题:API数据按月份分组展示
Hey there! Let's break down how to implement this grouped RecyclerView with month headers, even when dealing with dynamic API data that has variable item counts. Here's a practical, step-by-step solution:
The first thing you need to do is transform your raw API response into a structured list that mixes month headers and their corresponding content items.
First, define a sealed class to distinguish between header and content items (this makes handling view types easier later):
sealed class ListItem { data class MonthHeader(val monthName: String) : ListItem() data class ContentItem(val data: YourApiModel) : ListItem() }
Next, write a function to group your API data by month and build the mixed list:
fun processApiData(apiData: List<YourApiModel>): List<ListItem> { // Group raw data by month (assume YourApiModel has a 'month' field like "MAR") val groupedByMonth = apiData.groupBy { it.month } val processedList = mutableListOf<ListItem>() // Optional but critical: Sort months chronologically (not random API order) val monthOrderMap = mapOf( "JAN" to 1, "FEB" to 2, "MAR" to 3, "APR" to 4, "MAY" to 5, "JUN" to 6, "JUL" to 7, "AUG" to 8, "SEP" to 9, "OCT" to 10, "NOV" to 11, "DEC" to 12 ) val sortedGroups = groupedByMonth.entries.sortedBy { monthOrderMap[it.key] } // Populate the list with headers followed by their content sortedGroups.forEach { (month, items) -> processedList.add(ListItem.MonthHeader(month)) items.forEach { processedList.add(ListItem.ContentItem(it)) } } return processedList }
Since you have two distinct item types (headers and content), your adapter needs to handle both. Here's a clean implementation:
class GroupedMonthAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() { private val items = mutableListOf<ListItem>() companion object { private const val VIEW_TYPE_HEADER = 0 private const val VIEW_TYPE_CONTENT = 1 } fun updateData(newItems: List<ListItem>) { val diffCallback = ListItemDiffCallback(items, newItems) val diffResult = DiffUtil.calculateDiff(diffCallback) items.clear() items.addAll(newItems) diffResult.dispatchUpdatesTo(this) } override fun getItemViewType(position: Int): Int { return when (items[position]) { is ListItem.MonthHeader -> VIEW_TYPE_HEADER is ListItem.ContentItem -> VIEW_TYPE_CONTENT } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) return when (viewType) { VIEW_TYPE_HEADER -> { val view = inflater.inflate(R.layout.item_month_header, parent, false) HeaderViewHolder(view) } else -> { val view = inflater.inflate(R.layout.item_content, parent, false) ContentViewHolder(view) } } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (val item = items[position]) { is ListItem.MonthHeader -> (holder as HeaderViewHolder).bind(item) is ListItem.ContentItem -> (holder as ContentViewHolder).bind(item.data) } } override fun getItemCount() = items.size // ViewHolder classes class HeaderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { fun bind(header: ListItem.MonthHeader) { itemView.tv_month_header.text = header.monthName // Add styling here (bold text, background color, padding, etc.) } } class ContentViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { fun bind(data: YourApiModel) { // Bind your content data to views itemView.tv_title.text = data.title itemView.tv_details.text = data.details } } }
To avoid janky full-list refreshes when your API data updates, use DiffUtil to only update changed items. Add this callback class:
class ListItemDiffCallback( private val oldList: List<ListItem>, private val newList: List<ListItem> ) : DiffUtil.Callback() { override fun getOldListSize() = oldList.size override fun getNewListSize() = newList.size override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean { val oldItem = oldList[oldPos] val newItem = newList[newPos] return when { oldItem is ListItem.MonthHeader && newItem is ListItem.MonthHeader -> oldItem.monthName == newItem.monthName oldItem is ListItem.ContentItem && newItem is ListItem.ContentItem -> oldItem.data.id == newItem.data.id // Use your model's unique ID else -> false } } override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean { return oldList[oldPos] == newList[newPos] // Ensure your data classes (ListItem and YourApiModel) are 'data classes' so equals() works } }
If you want headers to stay fixed at the top while scrolling, you can implement a RecyclerView.ItemDecoration to draw and position the sticky header. Here's a simplified snippet to get you started:
class StickyHeaderDecoration(private val adapter: GroupedMonthAdapter) : RecyclerView.ItemDecoration() { override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { super.onDrawOver(c, parent, state) val topChild = parent.getChildAt(0) ?: return val topChildPos = parent.getChildAdapterPosition(topChild) if (topChildPos == RecyclerView.NO_POSITION) return val currentHeader = getHeaderView(topChildPos, parent) val contactPoint = currentHeader.bottom val childInContact = getChildInContact(parent, contactPoint) ?: return if (isHeader(parent.getChildAdapterPosition(childInContact))) { moveHeader(c, currentHeader, childInContact) return } drawHeader(c, currentHeader) } // Helper methods to fetch header views, check if an item is a header, etc. private fun getHeaderView(position: Int, parent: RecyclerView): View { val headerItem = adapter.items[position] as ListItem.MonthHeader val holder = adapter.onCreateViewHolder(parent, GroupedMonthAdapter.VIEW_TYPE_HEADER) as GroupedMonthAdapter.HeaderViewHolder holder.bind(headerItem) holder.itemView.measure( View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(View.LayoutParams.WRAP_CONTENT, View.MeasureSpec.UNSPECIFIED) ) holder.itemView.layout(0, 0, holder.itemView.measuredWidth, holder.itemView.measuredHeight) return holder.itemView } private fun isHeader(position: Int): Boolean { return adapter.items[position] is ListItem.MonthHeader } private fun getChildInContact(parent: RecyclerView, contactY: Int): View? { for (i in 0 until parent.childCount) { val child = parent.getChildAt(i) if (child.bottom > contactY && child.top <= contactY) { return child } } return null } private fun moveHeader(c: Canvas, header: View, child: View) { c.save() c.translate(0f, (child.top - header.height).toFloat()) header.draw(c) c.restore() } private fun drawHeader(c: Canvas, header: View) { c.save() c.translate(0f, 0f) header.draw(c) c.restore() } }
Then attach it to your RecyclerView:
recyclerView.addItemDecoration(StickyHeaderDecoration(adapter))
That's the full workflow! The core idea is transforming your ungrouped API data into a list that alternates headers and content, then letting the adapter handle the different view types. This setup works seamlessly no matter how many items your API returns.
内容的提问来源于stack exchange,提问作者Maulik Dodia

