Aspectos básicos de Kotlin para Android 07.5: Encabezados en RecyclerView

Este codelab es parte del curso Conceptos básicos de Kotlin para Android. Aprovecharás al máximo este curso si trabajas con los codelabs de forma secuencial. Todos los codelabs del curso se enumeran en la página de destino de los codelabs de Android Kotlin Fundamentals.

Introducción

En este codelab, aprenderás a agregar un encabezado que abarque el ancho de la lista que se muestra en un RecyclerView. Compilarás la app de Sleep Tracker de los codelabs anteriores.

Conocimientos que ya deberías tener

  • Cómo compilar una interfaz de usuario básica con una actividad, fragmentos y vistas
  • Cómo navegar entre fragmentos y cómo usar safeArgs para pasar datos entre fragmentos
  • Ver modelos, fábricas de modelos, transformaciones y LiveData, y sus observadores
  • Cómo crear una base de datos Room, crear un DAO y definir entidades
  • Cómo usar corrutinas para interacciones con bases de datos y otras tareas de larga duración
  • Cómo implementar un RecyclerView básico con un Adapter, un ViewHolder y un diseño de elemento
  • Cómo implementar la vinculación de datos para RecyclerView
  • Cómo crear y usar adaptadores de vinculación para transformar datos
  • Cómo usar GridLayoutManager
  • Cómo capturar y controlar los clics en elementos de un RecyclerView.

Qué aprenderás

  • Cómo usar más de un ViewHolder con un RecyclerView para agregar elementos con un diseño diferente Específicamente, cómo usar un segundo ViewHolder para agregar un encabezado sobre los elementos que se muestran en RecyclerView.

Actividades

  • Compilar la app de TrackMySleepQuality del codelab anterior de esta serie
  • Agrega un encabezado que abarque el ancho de la pantalla sobre las noches de sueño que se muestran en RecyclerView.

La app de monitoreo del sueño con la que comenzarás tiene tres pantallas, representadas por fragmentos, como se muestra en la siguiente figura.

La primera pantalla, que se muestra a la izquierda, tiene botones para iniciar y detener el seguimiento. En la pantalla, se muestran algunos de los datos de sueño del usuario. El botón Borrar borra de forma permanente todos los datos que la app recopiló del usuario. La segunda pantalla, que se muestra en el medio, es para seleccionar una calificación de calidad del sueño. La tercera pantalla es una vista de detalles que se abre cuando el usuario presiona un elemento de la cuadrícula.

Esta app usa una arquitectura simplificada con un controlador de IU, un ViewModel y LiveData, y una base de datos Room para conservar los datos de sueño.

En este codelab, agregarás un encabezado a la cuadrícula de elementos que se muestra. La pantalla principal final se verá de la siguiente manera:

En este codelab, se enseña el principio general de incluir elementos que usan diferentes diseños en un RecyclerView. Un ejemplo común es tener encabezados en tu lista o cuadrícula. Una lista puede tener un solo encabezado para describir el contenido del elemento. Una lista también puede tener varios encabezados para agrupar y separar elementos en una sola lista.

RecyclerView no sabe nada sobre tus datos ni qué tipo de diseño tiene cada elemento. LayoutManager organiza los elementos en la pantalla, pero el adaptador adapta los datos que se mostrarán y pasa los contenedores de vistas a RecyclerView. Por lo tanto, agregarás el código para crear encabezados en el adaptador.

Dos formas de agregar encabezados

En RecyclerView, cada elemento de la lista corresponde a un número de índice que comienza en 0. Por ejemplo:

[Actual Data] -> [Adapter Views]

[0: SleepNight] -> [0: SleepNight]

[1: SleepNight] -> [1: SleepNight]

[2: SleepNight] -> [2: SleepNight]

Una forma de agregar encabezados a una lista es modificar el adaptador para que use un ViewHolder diferente. Para ello, verifica los índices en los que se debe mostrar el encabezado. El Adapter será responsable de hacer un seguimiento del encabezado. Por ejemplo, para mostrar un encabezado en la parte superior de la tabla, debes devolver un ViewHolder diferente para el encabezado mientras diseñas el elemento indexado en cero. Luego, todos los demás elementos se correlacionarían con el desplazamiento del encabezado, como se muestra a continuación.

