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

Эта кодовая лаборатория является частью курса Android Kotlin Fundamentals. Вы получите максимальную отдачу от этого курса, если будете последовательно работать с лабораториями кода. Все кодовые лаборатории курса перечислены на целевой странице кодовых лабораторий Android Kotlin Fundamentals .

Введение

В этой лаборатории кода вы узнаете, как добавить заголовок, который охватывает ширину списка, отображаемого в 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 , он должен иметь возможность использовать любой тип держателя представления.

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

  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 . Подавите предупреждение о ворсе, как показано в приведенном ниже коде.
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() , чтобы ширина заголовка составляла только три интервала.

Удасити курс:

Документация для разработчиков Android:

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

  • При необходимости задайте домашнее задание.
  • Объясните учащимся, как сдавать домашние задания.
  • Оценивайте домашние задания.

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

Если вы работаете с этой кодовой лабораторией самостоятельно, не стесняйтесь использовать эти домашние задания, чтобы проверить свои знания.

Ответьте на эти вопросы

Вопрос 1

Какое из следующих утверждений о ViewHolder ?

▢ Адаптер может использовать несколько классов ViewHolder для хранения заголовков и различных типов данных.

▢ У вас может быть только один держатель представления для данных и один держатель представления для заголовка.

RecyclerView поддерживает несколько типов заголовков, но данные должны быть унифицированы.

▢ При добавлении заголовка вы создаете подкласс RecyclerView , чтобы вставить заголовок в правильную позицию.

вопрос 2

Когда следует использовать сопрограммы с RecyclerView ? Выберите все утверждения, которые верны.

▢ Никогда. RecyclerView является элементом пользовательского интерфейса и не должен использовать сопрограммы.

▢ Используйте сопрограммы для длительных задач, которые могут замедлить работу пользовательского интерфейса.

▢ Манипуляции со списками могут занять много времени, и вы всегда должны выполнять их с помощью сопрограмм.

▢ Используйте сопрограммы с функциями приостановки, чтобы избежать блокировки основного потока.

Вопрос 3

Что из следующего вам НЕ нужно делать при использовании более чем одного ViewHolder ?

▢ В ViewHolder предоставьте несколько файлов макета, которые можно будет увеличивать по мере необходимости.

▢ В onCreateViewHolder() вернуть правильный тип держателя представления для элемента данных.

▢ В onBindViewHolder() данные только в том случае, если держатель представления является правильным типом держателя представления для элемента данных.

▢ Обобщите сигнатуру класса адаптера, чтобы она принимала любой RecyclerView.ViewHolder .

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

Ссылки на другие лаборатории кода в этом курсе см. на целевой странице лаборатории кода Android Kotlin Fundamentals .