Эта практическая работа входит в курс «Основы Android Kotlin». Вы получите максимальную пользу от этого курса, если будете выполнять практические работы последовательно. Все практические работы курса перечислены на целевой странице практической работы «Основы Android Kotlin» .
Введение
В этой практической работе вы научитесь добавлять заголовок, охватывающий всю ширину списка, отображаемого в RecyclerView . Вы будете развивать приложение для отслеживания сна из предыдущих практической работы.
Что вам уже следует знать
- Как создать базовый пользовательский интерфейс с использованием активности, фрагментов и представлений.
- Как перемещаться между фрагментами и как использовать
safeArgsдля передачи данных между фрагментами. - Просмотр моделей, просмотр фабрик моделей, преобразований, а также
LiveDataи их наблюдателей. - Как создать базу данных
Room, создать DAO и определить сущности. - Как использовать сопрограммы для взаимодействия с базой данных и других длительных задач.
- Как реализовать базовый
RecyclerViewсAdapter,ViewHolderи макетом элемента. - Как реализовать привязку данных для
RecyclerView. - Как создавать и использовать адаптеры привязки для преобразования данных.
- Как использовать
GridLayoutManager. - Как захватывать и обрабатывать нажатия на элементы в
RecyclerView.
Чему вы научитесь
- Как использовать несколько
ViewHolderсRecyclerViewдля добавления элементов с разным макетом. В частности, как использовать второйViewHolderдля добавления заголовка над элементами, отображаемыми вRecyclerView.
Что ты будешь делать?
- Используйте приложение TrackMySleepQuality из предыдущей лабораторной работы этой серии.
- Добавьте заголовок, охватывающий всю ширину экрана над ночами сна, отображаемыми в
RecyclerView.
Приложение для отслеживания сна, с которого вы начнете работу, имеет три экрана, представленных фрагментами, как показано на рисунке ниже.
|
|
|
На первом экране, показанном слева, расположены кнопки для запуска и остановки отслеживания. На экране отображаются некоторые данные о сне пользователя. Кнопка «Очистить» безвозвратно удаляет все данные, собранные приложением. Второй экран, показанный посередине, предназначен для выбора оценки качества сна. Третий экран — это подробное представление, которое открывается при нажатии пользователем на элемент сетки.
Это приложение использует упрощенную архитектуру с контроллером пользовательского интерфейса, моделью представления и LiveData , а также базу данных Room для сохранения данных о сне.

В этой лабораторной работе вы добавите заголовок к сетке отображаемых элементов. Ваш финальный главный экран будет выглядеть так:

В этой практической работе рассматривается общий принцип включения элементов с различными макетами в RecyclerView . Одним из распространённых примеров является наличие заголовков в списке или таблице. Список может иметь один заголовок для описания содержимого элемента. Список также может иметь несколько заголовков для группировки и разделения элементов в одном списке.
RecyclerView ничего не знает о ваших данных и о типе макета каждого элемента. LayoutManager упорядочивает элементы на экране, но адаптер адаптирует отображаемые данные и передаёт держатели представлений в RecyclerView . Поэтому вам нужно добавить код для создания заголовков в адаптер.
Два способа добавления заголовков
В RecyclerView каждому элементу списка соответствует порядковый номер, начинающийся с 0. Например:
[Фактические данные] -> [Представления адаптера]
[0: Ночь сна] -> [0: Ночь сна]
[1: Ночной сон] -> [1: Ночной сон]
[2: Ночной сон] -> [2: Ночной сон]
Один из способов добавить заголовки в список — изменить адаптер так, чтобы он использовал другой ViewHolder , проверив индексы, в которых должен отображаться заголовок. Adapter будет отвечать за отслеживание заголовка. Например, чтобы отобразить заголовок в верхней части таблицы, необходимо вернуть другой ViewHolder для него при размещении элемента с нулевым индексом. Тогда все остальные элементы будут сопоставлены со смещением заголовка, как показано ниже.
[Фактические данные] -> [Представления адаптера]
[0: Заголовок]
[0: Ночной сон] -> [1: Ночной сон]
[1: Ночной сон] -> [2: Ночной сон]
[2: Ночь сна] -> [3: Ночь сна.
Другой способ добавить заголовки — изменить базовый набор данных для вашей таблицы данных. Поскольку все данные, которые необходимо отобразить, хранятся в списке, вы можете изменить список, включив в него элементы, представляющие заголовок. Это немного проще для понимания, но требует продумывания дизайна объектов, чтобы можно было объединить элементы разных типов в один список. При такой реализации адаптер будет отображать переданные ему элементы. Таким образом, элемент в позиции 0 — это заголовок, а элемент в позиции 1 — SleepNight , который напрямую соответствует тому, что находится на экране.
[Фактические данные] -> [Представления адаптера]
[0: Заголовок] -> [0: Заголовок]
[1: Ночной сон] -> [1: Ночной сон]
[2: Ночной сон] -> [2: Ночной сон]
[3: Ночь сна] -> [3: Ночь сна]
У каждой методологии есть свои преимущества и недостатки. Изменение набора данных не вносит существенных изменений в остальной код адаптера, и вы можете добавить логику заголовка, манипулируя списком данных. С другой стороны, использование другого ViewHolder с проверкой индексов на наличие заголовков даёт больше свободы в настройке макета заголовка. Это также позволяет адаптеру управлять адаптацией данных к представлению, не изменяя исходные данные.
В этой лабораторной работе вы обновите RecyclerView , чтобы отобразить заголовок в начале списка. В этом случае ваше приложение будет использовать для заголовка другой ViewHolder , нежели для элементов данных. Приложение проверит индекс списка, чтобы определить, какой ViewHolder использовать.
Шаг 1: Создайте класс DataItem
Чтобы абстрагировать тип элемента и позволить адаптеру работать только с «элементами», можно создать класс-хранилище данных, представляющий либо SleepNight , либо Header . В этом случае ваш набор данных будет представлять собой список элементов-хранилищ данных.
Вы можете либо загрузить стартовое приложение с GitHub, либо продолжить использовать приложение SleepTracker, созданное вами в предыдущей лабораторной работе.
- Скачайте код RecyclerViewHeaders-Starter с GitHub. Каталог RecyclerViewHeaders-Starter содержит стартовую версию приложения SleepTracker, необходимую для этой практической работы. Вы также можете продолжить работу над готовым приложением из предыдущей практической работы, если хотите.
- Откройте SleepNightAdapter.kt .
- Ниже класса
SleepNightListener, на верхнем уровне, определитеsealedкласс с именемDataItem, который представляет элемент данных.
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на уровне класса определитеabstractсвойствоLongс именемid. Когда адаптер используетDiffUtilдля определения того, изменился ли элемент и как именно,DiffItemCallbackдолжен знать идентификатор каждого элемента. Вы увидите ошибку, поскольку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)- Добавьте условие, чтобы назначать данные держателю представления только в том случае, если держатель является
ViewHolder.
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. Вместо использованияsubmitList(), предоставляемогоListAdapter, для отправки списка вы будете использовать эту функцию для добавления заголовка и последующей отправки списка.
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определитеCoroutineScopeсDispatchers.Default.
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)
}
}
}- Ваш код должен скомпилироваться и запуститься, и вы не увидите никакой разницы.
В настоящее время ширина заголовка совпадает с шириной остальных элементов сетки, занимая по одному интервалу по горизонтали и вертикали. Вся сетка вмещает три элемента шириной по одному интервалу по горизонтали, поэтому заголовок должен занимать по горизонтали три интервала.
Чтобы изменить ширину заголовка, необходимо указать GridLayoutManager , когда данные следует распределить по всем столбцам. Это можно сделать, настроив SpanSizeLookup в GridLayoutManager . Этот объект конфигурации GridLayoutManager использует для определения количества интервалов для каждого элемента списка.
- Откройте SleepTrackerFragment.kt .
- Найдите код, в котором вы определяете
manager, ближе к концуonCreateView().
val manager = GridLayoutManager(activity, 3)- Ниже
managerопределитеmanager.spanSizeLookup, как показано. Вам необходимо создатьobject, посколькуsetSpanSizeLookupне принимает лямбда-выражение. Чтобы создатьobjectв Kotlin, введитеobject : classname, в данном случаеGridLayoutManager.SpanSizeLookup.
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
}- При вызове конструктора может возникнуть ошибка компиляции. В этом случае откройте меню намерений, нажав
Option+Enter(Mac) илиAlt+Enter(Windows), чтобы применить вызов конструктора.
- Затем вы получите ошибку на
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, проверив индексы, в которых должен отображаться заголовок.Adapterотвечает за отслеживание заголовка. - Другой способ добавления заголовков — изменение базового набора данных (списка) для вашей сетки данных, что вы и сделали в этой лабораторной работе.
Вот основные шаги по добавлению заголовка:
- Абстрагируйте данные в вашем списке, создав
DataItem, который может содержать заголовок или данные. - Создайте держатель представления с макетом для заголовка в адаптере.
- Обновите адаптер и его методы для использования любого типа
RecyclerView.ViewHolder. - В
onCreateViewHolder()верните правильный тип держателя представления для элемента данных. - Обновите
SleepNightDiffCallbackдля работы с классомDataItem. - Создайте функцию
addHeaderAndSubmitList(), которая использует сопрограммы для добавления заголовка в набор данных, а затем вызываетsubmitList(). - Реализуйте
GridLayoutManager.SpanSizeLookup()чтобы сделать ширину только заголовка равной трем диапазонам.
Курс Udacity:
Документация для разработчиков Android:
В этом разделе перечислены возможные домашние задания для студентов, работающих над этой лабораторной работой в рамках курса, проводимого преподавателем. Преподаватель должен выполнить следующие действия:
- При необходимости задавайте домашнее задание.
- Объясните учащимся, как следует сдавать домашние задания.
- Оцените домашние задания.
Преподаватели могут использовать эти предложения так часто или редко, как пожелают, и могут свободно задавать любые другие домашние задания, которые они сочтут подходящими.
Если вы работаете с этой лабораторной работой самостоятельно, можете использовать эти домашние задания для проверки своих знаний.
Ответьте на эти вопросы
Вопрос 1
Какое из следующих утверждений верно в отношении ViewHolder ?
▢ Адаптер может использовать несколько классов ViewHolder для хранения заголовков и различных типов данных.
▢ У вас может быть только один держатель представления для данных и один держатель представления для заголовка.
▢ RecyclerView поддерживает несколько типов заголовков, но данные должны быть единообразными.
▢ При добавлении заголовка вы создаете подкласс RecyclerView , чтобы вставить заголовок в правильное положение.
Вопрос 2
Когда следует использовать сопрограммы с RecyclerView ? Выберите все верные утверждения.
▢ Никогда. RecyclerView — это элемент пользовательского интерфейса, и он не должен использовать сопрограммы.
▢ Используйте сопрограммы для длительных задач, которые могут замедлить работу пользовательского интерфейса.
▢ Операции со списками могут занять много времени, и их всегда следует выполнять с помощью сопрограмм.
▢ Используйте сопрограммы с функциями приостановки, чтобы избежать блокировки основного потока.
Вопрос 3
Что из перечисленного вам НЕ нужно делать при использовании более одного ViewHolder ?
▢ В ViewHolder предоставьте несколько файлов макетов для наполнения по мере необходимости.
▢ В onCreateViewHolder() верните правильный тип держателя представления для элемента данных.
▢ В onBindViewHolder() привязывайте данные только в том случае, если держатель представления является правильным типом держателя представления для элемента данных.
▢ Обобщите сигнатуру класса адаптера, чтобы принять любой RecyclerView.ViewHolder .
Начните следующий урок:
Ссылки на другие практические занятия по этому курсу см. на целевой странице практических занятий по основам Android Kotlin .


