Android Kotlin 基础知识 07.5:RecyclerView 中的头文件

此 Codelab 是“Android Kotlin 基础知识”课程的一部分。如果您按顺序学习这些 Codelab,您将会充分发掘此课程的价值。“Android Kotlin 基础知识”Codelab 着陆页列出了所有课程 Codelab。

简介

在此 Codelab 中,您将学习如何添加跨越 RecyclerView 中显示的列表宽度的标头。您在之前的 Codelab 中构建了睡眠跟踪器应用。

您应当已掌握的内容

  • 如何使用 activity、fragment 和视图构建基本界面。
  • 如何在 fragment 之间导航,以及如何使用 safeArgs 在 fragment 之间传递数据。
  • 查看模型、查看模型工厂、转换以及 LiveData 及其观察者。
  • 如何创建 Room 数据库、创建 DAO 以及定义实体。
  • 如何使用协程来执行数据库交互及其他长时间运行的任务。
  • 如何使用 AdapterViewHolder 和项布局实现基本 RecyclerView
  • 如何为 RecyclerView 实现数据绑定。
  • 如何创建和使用绑定适配器来转换数据。
  • 如何使用 GridLayoutManager.
  • 如何捕获和处理 RecyclerView. 中的项点击

学习内容

  • 如何通过 RecyclerView 使用多个 ViewHolder 来添加具有不同布局的项目。具体而言,如何使用第二个 ViewHolderRecyclerView 中显示的项上方添加标头。

您将执行的操作

  • 基于本系列上一个 Codelab 中的 TrackMySleepQuality 应用进行构建。
  • 添加一个跨越屏幕宽度(在 RecyclerView 中显示的睡眠之夜)的标头。

您首先使用的睡眠跟踪器应用有三个屏幕(由 fragment 表示),如下图所示。

左侧所示的第一个屏幕包含用于开始和停止跟踪的按钮。这个屏幕会显示用户的一些睡眠数据。点击清除按钮会永久删除应用为用户收集的所有数据。第二个屏幕(中间显示)用于选择睡眠质量评分。第三个屏幕是一个详情视图,会在用户点按网格中的内容后打开。

此应用使用简化的架构,其中包含界面控制器、视图模型和 LiveData,以及 Room 数据库以持久存储睡眠数据。

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

此 Codelab 介绍了在 RecyclerView 中添加使用不同布局的项的一般原则。例如,列表或网格中包含标题。列表可以包含一个用于描述商品内容的标头。一个列表也可以使用多个标头,以便将项目分组并分隔到一个列表中。

RecyclerView对您数据或每一项的布局类型一无所知。LayoutManager 排列屏幕上的项目,但 Adapter 调整要显示的数据并将 ViewHolder 传递给 RecyclerView。因此,您将添加代码以在适配器中创建标头。

添加标头的两种方法

RecyclerView 中,列表中的每一项都对应一个从 0 开始的索引编号。例如:

[实际数据] ->[适配器视图]

[0: SleepNight] -> [0: SleepNight]

[1: SleepNight] -> [1: SleepNight]

[2: SleepNight] -> [2: SleepNight]

将标头添加到列表中的一种方法是,通过检查需要显示标头的索引,将适配器修改为使用其他 ViewHolderAdapter 将负责跟踪标头。例如,要在表格顶部显示标题,您需要在布置零索引项的同时为标题返回不同的 ViewHolder。然后,所有其他项都会使用标头偏移进行映射,如下所示。

[实际数据] ->[适配器视图]

[0: 标题]

[0: SleepNight] -> [1: SleepNight]

[1: SleepNight] -> [2: SleepNight]

