20、Header 与 Footer:在列表首尾添加额外 Item
列表开发中,有个需求特别常见——在列表最前面加个横幅,或者在最后面加个「加载更多」按钮。说白了,就是 Header 和 Footer。
我刚开始做 RecyclerView 时,以为这得用两个不同的 Adapter 或者嵌套布局来实现。后来发现,RecyclerView 本身就能优雅地搞定这件事。今天我们就来聊聊,怎么用 ListAdapter 给列表加上头尾。
为什么需要 Header 和 Footer?
你想想看,一个列表往往不只是展示同类型的数据。比如一个商品列表:
- 顶部可能有个轮播图或者搜索框(Header)
- 底部可能有个「已经到底了」的提示(Footer)
如果把这些内容硬塞进数据源里,代码会变得很别扭。更好的做法是:在 Adapter 层面区分 Item 类型。
核心思路:把 Header 和 Footer 当作特殊的 ViewType,在 Adapter 中分别创建和绑定。
实现方案概览
我个人习惯用 ListAdapter 配合 DiffUtil 来做。这样不仅性能好,而且代码结构清晰。整体流程是这样的:
代码实战:带 Header 和 Footer 的 ListAdapter
我们先定义一个数据模型。为了区分普通数据和头尾,我习惯用 密封类:
// 定义一个密封类,表示列表中的各种 Item
sealed class ListItem {
data class Header(val title: String) : ListItem()
data class Content(val id: Int, val name: String) : ListItem()
data class Footer(val message: String) : ListItem()
}
然后定义 ViewHolder。每种类型一个:
class HeaderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvTitle: TextView = itemView.findViewById(R.id.tvHeaderTitle)
fun bind(header: ListItem.Header) {
tvTitle.text = header.title
}
}
class ContentViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvName: TextView = itemView.findViewById(R.id.tvContentName)
fun bind(content: ListItem.Content) {
tvName.text = content.name
}
}
class FooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvMessage: TextView = itemView.findViewById(R.id.tvFooterMessage)
fun bind(footer: ListItem.Footer) {
tvMessage.text = footer.message
}
}
接下来是 Adapter 的核心。注意 getItemViewType 和 onCreateViewHolder 的配合:
class HeaderFooterAdapter : ListAdapter<ListItem, RecyclerView.ViewHolder>(DiffCallback()) {
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_CONTENT = 1
private const val TYPE_FOOTER = 2
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is ListItem.Header -> TYPE_HEADER
is ListItem.Content -> TYPE_CONTENT
is ListItem.Footer -> TYPE_FOOTER
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_HEADER -> {
val view = inflater.inflate(R.layout.item_header, parent, false)
HeaderViewHolder(view)
}
TYPE_CONTENT -> {
val view = inflater.inflate(R.layout.item_content, parent, false)
ContentViewHolder(view)
}
TYPE_FOOTER -> {
val view = inflater.inflate(R.layout.item_footer, parent, false)
FooterViewHolder(view)
}
else -> throw IllegalArgumentException("Unknown viewType: $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = getItem(position)) {
is ListItem.Header -> (holder as HeaderViewHolder).bind(item)
is ListItem.Content -> (holder as ContentViewHolder).bind(item)
is ListItem.Footer -> (holder as FooterViewHolder).bind(item)
}
}
class DiffCallback : DiffUtil.ItemCallback<ListItem>() {
override fun areItemsTheSame(oldItem: ListItem, newItem: ListItem): Boolean {
return when {
oldItem is ListItem.Content && newItem is ListItem.Content -> oldItem.id == newItem.id
oldItem is ListItem.Header && newItem is ListItem.Header -> oldItem.title == newItem.title
oldItem is ListItem.Footer && newItem is ListItem.Footer -> oldItem.message == newItem.message
else -> false
}
}
override fun areContentsTheSame(oldItem: ListItem, newItem: ListItem): Boolean {
return oldItem == newItem
}
}
}
💡 小技巧:我建议把 ViewType 常量定义在 companion object 里,这样可读性更好。另外,DiffUtil 的 areItemsTheSame 要针对每种类型分别判断,否则容易出 bug。
使用方式
在 Activity 或 Fragment 中,我们只需要构造数据源,然后提交给 Adapter:
val adapter = HeaderFooterAdapter()
// 构造数据
val items = listOf(
ListItem.Header("🔥 热门推荐"),
ListItem.Content(1, "商品A"),
ListItem.Content(2, "商品B"),
ListItem.Content(3, "商品C"),
ListItem.Footer("—— 已经到底了 ——")
)
adapter.submitList(items)
recyclerView.adapter = adapter
嗯,就是这么简单。你想想看,以前用 ListView 的时候,加 Header 还得调用 addHeaderView(),现在用 ViewType 的方式,反而更灵活了。
避坑指南
⚠️ 我曾经踩过的坑:
- position 偏移问题:如果你在 Adapter 外部计算 position,记得 Header 占了一个位置。比如点击第 3 个 Content,实际 position 是 4(因为 Header 在 0)。
- DiffUtil 的稳定性:如果 Header 或 Footer 的数据不变,DiffUtil 不会触发刷新。但如果你动态修改了 Header 的内容,记得更新数据源中的对应对象。
- 嵌套滚动冲突:如果 Header 里有个可滚动的 View(比如横向 RecyclerView),记得处理好嵌套滚动,否则会出现滑动卡顿。
进阶:动态控制 Header 和 Footer 的显示
有时候 Header 不是一直显示的。比如网络加载失败时,我们想隐藏 Header,显示一个错误 Footer。我一般这样做:
fun updateWithLoadingState(isLoading: Boolean, error: String?) {
val newList = mutableListOf<ListItem>()
// 只在非加载状态时显示 Header
if (!isLoading) {
newList.add(ListItem.Header("📢 今日推荐"))
}
// 添加数据
newList.addAll(dataList.map { ListItem.Content(it.id, it.name) })
// 如果有错误,显示错误 Footer
if (error != null) {
newList.add(ListItem.Footer("加载失败:$error"))
} else if (!isLoading) {
newList.add(ListItem.Footer("—— 加载完成 ——"))
}
submitList(newList)
}
说白了,就是把业务逻辑放在数据源构建阶段。Adapter 只负责渲染,不关心数据从哪来。这样代码职责清晰,后期维护也方便。
性能考量
| 场景 | 建议 |
|---|---|
| Header 固定不变 | 使用 setHasStableIds(true) 并返回固定 ID,避免重复创建 |
| Header 频繁更新 | 确保 DiffUtil 能正确识别变化,避免全量刷新 |
| 列表数据量很大 | Header 和 Footer 的布局尽量轻量,不要嵌套太深 |
| Footer 是加载更多 | 配合 Paging 3 使用,效果更佳 |
总结一下:Header 和 Footer 的实现,本质上就是多 ViewType 的一种应用。用密封类 + ListAdapter 的方式,代码清晰、性能好、易维护。我在多个项目中都用这个方案,没出过问题。
好了,这一章就到这里。记住:不要把 Header 和 Footer 当成特殊的存在,它们只是列表中的另一种 Item 而已。