Эта практическая работа входит в курс «Основы 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 .