[Actual Data] -> [Adapter Views]

[0: Header]

[0: SleepNight] -> [1: SleepNight]

[1: SleepNight] -> [2: SleepNight]

[2: SleepNight] -> [3: SleepNight.

Otra forma de agregar encabezados es modificar el conjunto de datos de respaldo de tu cuadrícula de datos. Dado que todos los datos que se deben mostrar se almacenan en una lista, puedes modificarla para incluir elementos que representen un encabezado. Esto es un poco más fácil de entender, pero requiere que pienses en cómo diseñar tus objetos para que puedas combinar los diferentes tipos de elementos en una sola lista. De esta manera, el adaptador mostrará los elementos que se le pasen. Por lo tanto, el elemento en la posición 0 es un encabezado, y el elemento en la posición 1 es un SleepNight, que se asigna directamente a lo que se muestra en la pantalla.

[Actual Data] -> [Adapter Views]

[0: Header] -> [0: Header]

[1: SleepNight] -> [1: SleepNight]

[2: SleepNight] -> [2: SleepNight]

[3: SleepNight] -> [3: SleepNight]

Cada metodología tiene sus ventajas y desventajas. Cambiar el conjunto de datos no introduce muchos cambios en el resto del código del adaptador, y puedes agregar lógica de encabezado manipulando la lista de datos. Por otro lado, usar un ViewHolder diferente verificando los índices de los encabezados brinda más libertad en el diseño del encabezado. También permite que el adaptador controle cómo se adaptan los datos a la vista sin modificar los datos de respaldo.

En este codelab, actualizarás tu RecyclerView para mostrar un encabezado al comienzo de la lista. En este caso, tu app usará un ViewHolder diferente para el encabezado que para los elementos de datos. La app verificará el índice de la lista para determinar qué ViewHolder usar.

Paso 1: Crea una clase DataItem

Para abstraer el tipo de elemento y permitir que el adaptador solo se encargue de los "elementos", puedes crear una clase de contenedor de datos que represente un SleepNight o un Header. Tu conjunto de datos será una lista de elementos de titulares de datos.

Puedes obtener la app de partida desde GitHub o seguir usando la app de SleepTracker que compilaste en el codelab anterior.

  1. Descarga el código de RecyclerViewHeaders-Starter desde GitHub. El directorio RecyclerViewHeaders-Starter contiene la versión inicial de la app de SleepTracker que se necesita para este codelab. Si lo prefieres, también puedes continuar con la app que completaste en el codelab anterior.
  2. Abre SleepNightAdapter.kt.
  3. Debajo de la clase SleepNightListener, en el nivel superior, define una clase sealed llamada DataItem que represente un elemento de datos.

    Una clase sealed define un tipo cerrado, lo que significa que todas las subclases de DataItem deben definirse en este archivo. Como resultado, el compilador conoce la cantidad de subclases. Otra parte de tu código no puede definir un nuevo tipo de DataItem que podría dañar tu adaptador.
sealed class DataItem {

 }
  1. Dentro del cuerpo de la clase DataItem, define dos clases que representen los diferentes tipos de elementos de datos. El primero es un SleepNightItem, que es un wrapper alrededor de un SleepNight, por lo que toma un solo valor llamado sleepNight. Para que forme parte de la clase sellada, haz que extienda DataItem.
data class SleepNightItem(val sleepNight: SleepNight): DataItem()
  1. La segunda clase es Header, para representar un encabezado. Dado que un encabezado no tiene datos reales, puedes declararlo como un object. Esto significa que solo habrá una instancia de Header. Nuevamente, haz que extienda DataItem.
object Header: DataItem()
  1. Dentro de DataItem, a nivel de la clase, define una propiedad abstract Long llamada id. Cuando el adaptador usa DiffUtil para determinar si un elemento cambió y cómo lo hizo, el DiffItemCallback necesita conocer el ID de cada elemento. Verás un error porque SleepNightItem y Header deben anular la propiedad abstracta id.
abstract val id: Long
  1. En SleepNightItem, anula id para devolver nightId.
override val id = sleepNight.nightId
  1. En Header, anula id para devolver Long.MIN_VALUE, que es un número muy, muy pequeño (literalmente, -2 elevado a la potencia de 63). Por lo tanto, nunca entrará en conflicto con ningún nightId existente.
override val id = Long.MIN_VALUE
  1. El código finalizado debería verse así, y la app debería compilarse sin errores.
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
    }
}