[2: SleepNight] -> [3: SleepNight。

添加标头的另一种方法是修改数据网格的后备数据集。因为需要显示的所有数据都存储在列表中,所以您可以修改列表以包含代表标题的项目。理解起来相对容易,但您需要考虑如何设计对象,以便将不同的项目类型组合成一个列表。以这种方式实现时,适配器会显示传递给它的项目。因此,位于位置 0 的项是标题,位置 1 的项是 SleepNight,它会直接映射到屏幕上的内容。

[实际数据] ->[适配器视图]

[0: 标头] -> [0: 标头]

[1: SleepNight] -> [1: SleepNight]

[2: SleepNight] -> [2: SleepNight]

[3: SleepNight] -> [3: SleepNight]

每种方法各有利弊。更改数据集并不会对其余适配器代码做出过多更改,并且您可以通过操控数据列表来添加标头逻辑。另一方面,通过检查标头索引使用不同的 ViewHolder 可更灵活地设置标头的布局。它还允许适配器处理数据如何适应视图,而无需修改后备数据。

在此 Codelab 中,您将更新 RecyclerView,以在列表开头显示标头。在这种情况下,应用将对标题使用与数据项不同的 ViewHolder。该应用将检查列表的索引,以确定要使用哪个 ViewHolder

第 1 步:创建 DataItem 类

为了抽象化菜单项的类型,并使适配器只处理“items”,您可以创建表示 SleepNightHeader 的数据容器类。然后,您的数据集即为一个包含数据容器项的列表。

您可以从 GitHub 获取起始应用,也可以继续使用在上一个 Codelab 中构建的 SleepTracker 应用。

  1. 从 GitHub 下载 RecyclerViewHeaders-Starter 代码。RecyclerViewHeaders-Starter 目录包含此 Codelab 所需的入门版 SleepTracker 应用。如果您愿意,也可以继续使用上一个 Codelab 中完成后的应用。
  2. 打开 SleepNightAdapter.kt
  3. SleepNightListener 类的顶层,定义一个表示数据项的名为 DataItemsealed 类。

    sealed 类定义了封闭类型,这意味着 DataItem 的所有子类都必须在该文件中定义。因此,编译器会得知子类的数量。代码的其他部分无法定义可能会破坏适配器的新 DataItem 类型。
sealed class DataItem {

 }
  1. DataItem 类的正文内,定义两个表示不同类型的数据项的类。第一个元素是 SleepNightItem,它是 SleepNight 的封装容器,因此它接受一个名为 sleepNight 的值。要使其成为密封类的一部分,请将其扩展 DataItem
data class SleepNightItem(val sleepNight: SleepNight): DataItem()
  1. 第二个类是 Header,用于表示标头。由于标头没有实际数据,因此您可以将其声明为 object。这意味着,Header 将始终只有一个实例。同样,让它扩展 DataItem
object Header: DataItem()
  1. DataItem 内的类级别上,定义一个名为 idabstract Long 属性。当适配器使用 DiffUtil 来确定列表项是否已更改时以及更改方式时,DiffItemCallback 需要知道每个列表项的 ID。您将看到一条错误,因为 SleepNightItemHeader 需要替换抽象属性 id
abstract val id: Long
  1. SleepNightItem 中,替换 id 以返回 nightId
override val id = sleepNight.nightId
  1. Header 中,替换 id 以返回 Long.MIN_VALUE,这是一个非常小的数字(实际上是 -2 的 63 的次方)。因此,这绝不会与已存在的任何 nightId 发生冲突。
override val id = Long.MIN_VALUE
  1. 完成后的代码应如下所示,您的应用应该不会出现错误。
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

  1. 名为 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" />
  1. "Sleep Results" 提取到字符串资源中,并将其命名为 header_text
<string name="header_text">Sleep Results</string>
  1. SleepNightAdapter.kt 中的 SleepNightAdapter 内,在 ViewHolder 类上方,创建一个新的 TextViewHolder 类。此类会膨胀 textview.xml 布局,并返回 TextViewHolder 实例。由于您之前已经执行此操作,因此代码如下,而且您必须导入 ViewR
    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

定义项的类型

  1. SleepNightAdapter.kt 中的顶级 import 语句下方、SleepNightAdapter 上方,为视图类型定义两个常量。

    RecyclerView 需要区分每个项的视图类型,以便其为视图正确分配 ViewHolder。
    private val ITEM_VIEW_TYPE_HEADER = 0
    private val ITEM_VIEW_TYPE_ITEM = 1
  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 定义

  1. SleepNightAdapter 的定义中,将 ListAdapter 的第一个参数从 SleepNight 更新为 DataItem
  2. SleepNightAdapter 的定义中,将 ListAdapter 的第二个通用参数从 SleepNightAdapter.ViewHolder 更改为 RecyclerView.ViewHolder。您会看到进行必要更新时遇到的一些错误,而且类标题应如下所示。
class SleepNightAdapter(val clickListener: SleepNightListener):
       ListAdapter<DataItem, RecyclerView.ViewHolder>(SleepNightDiffCallback()) {

更新了 onCreateViewHolder()

  1. 更改 onCreateViewHolder() 的签名以返回 RecyclerView.ViewHolder
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder
  1. 扩展 onCreateViewHolder() 方法的实现,以测试每个项类型并返回相应的 ViewHolder。更新后的方法应如以下代码所示。
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()

  1. onBindViewHolder() 的参数类型从 ViewHolder 更改为 RecyclerView.ViewHolder
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
  1. 添加一个条件,以便仅在 ViewHolder 为 ViewHolder 时向其分配数据。
        when (holder) {
            is ViewHolder -> {...}
  1. 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 回调

  1. 更改 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
    }
}

添加并提交标头

  1. SleepNightAdapter 中的 onCreateViewHolder() 下方,定义一个函数 addHeaderAndSubmitList(),如下所示。此函数接受 SleepNight 的列表。您将使用此函数添加标头,然后提交列表,而不是使用 ListAdapter 提供的 submitList()
fun addHeaderAndSubmitList(list: List<SleepNight>?) {}
  1. addHeaderAndSubmitList() 中,如果传入的列表是 null,则仅返回标头;否则,请将标头附加到列表的标头,然后提交列表。
val items = when (list) {
                null -> listOf(DataItem.Header)
                else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
            }
submitList(items)
  1. 打开 SleepTrackerFragment.kt 并将对 submitList() 的调用更改为 addHeaderAndSubmitList()
  1. 运行应用,观察标头如何作为睡眠项目列表中的第一项显示。

此应用需要修复两个问题。一个是可见的,另一个是不可见的。

  • 标头显示在左上角,不易区分。
  • 只有一个标头的短列表并不重要,但您不应在界面线程上的 addHeaderAndSubmitList() 中执行列表操作。假设有一个包含数百项、多个标头和逻辑的清单,用来决定需要将项插入到什么位置。此工作属于协程。

addHeaderAndSubmitList() 更改为使用协程:

  1. SleepNightAdapter 类的顶层,使用 Dispatchers.Default 定义 CoroutineScope
private val adapterScope = CoroutineScope(Dispatchers.Default)
  1. 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)
            }
        }
    }
  1. 您的代码应当会构建和运行,不会有任何变化。

