此 Codelab 是“Android Kotlin 基础知识”课程的一部分。如果您按顺序学习这些 Codelab,您将会充分发掘此课程的价值。“Android Kotlin 基础知识”Codelab 着陆页列出了所有课程 Codelab。
简介
在此 Codelab 中,您将学习如何添加一个横跨 RecyclerView 中显示的列表宽度的标题。您将在之前的 Codelab 中的睡眠跟踪器应用的基础上进行构建。
您应当已掌握的内容
- 如何使用 activity、fragment 和视图构建基本界面。
- 如何在 fragment 之间导航,以及如何使用
safeArgs在 fragment 之间传递数据。 - 视图模型、视图模型工厂、转换以及
LiveData及其观察器。 - 如何创建
Room数据库、创建 DAO 以及定义实体。 - 如何将协程用于数据库互动和其他长时间运行的任务。
- 如何使用
Adapter、ViewHolder和项布局实现基本RecyclerView。 - 如何为
RecyclerView实现数据绑定。 - 如何创建和使用绑定适配器来转换数据。
- 如何使用
GridLayoutManager. - 如何捕获和处理对
RecyclerView.中项的点击
学习内容
- 如何将多个
ViewHolder与RecyclerView搭配使用,以添加布局不同的项。具体而言,如何使用第二个ViewHolder在RecyclerView中显示的项上方添加标题。
您将执行的操作
- 在本系列上一个 Codelab 的 TrackMySleepQuality 应用的基础上进行进一步构建。
- 在
RecyclerView中显示的睡眠夜数上方添加一个横跨屏幕宽度的标题。
您开始使用的睡眠跟踪器应用有三个屏幕,以 fragment 表示,如下图所示。
|
|
|
左侧所示的第一个屏幕包含用于开始和停止跟踪的按钮。这个屏幕会显示用户的一些睡眠数据。CLEAR 按钮用于永久删除应用针对用户收集的所有数据。中间所示的第二个界面用于选择睡眠质量评分。第三个屏幕是详细视图,当用户点按网格中的某个项时,系统会打开该视图。
此应用采用简化的架构,其中包括一个界面控制器、视图模型和 LiveData,以及一个用于保留睡眠数据的 Room 数据库。

在此 Codelab 中,您将向显示的商品网格添加标题。最终的主屏幕将如下所示:

此 Codelab 介绍了在 RecyclerView 中包含使用不同布局的项的一般原则。一个常见的示例是在列表或网格中添加标题。一个列表可以包含一个用于描述商品内容的标题。一个列表还可以包含多个标题,用于对单个列表中的项进行分组和分隔。
RecyclerView 不了解您的数据或每个商品的布局类型。LayoutManager 会在屏幕上排列项,但适配器会调整要显示的数据并将视图持有者传递给 RecyclerView。因此,您将在适配器中添加用于创建标题的代码。
添加标题的两种方式
在 RecyclerView 中,列表中的每个项目都对应一个从 0 开始的索引号。例如:
[实际数据] -> [适配器视图]
[0: SleepNight] -> [0: SleepNight]
[1: SleepNight] -> [1: SleepNight]
[2: SleepNight] -> [2: SleepNight]
向列表添加标题的一种方法是修改适配器,通过检查需要显示标题的索引来使用不同的 ViewHolder。Adapter 将负责跟踪标头。例如,如需在表格顶部显示标题,您需要在布局从零开始索引的项时,为标题返回不同的 ViewHolder。然后,所有其他项都将通过标头偏移量进行映射,如下所示。
[实际数据] -> [适配器视图]
[0: Header]
[0: SleepNight] -> [1: SleepNight]
[1: SleepNight] -> [2: SleepNight]
[2: SleepNight] -> [3: SleepNight.
添加标题的另一种方法是修改数据网格的后备数据集。由于需要显示的所有数据都存储在一个列表中,因此您可以修改该列表以包含表示标题的项。这种方法更容易理解,但需要您考虑如何设计对象,以便将不同的商品类型合并到一个列表中。以这种方式实现后,适配器将显示传递给它的项。因此,位置 0 处的项是标题,位置 1 处的项是 SleepNight,它直接映射到屏幕上的内容。
[实际数据] -> [适配器视图]
[0: Header] -> [0: Header]
[1: SleepNight] -> [1: SleepNight]
[2: SleepNight] -> [2: SleepNight]
[3: SleepNight] -> [3: SleepNight]
每种方法都有其优点和缺点。更改数据集不会对适配器的其余代码造成太大影响,您可以通过操纵数据列表来添加标题逻辑。另一方面,通过检查标题的索引来使用不同的 ViewHolder 可以更自由地设置标题的布局。它还允许适配器处理如何将数据调整为视图,而无需修改后备数据。
在此 Codelab 中,您将更新 RecyclerView 以在列表开头显示标题。在这种情况下,应用将使用不同的 ViewHolder 来处理标题和数据项。应用将检查列表的索引,以确定要使用哪个 ViewHolder。
第 1 步:创建 DataItem 类
为了抽象出商品类型并让适配器仅处理“商品”,您可以创建一个数据容器类来表示 SleepNight 或 Header。然后,您的数据集将成为数据持有者项的列表。
您可以从 GitHub 获取起始应用,也可以继续使用在上一个 Codelab 中构建的睡眠跟踪器应用。
- 从 GitHub 下载 RecyclerViewHeaders-Starter 代码。RecyclerViewHeaders-Starter 目录包含此 Codelab 所需的睡眠跟踪器应用的起始版本。您也可以继续使用上一个 Codelab 中完成的应用。
- 打开 SleepNightAdapter.kt。
- 在
SleepNightListener类下方的顶层,定义一个名为DataItem的sealed类,用于表示数据项。sealed类定义了一个封闭类型,这意味着DataItem的所有子类都必须在此文件中定义。这样一来,编译器便可知道子类的数量。您的代码的其他部分无法定义可能会破坏适配器的新DataItem类型。
sealed class DataItem {
}- 在
DataItem类的正文内,定义两个表示不同类型数据项的类。第一个是SleepNightItem,它是SleepNight的封装容器,因此它采用一个名为sleepNight的值。如需将其作为密封类的一部分,请让其扩展DataItem。
data class SleepNightItem(val sleepNight: SleepNight): DataItem()- 第二个类是
Header,用于表示标题。由于标头没有实际数据,因此您可以将其声明为object。这意味着,Header始终只有一个实例。同样,让它扩展DataItem。
object Header: DataItem()- 在
DataItem内,在类级别定义一个名为id的abstractLong属性。当适配器使用DiffUtil来确定某项内容是否以及如何发生变化时,DiffItemCallback需要知道每项内容的 ID。您会看到一个错误,因为SleepNightItem和Header需要替换抽象属性id。
abstract val id: Long- 在
SleepNightItem中,替换id以返回nightId。
override val id = sleepNight.nightId- 在
Header中,替换id以返回Long.MIN_VALUE,这是一个非常非常小的数字(实际上是 -2 的 63 次方)。因此,此值永远不会与任何现有的nightId发生冲突。
override val id = Long.MIN_VALUE- 完成后的代码应如下所示,并且您的应用应该能顺利构建,不会出现任何错误。
sealed class DataItem {
abstract val id: Long
data class SleepNightItem(val sleepNight: SleepNight): DataItem() {
override val id = sleepNight.nightId
}
object Header: DataItem() {
override val id = Long.MIN_VALUE
}
}第 2 步:为标题创建 ViewHolder
- 在名为 header.xml 的新布局资源文件中创建标题的布局,该布局会显示
TextView。这方面没有什么值得兴奋的,因此我们直接提供代码。
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceLarge"
android:text="Sleep Results"
android:padding="8dp" />- 将
"Sleep Results"提取到字符串资源中,并将其命名为header_text。
<string name="header_text">Sleep Results</string>- 在 SleepNightAdapter.kt 中,在
SleepNightAdapter内的ViewHolder类上方,创建一个新的TextViewHolder类。此类会扩充 textview.xml 布局,并返回一个TextViewHolder实例。由于您之前已经执行过此操作,因此代码如下,您需要导入View和R:
class TextViewHolder(view: View): RecyclerView.ViewHolder(view) {
companion object {
fun from(parent: ViewGroup): TextViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val view = layoutInflater.inflate(R.layout.header, parent, false)
return TextViewHolder(view)
}
}
}第 3 步:更新 SleepNightAdapter
接下来,您需要更新 SleepNightAdapter 的声明。它不仅需要支持一种类型的 ViewHolder,还需要能够使用任何类型的 ViewHolder。
定义商品类型
- 在
SleepNightAdapter.kt的顶层,在import语句下方和SleepNightAdapter上方,定义两个视图类型常量。RecyclerView需要区分每个商品的视图类型,以便正确为其分配视图持有者。
private val ITEM_VIEW_TYPE_HEADER = 0
private val ITEM_VIEW_TYPE_ITEM = 1- 在
SleepNightAdapter内,创建一个函数来替换getItemViewType(),以根据当前商品的类型返回正确的标头或商品常量。
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is DataItem.Header -> ITEM_VIEW_TYPE_HEADER
is DataItem.SleepNightItem -> ITEM_VIEW_TYPE_ITEM
}
}更新 SleepNightAdapter 定义
- 在
SleepNightAdapter的定义中,将ListAdapter的第一个实参从SleepNight更新为DataItem。 - 在
SleepNightAdapter的定义中,将ListAdapter的第二个泛型实参从SleepNightAdapter.ViewHolder更改为RecyclerView.ViewHolder。您会看到一些有关必要更新的错误,并且您的类标头应如下所示。
class SleepNightAdapter(val clickListener: SleepNightListener):
ListAdapter<DataItem, RecyclerView.ViewHolder>(SleepNightDiffCallback()) {更新 onCreateViewHolder()
- 更改
onCreateViewHolder()的签名以返回RecyclerView.ViewHolder。
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder- 展开
onCreateViewHolder()方法的实现,以针对每种商品类型测试并返回相应的视图持有者。更新后的方法应如以下代码所示。
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
ITEM_VIEW_TYPE_HEADER -> TextViewHolder.from(parent)
ITEM_VIEW_TYPE_ITEM -> ViewHolder.from(parent)
else -> throw ClassCastException("Unknown viewType ${viewType}")
}
}更新 onBindViewHolder()
- 将
onBindViewHolder()的参数类型从ViewHolder更改为RecyclerView.ViewHolder。
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)- 添加一个条件,仅当 holder 为
ViewHolder时才将数据分配给视图 holder。
when (holder) {
is ViewHolder -> {...}- 将
getItem()返回的对象类型强制转换为DataItem.SleepNightItem。完成后的onBindViewHolder()函数应如下所示。
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is ViewHolder -> {
val nightItem = getItem(position) as DataItem.SleepNightItem
holder.bind(nightItem.sleepNight, clickListener)
}
}
}更新 diffUtil 回调
- 更改
SleepNightDiffCallback中的方法,以使用新的DataItem类,而不是SleepNight。禁止显示 lint 警告,如下面的代码所示。
class SleepNightDiffCallback : DiffUtil.ItemCallback<DataItem>() {
override fun areItemsTheSame(oldItem: DataItem, newItem: DataItem): Boolean {
return oldItem.id == newItem.id
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: DataItem, newItem: DataItem): Boolean {
return oldItem == newItem
}
}添加并提交标头
- 在
SleepNightAdapter内,在onCreateViewHolder()下方,定义一个函数addHeaderAndSubmitList(),如下所示。此函数接受SleepNight的列表。您将使用此函数添加标题,然后提交列表,而不是使用ListAdapter提供的submitList()来提交列表。
fun addHeaderAndSubmitList(list: List<SleepNight>?) {}- 在
addHeaderAndSubmitList()内,如果传入的列表为null,则仅返回标头;否则,将标头附加到列表的开头,然后提交列表。
val items = when (list) {
null -> listOf(DataItem.Header)
else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
}
submitList(items)- 打开 SleepTrackerFragment.kt,并将对
submitList()的调用更改为addHeaderAndSubmitList()。
- 运行应用,并观察标题如何显示为睡眠项目列表中的第一项。