Paso 2: Crea un ViewHolder para el encabezado

  1. Crea el diseño del encabezado en un nuevo archivo de recursos de diseño llamado header.xml que muestre un TextView. No hay nada emocionante en esto, así que aquí está el código.
<?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. Extrae "Sleep Results" en un recurso de cadena y llámalo header_text.
<string name="header_text">Sleep Results</string>
  1. En SleepNightAdapter.kt, dentro de SleepNightAdapter, sobre la clase ViewHolder, crea una nueva clase TextViewHolder. Esta clase infla el diseño textview.xml y devuelve una instancia de TextViewHolder. Como ya lo hiciste antes, aquí tienes el código y tendrás que importar View y 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)
            }
        }
    }

Paso 3: Actualiza SleepNightAdapter

A continuación, debes actualizar la declaración de SleepNightAdapter. En lugar de admitir solo un tipo de ViewHolder, debe poder usar cualquier tipo de contenedor de vistas.

Define los tipos de elementos

  1. En SleepNightAdapter.kt, en el nivel superior, debajo de las instrucciones import y arriba de SleepNightAdapter, define dos constantes para los tipos de vistas.

    El RecyclerView deberá distinguir el tipo de vista de cada elemento para poder asignarle correctamente un elemento de ViewHolder.
    private val ITEM_VIEW_TYPE_HEADER = 0
    private val ITEM_VIEW_TYPE_ITEM = 1
  1. Dentro de SleepNightAdapter, crea una función para anular getItemViewType() y devolver la constante de encabezado o elemento correcta según el tipo del elemento actual.
override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is DataItem.Header -> ITEM_VIEW_TYPE_HEADER
            is DataItem.SleepNightItem -> ITEM_VIEW_TYPE_ITEM
        }
    }

Actualiza la definición de SleepNightAdapter

  1. En la definición de SleepNightAdapter, actualiza el primer argumento para ListAdapter de SleepNight a DataItem.
  2. En la definición de SleepNightAdapter, cambia el segundo argumento genérico para ListAdapter de SleepNightAdapter.ViewHolder a RecyclerView.ViewHolder. Verás algunos errores relacionados con las actualizaciones necesarias, y el encabezado de la clase debería verse como se muestra a continuación.
class SleepNightAdapter(val clickListener: SleepNightListener):
       ListAdapter<DataItem, RecyclerView.ViewHolder>(SleepNightDiffCallback()) {

Actualiza onCreateViewHolder()

  1. Cambia la firma de onCreateViewHolder() para que muestre un RecyclerView.ViewHolder.
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder
  1. Expande la implementación del método onCreateViewHolder() para probar y devolver el elemento adecuado para cada tipo de elemento. Tu método actualizado debería verse como el siguiente código.
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}")
        }
    }

Actualiza onBindViewHolder()

  1. Cambia el tipo de parámetro de onBindViewHolder() de ViewHolder a RecyclerView.ViewHolder.
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
  1. Agrega una condición para asignar datos al titular de la vista solo si el titular es un ViewHolder.
        when (holder) {
            is ViewHolder -> {...}
  1. Convierte el tipo de objeto que muestra getItem() en DataItem.SleepNightItem. La función onBindViewHolder() terminada debería verse de la siguiente manera:
  override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (holder) {
            is ViewHolder -> {
                val nightItem = getItem(position) as DataItem.SleepNightItem
                holder.bind(nightItem.sleepNight, clickListener)
            }
        }
    }

Actualiza las devoluciones de llamada de DiffUtil

  1. Cambia los métodos en SleepNightDiffCallback para usar tu nueva clase DataItem en lugar de SleepNight. Suprime la advertencia de lint como se muestra en el siguiente código.
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
    }
}