目前,页眉与网格上的其他项具有相同的宽度,水平和垂直占用一个跨距。整个网格水平放置了三个 span 的宽度,因此页眉应在水平方向使用三个 span。

要修复标题宽度,您需要告知 GridLayoutManager 何时将数据跨越所有列。为此,您可以在 GridLayoutManager 上配置 SpanSizeLookup。这是一个配置对象,GridLayoutManager 用它来确定为列表中的每一项使用多少 Span。

  1. 打开 SleepTrackerFragment.kt
  2. 找到您定义 manager 的代码,靠近 onCreateView() 的结尾。
val manager = GridLayoutManager(activity, 3)
  1. manager 下,定义 manager.spanSizeLookup,如下所示。您需要创建 object,因为 setSpanSizeLookup 不接受 lambda。如需使用 Kotlin 创建 object,请输入 object : classname(在此示例中为 GridLayoutManager.SpanSizeLookup)。
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
}
  1. 您可能会遇到编译器错误,以调用构造函数。否则,使用 Option+Enter (Mac) 或 Alt+Enter (Windows) 打开 intent 菜单,以应用构造函数调用。
  1. 然后,您会在 object 上收到一条错误消息,指出您需要替换方法。将光标放在 object 上,按 Option+Enter (Mac) 或 Alt+Enter (Windows) 打开 intent 菜单,然后替换 getSpanSize() 方法。
  1. getSpanSize() 的正文中,为每个位置返回正确的跨度大小。位置 0 的跨度大小为 3,而其他位置的跨度大小为 1。完成后的代码应如下所示:
    manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
            override fun getSpanSize(position: Int) =  when (position) {
                0 -> 3
                else -> 1
            }
        }
  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"
  1. 运行您的应用。它应如下面的屏幕截图所示。