此应用需要修正两处问题。其中一处问题是可见的,另一处问题是不可见的。
- 标题显示在左上角,不易区分。
- 对于只有一个标题的短列表,这并不重要,但您不应在界面线程上的
addHeaderAndSubmitList()中进行列表操作。假设有一个包含数百个项、多个标题的列表,并且其中包含用于确定需要在何处插入项的逻辑。此工作属于协程。
将 addHeaderAndSubmitList() 更改为使用协程:
- 在
SleepNightAdapter类内的顶层,定义一个包含Dispatchers.Default的CoroutineScope。
private val adapterScope = CoroutineScope(Dispatchers.Default)- 在
addHeaderAndSubmitList()中,在adapterScope中启动一个协程来操作列表。然后切换到Dispatchers.Main上下文以提交列表,如下面的代码所示。
fun addHeaderAndSubmitList(list: List<SleepNight>?) {
adapterScope.launch {
val items = when (list) {
null -> listOf(DataItem.Header)
else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
}
withContext(Dispatchers.Main) {
submitList(items)
}
}
}- 您的代码应能正常构建和运行,并且您不会看到任何差异。
目前,标题的宽度与网格上的其他项相同,在水平和垂直方向上都占据一个 span。整个网格在水平方向上可容纳三个跨度宽度为 1 的项,因此标题应在水平方向上使用三个跨度。
如需修正标题宽度,您需要告知 GridLayoutManager 何时将数据跨越所有列。为此,您可以在 GridLayoutManager 上配置 SpanSizeLookup。这是一个配置对象,GridLayoutManager 使用它来确定列表中每个项要使用的跨度数。
- 打开 SleepTrackerFragment.kt。
- 在
onCreateView()的末尾附近找到定义manager的代码。
val manager = GridLayoutManager(activity, 3)- 在
manager下,定义manager.spanSizeLookup,如下所示。您需要创建object,因为setSpanSizeLookup不接受 lambda。如需在 Kotlin 中创建object,请键入object : classname,在本例中为GridLayoutManager.SpanSizeLookup。
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
}- 您可能会遇到调用构造函数的编译器错误。如果需要,请使用
Option+Enter(Mac) 或Alt+Enter(Windows) 打开 intent 菜单,以应用构造函数调用。
- 然后,您会在
object上收到一条错误消息,提示您需要替换方法。将光标放在object上,按Option+Enter(Mac) 或Alt+Enter(Windows) 打开意图菜单,然后替换方法getSpanSize()。
- 在
getSpanSize()的正文中,为每个位置返回正确的跨度大小。位置 0 的跨度大小为 3,其他位置的跨度大小为 1。完成后的代码应如下所示:
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) = when (position) {
0 -> 3
else -> 1
}
}- 如需改进标题的外观,请打开 header.xml,然后将以下代码添加到布局文件 header.xml。
android:textColor="@color/white_text_color"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:background="@color/colorAccent"- 运行应用。应用界面应如以下屏幕截图所示。

