此 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
的abstract
Long
属性。当适配器使用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 着陆页。