恭喜!大功告成。

Android Studio 项目:RecyclerViewHeaders

  • 标头通常是跨列表宽度的项目,用作标题或分隔符。列表可以使用单个标题来描述项内容,也可以有多个标题来将项分组并相互分隔。
  • RecyclerView 可以使用多个 ViewHolder 来适应一组不同的项;例如,标题和列表项。
  • 添加标头的一种方法是,通过检查需要显示标头的索引来修改适配器,以使用其他 ViewHolderAdapter 负责跟踪标头。
  • 添加标头的另一种方法是修改数据网格的后备数据集(列表),您在此 Codelab 中执行的操作。

以下是添加标头的主要步骤:

  • 通过创建可以保存标头或数据的 DataItem,抽象化列表中的数据。
  • 为适配器中的标头创建包含布局的 ViewHolder。
  • 更新适配器及其方法以使用任何类型的 RecyclerView.ViewHolder
  • onCreateViewHolder() 中,为数据项返回正确类型的 ViewHolder。
  • 更新 SleepNightDiffCallback 以使用 DataItem 类。
  • 创建一个使用协程向数据集添加标题的 addHeaderAndSubmitList() 函数,然后调用 submitList()
  • 实现 GridLayoutManager.SpanSizeLookup() 以使标题仅跨越三个跨度。

Udacity 课程:

Android 开发者文档:

此部分列出了在由讲师主导的课程中,学生学习此 Codelab 后可能需要完成的家庭作业。讲师自行决定是否执行以下操作:

  • 根据需要布置作业。
  • 告知学生如何提交家庭作业。
  • 给家庭作业评分。

讲师可以酌情采纳这些建议,并且可以自由布置自己认为合适的任何其他家庭作业。

如果您是在自学此 Codelab,可随时通过这些家庭作业来检测您的知识掌握情况。

回答以下问题

问题 1

在以下关于 ViewHolder 的表述中,哪一项是正确的?

▢ 适配器可以使用多个 ViewHolder 类来存储标头和不同类型的数据。

▢ 可以为数据使用一个 ViewHolder,为标头使用一个 ViewHolder。

RecyclerView 支持多种类型的标头,但数据类型必须相同。

▢ 添加标头时,您应为 RecyclerView 创建子类,以将标头插入正确的位置。

问题 2

何时应将协程与 RecyclerView 一起使用?请选择所有正确的表述。

▢ 永不。RecyclerView 是界面元素,不应使用协程。

▢ 对可能会拖慢界面运行时间的长时间运行的任务使用协程。

▢ 操纵列表可能需要很长时间,您应始终使用协程执行此操作。

▢ 将协程与挂起函数一起使用,以避免阻塞主线程。

问题 3

使用多个 ViewHolder 时,不必执行以下哪项操作?

▢ 在 ViewHolder 中,提供多个需要膨胀的布局文件。

▢ 在 onCreateViewHolder() 中,为数据项返回正确类型的 ViewHolder。

▢ 在 onBindViewHolder() 中,只有当 ViewHolder 是数据项的正确 ViewHolder 类型时,才绑定数据。

▢ 将适配器类签名泛化为接受任何 RecyclerView.ViewHolder

开始学习下一课:8.1 从互联网获取数据

如需本课程中其他 Codelab 的链接,请参阅“Android Kotlin 基础知识”Codelab 着陆页