Основы Android Kotlin 07.5: Заголовки в RecyclerView

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

  1. Скачайте код RecyclerViewHeaders-Starter с GitHub. Каталог RecyclerViewHeaders-Starter содержит стартовую версию приложения SleepTracker, необходимую для этой практической работы. Вы также можете продолжить работу над готовым приложением из предыдущей практической работы, если хотите.
  2. Откройте SleepNightAdapter.kt .
  3. Ниже класса SleepNightListener , на верхнем уровне, определите sealed класс с именем DataItem , который представляет элемент данных.

    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 на уровне класса определите abstract свойство Long с именем id . Когда адаптер использует DiffUtil для определения того, изменился ли элемент и как именно, DiffItemCallback должен знать идентификатор каждого элемента. Вы увидите ошибку, поскольку SleepNightItem и Header должны переопределить абстрактное свойство 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 . Поскольку вы уже делали это ранее, вот код, и вам нужно импортировать 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 .

Определить типы предметов

  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. Добавьте условие, чтобы назначать данные держателю представления только в том случае, если держатель является 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 . Вместо использования submitList() , предоставляемого ListAdapter , для отправки списка вы будете использовать эту функцию для добавления заголовка и последующей отправки списка.
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 определите CoroutineScope с Dispatchers.Default .
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. Ваш код должен скомпилироваться и запуститься, и вы не увидите никакой разницы.

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

Чтобы изменить ширину заголовка, необходимо указать GridLayoutManager , когда данные следует распределить по всем столбцам. Это можно сделать, настроив SpanSizeLookup в GridLayoutManager . Этот объект конфигурации GridLayoutManager использует для определения количества интервалов для каждого элемента списка.

  1. Откройте SleepTrackerFragment.kt .
  2. Найдите код, в котором вы определяете manager , ближе к концу onCreateView() .
val manager = GridLayoutManager(activity, 3)
  1. Ниже manager определите manager.spanSizeLookup , как показано. Вам необходимо создать object , поскольку setSpanSizeLookup не принимает лямбда-выражение. Чтобы создать object в Kotlin, введите object : classname , в данном случае GridLayoutManager.SpanSizeLookup .
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
}
  1. При вызове конструктора может возникнуть ошибка компиляции. В этом случае откройте меню намерений, нажав Option+Enter (Mac) или Alt+Enter (Windows), чтобы применить вызов конструктора.
  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 , проверив индексы, в которых должен отображаться заголовок. 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 .

Начните следующий урок: 8.1 Получение данных из Интернета

Ссылки на другие практические занятия по этому курсу см. на целевой странице практических занятий по основам Android Kotlin .