恭喜!恭喜您,已完成!
Android Studio 项目:RecyclerViewHeaders
- 标题通常是指跨越列表宽度的项,用作标题或分隔符。列表可以包含单个标题来描述商品内容,也可以包含多个标题来对商品进行分组并分隔商品。
RecyclerView可以使用多个 ViewHolder 来容纳一组异构项,例如标题和列表项。- 添加标头的一种方法是修改适配器,通过检查需要显示标头的索引来使用不同的
ViewHolder。Adapter负责跟踪标头。 - 添加标题的另一种方法是修改数据网格的支持数据集(列表),这正是您在此 Codelab 中所做的。
以下是添加标题的主要步骤:
- 通过创建可包含标题或数据的
DataItem来抽象化列表中的数据。 - 在适配器中创建一个包含标题布局的视图持有者。
- 更新适配器及其方法,以使用任何类型的
RecyclerView.ViewHolder。 - 在
onCreateViewHolder()中,为数据项返回正确的视图持有者类型。 - 更新
SleepNightDiffCallback以便与DataItem类配合使用。 - 创建一个
addHeaderAndSubmitList()函数,该函数使用协程将标题添加到数据集,然后调用submitList()。 - 实现
GridLayoutManager.SpanSizeLookup()以使标题仅为 3 个跨度宽。
Udacity 课程:
Android 开发者文档:
此部分列出了在由讲师主导的课程中,学生学习此 Codelab 后可能需要完成的家庭作业。讲师自行决定是否执行以下操作:
- 根据需要布置作业。
- 告知学生如何提交家庭作业。
- 给家庭作业评分。
讲师可以酌情采纳这些建议,并且可以自由布置自己认为合适的任何其他家庭作业。
如果您是在自学此 Codelab,可随时通过这些家庭作业来检测您的知识掌握情况。
回答以下问题
问题 1
以下关于 ViewHolder 的表述中,哪一项是正确的?
▢ 适配器可以使用多个 ViewHolder 类来存储标头和不同类型的数据。
▢ 您可以为数据使用一个 ViewHolder,并为标头使用一个 ViewHolder。
▢ RecyclerView 支持多种类型的标头,但数据类型必须相同。
▢ 添加标头时,您应将 RecyclerView 变为子类,以在正确的位置插入标头。
问题 2
何时应将协程与 RecyclerView 搭配使用?请选择所有正确的表述。
▢ 从不。RecyclerView 是界面元素,不应使用协程。
▢ 使用协程处理可能会导致界面变慢的长时间运行的任务。
▢ 列表操作可能需要很长时间,您应始终使用协程来执行这些操作。
▢ 使用协程和挂起函数来避免阻塞主线程。
问题 3
使用多个 ViewHolder 时,您不必执行以下哪项操作?
▢ 在 ViewHolder 中,根据需要提供多个要扩充的布局文件。
▢ 在 onCreateViewHolder() 中,为数据项返回正确的视图持有者类型。
▢ 在 onBindViewHolder() 中,仅当视图持有者是数据项的正确视图持有者类型时才绑定数据。
▢ 将适配器类签名泛化为接受任何 RecyclerView.ViewHolder。
开始学习下一课:
如需本课程中其他 Codelab 的链接,请参阅“Android Kotlin 基础知识”Codelab 着陆页。