Agrega y envía el encabezado

  1. Dentro de SleepNightAdapter, debajo de onCreateViewHolder(), define una función addHeaderAndSubmitList() como se muestra a continuación. Esta función toma una lista de SleepNight. En lugar de usar submitList(), proporcionado por ListAdapter, para enviar tu lista, usarás esta función para agregar un encabezado y, luego, enviar la lista.
fun addHeaderAndSubmitList(list: List<SleepNight>?) {}
  1. Dentro de addHeaderAndSubmitList(), si la lista pasada es null, devuelve solo un encabezado. De lo contrario, adjunta el encabezado al principio de la lista y, luego, envía la lista.
val items = when (list) {
                null -> listOf(DataItem.Header)
                else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
            }
submitList(items)
  1. Abre SleepTrackerFragment.kt y cambia la llamada a submitList() por addHeaderAndSubmitList().
  1. Ejecuta tu app y observa cómo se muestra el encabezado como el primer elemento de la lista de elementos de sueño.

Hay dos cosas que se deben corregir en esta app. Una es visible y la otra no.

  • El encabezado aparece en la esquina superior izquierda y no se distingue fácilmente.
  • No importa mucho para una lista corta con un encabezado, pero no debes manipular la lista en addHeaderAndSubmitList() en el subproceso de IU. Imagina una lista con cientos de elementos, varios encabezados y lógica para decidir dónde se deben insertar los elementos. Este trabajo pertenece a una corrutina.

Cambia addHeaderAndSubmitList() para usar corrutinas:

  1. En el nivel superior dentro de la clase SleepNightAdapter, define un CoroutineScope con Dispatchers.Default.
private val adapterScope = CoroutineScope(Dispatchers.Default)
  1. En addHeaderAndSubmitList(), inicia una corrutina en adapterScope para manipular la lista. Luego, cambia al contexto Dispatchers.Main para enviar la lista, como se muestra en el siguiente código.
 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. Tu código debería compilarse y ejecutarse, y no verás ninguna diferencia.

Actualmente, el encabezado tiene el mismo ancho que los demás elementos de la cuadrícula, y ocupa un tramo horizontal y vertical. Toda la cuadrícula admite tres elementos de un ancho de tramo horizontalmente, por lo que el encabezado debe usar tres tramos horizontalmente.

Para corregir el ancho del encabezado, debes indicarle a GridLayoutManager cuándo abarcar los datos en todas las columnas. Para ello, configura SpanSizeLookup en un GridLayoutManager. Es un objeto de configuración que usa GridLayoutManager para determinar cuántos tramos usar para cada elemento de la lista.

  1. Abre SleepTrackerFragment.kt.
  2. Busca el código en el que defines manager, cerca del final de onCreateView().
val manager = GridLayoutManager(activity, 3)
  1. Debajo de manager, define manager.spanSizeLookup, como se muestra. Debes crear un object porque setSpanSizeLookup no acepta una lambda. Para crear un object en Kotlin, escribe object : classname, en este caso GridLayoutManager.SpanSizeLookup.
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
}
  1. Es posible que recibas un error del compilador para llamar al constructor. Si lo haces, abre el menú de intención con Option+Enter (Mac) o Alt+Enter (Windows) para aplicar la llamada al constructor.
  1. Luego, recibirás un error en object que indica que debes anular los métodos. Coloca el cursor en object, presiona Option+Enter (Mac) o Alt+Enter (Windows) para abrir el menú de intenciones y, luego, anula el método getSpanSize().
  1. En el cuerpo de getSpanSize(), devuelve el tamaño de intervalo correcto para cada posición. La posición 0 tiene un tamaño de intervalo de 3, y las demás posiciones tienen un tamaño de intervalo de 1. El código completado debería verse como el siguiente:
    manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
            override fun getSpanSize(position: Int) =  when (position) {
                0 -> 3
                else -> 1
            }
        }
  1. Para mejorar el aspecto del encabezado, abre header.xml y agrega este código al archivo de diseño 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. Ejecuta la app. Debería verse como en la siguiente captura de pantalla.

¡Felicitaciones! Terminaste.

