20、Header 与 Footer:在列表首尾添加额外 Item

列表开发中,有个需求特别常见——在列表最前面加个横幅,或者在最后面加个「加载更多」按钮。说白了,就是 HeaderFooter

我刚开始做 RecyclerView 时,以为这得用两个不同的 Adapter 或者嵌套布局来实现。后来发现,RecyclerView 本身就能优雅地搞定这件事。今天我们就来聊聊,怎么用 ListAdapter 给列表加上头尾。

为什么需要 Header 和 Footer?

你想想看,一个列表往往不只是展示同类型的数据。比如一个商品列表:

  • 顶部可能有个轮播图或者搜索框(Header)
  • 底部可能有个「已经到底了」的提示(Footer)

如果把这些内容硬塞进数据源里,代码会变得很别扭。更好的做法是:在 Adapter 层面区分 Item 类型

核心思路:把 Header 和 Footer 当作特殊的 ViewType,在 Adapter 中分别创建和绑定。

实现方案概览

我个人习惯用 ListAdapter 配合 DiffUtil 来做。这样不仅性能好,而且代码结构清晰。整体流程是这样的:

Header & Footer 实现流程 数据源 List<Item> Adapter 判断 ViewType Header (ViewType=0) 普通 Item (ViewType=1) Footer (ViewType=2) RecyclerView 根据 ViewType 渲染不同布局

代码实战:带 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 的核心。注意 getItemViewTypeonCreateViewHolder 的配合:

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 而已


公众号:蓝海资料掘金营,微信deep3321