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. 中项的点击

学习内容

  • 如何将多个 ViewHolderRecyclerView 搭配使用,以添加布局不同的项。具体而言,如何使用第二个 ViewHolderRecyclerView 中显示的项上方添加标题。

您将执行的操作

  • 在本系列上一个 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]

向列表添加标题的一种方法是修改适配器,通过检查需要显示标题的索引来使用不同的 ViewHolderAdapter 将负责跟踪标头。例如,如需在表格顶部显示标题,您需要在布局从零开始索引的项时,为标题返回不同的 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 类

为了抽象出商品类型并让适配器仅处理“商品”,您可以创建一个数据容器类来表示 SleepNightHeader。然后,您的数据集将成为数据持有者项的列表。

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

  1. 从 GitHub 下载 RecyclerViewHeaders-Starter 代码。RecyclerViewHeaders-Starter 目录包含此 Codelab 所需的睡眠跟踪器应用的起始版本。您也可以继续使用上一个 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 需要区分每个商品的视图类型,以便正确为其分配视图持有者。
    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() 方法的实现,以针对每种商品类型测试并返回相应的视图持有者。更新后的方法应如以下代码所示。
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. 添加一个条件,仅当 holder 为 ViewHolder 时才将数据分配给视图 holder。
        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.DefaultCoroutineScope
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。整个网格在水平方向上可容纳三个跨度宽度为 1 的项,因此标题应在水平方向上使用三个跨度。

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

  1. 打开 SleepTrackerFragment.kt
  2. onCreateView() 的末尾附近找到定义 manager 的代码。
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) 打开意图菜单,然后替换方法 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 来抽象化列表中的数据。
  • 在适配器中创建一个包含标题布局的视图持有者。
  • 更新适配器及其方法,以使用任何类型的 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

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

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