Proyecto de Android Studio: RecyclerViewHeaders

  • Un encabezado suele ser un elemento que abarca el ancho de una lista y actúa como título o separador. Una lista puede tener un solo encabezado para describir el contenido del elemento o varios encabezados para agrupar los elementos y separarlos entre sí.
  • Un RecyclerView puede usar varios contenedores de vistas para admitir un conjunto heterogéneo de elementos, por ejemplo, encabezados y elementos de lista.
  • Una forma de agregar encabezados es modificar tu adaptador para que use un ViewHolder diferente. Para ello, verifica los índices en los que se debe mostrar el encabezado. El Adapter es responsable de hacer un seguimiento del encabezado.
  • Otra forma de agregar encabezados es modificar el conjunto de datos de respaldo (la lista) de tu cuadrícula de datos, que es lo que hiciste en este codelab.

Estos son los pasos principales para agregar un encabezado:

  • Abstrae los datos de tu lista creando un DataItem que pueda contener un encabezado o datos.
  • Crea un contenedor de vistas con un diseño para el encabezado en el adaptador.
  • Actualiza el adaptador y sus métodos para usar cualquier tipo de RecyclerView.ViewHolder.
  • En onCreateViewHolder(), devuelve el tipo correcto de titular de la vista para el elemento de datos.
  • Actualiza SleepNightDiffCallback para que funcione con la clase DataItem.
  • Crea una función addHeaderAndSubmitList() que use corrutinas para agregar el encabezado al conjunto de datos y, luego, llame a submitList().
  • Implementa GridLayoutManager.SpanSizeLookup() para que solo el encabezado tenga tres tramos de ancho.

Curso de Udacity:

Documentación para desarrolladores de Android:

En esta sección, se enumeran las posibles actividades para el hogar para los alumnos que trabajan en este codelab como parte de un curso dirigido por un instructor. Depende del instructor hacer lo siguiente:

  • Si es necesario, asigna una tarea.
  • Comunicarles a los alumnos cómo enviar las actividades para el hogar.
  • Califica las actividades para el hogar.

Los instructores pueden usar estas sugerencias en la medida que quieran y deben asignar cualquier otra actividad para el hogar que consideren apropiada.

Si estás trabajando en este codelab por tu cuenta, usa estas actividades para el hogar para probar tus conocimientos.

Responde estas preguntas:

Pregunta 1

¿Cuál de las siguientes afirmaciones es verdadera con respecto a ViewHolder?

▢ Un adaptador puede usar varias clases ViewHolder para contener encabezados y diferentes tipos de datos.

▢ Se pueden tener exactamente un contenedor de vistas para los datos y uno para un encabezado.

▢ Un objeto RecyclerView admite varios tipos de encabezados, pero los datos deben ser uniformes.

▢ Cuando se agrega un encabezado, se implementa RecyclerView como subclase para insertar el encabezado en la posición correcta.

Pregunta 2

¿Cuándo deberías usar corrutinas con un RecyclerView? Selecciona todas las afirmaciones que sean verdaderas.

▢ Nunca. Un RecyclerView es un elemento de la IU y no debe usar corrutinas.

▢ Usa corrutinas para tareas de larga duración que podrían ralentizar la IU.

▢ Las manipulaciones de listas pueden tardar mucho tiempo, y siempre debes realizarlas con corrutinas.

▢ Usa corrutinas con funciones de suspensión para evitar bloquear el subproceso principal.

Pregunta 3

¿Cuál de las siguientes opciones NO tienes que hacer cuando usas más de un ViewHolder?

▢ En el ViewHolder, proporciona varios archivos de diseño para inflar según sea necesario.

▢ En onCreateViewHolder(), devuelve el tipo correcto de titular de la vista para el elemento de datos.

▢ En onBindViewHolder(), solo vincula datos si el titular de la vista es el tipo correcto de titular de la vista para el elemento de datos.

▢ Generaliza la firma de la clase del adaptador para que acepte cualquier RecyclerView.ViewHolder.

Comienza la siguiente lección: 8.1 Cómo obtener datos de Internet

Para obtener vínculos a otros codelabs de este curso, consulta la página de destino de los codelabs de Conceptos básicos de Kotlin para Android.