Crear Listas Con RecyclerView En Android

El RecyclerView en Android es un ViewGroup flexible que permite mostrar un conjunto de datos con capacidad de scroll. Su uso más común es la presentación de listas de ítems al usuario.

Apariencia de RecyclerView en Android

A diferencia del ListView, el RecyclerView reúsa los views de los ítems en su jerarquía, en vez de destruirlos cuando el desplazamiento alcanza los límites de la pantalla. Lo cual se refleja en mejora de rendimiento y por lo tanto menor consumo de tu App.

Ejemplo De RecyclerView En Android

En este tutorial aprenderás a implementar y configurar un RecyclerView a partir de las clases e interfaces proveídas por la librería que lo contiene. A continuación se muestra una imagen de varios ejemplos que construirás:

La idea es crear diferentes layouts (lista, grilla y grilla escalonada) del RecyclerView para mostrar una lista de artículos sobre estudios.

Aunque el objetivo es que llegues a este resultado con la explicación dada, puedes descargar el proyecto Android Studio desde el siguiente enlace por si te pierdes en detalles que en ocasiones son obviados:


RecyclerView En Android

Las siguientes son las clases fundamentales que actúan como participantes en la estructura de creación de una lista de elementos:

  • Datos: Determinan la colección que se quiere mostrar sobre los ítems (arrays, listas, sets, etc.).
  • RecyclerView: Es el contenedor donde se mostraran el resultado final, es decir, los views producidos por el proceso de inflado y población de datos. Usaremos instancias de este ViewGroup en los layouts de Actividades y fragmentos como normalmente lo hacemos.
  • RecyclerView.ViewHolder: Contiene la referencia del view creado para un ítem y la información de su lugar en el RecyclerView. Esto te permitirá vincular directamente tu información sobre los widgets del ítem.
  • RecyclerView.Adapter: Colabora con el ViewHolder para convertir una colección de datos en una lista de views que serán añadidos al RecyclerView y enlazados con dichos datos.
  • RecyclerView.LayoutManager: Es el responsable de medir y posicionar los ítems dentro del RecyclerView. Además determinar cuando los ítems no son visible para el usuario. Existen varias implementaciones de esta clase para permitirnos crear listas, grillas o estructuras escalonadas.

La siguiente ilustración muestra de forma general la anterior estructura:

RecyclerView Android Componentes

Implementar RecyclerView

Basado en los conceptos previos, procedamos a realizar la implementación de nuestro ejemplo a partir de la siguiente receta.

1. Agregar Dependencia Gradle

Hoy en día la creación de nuevos proyectos en Android Studio trae por defecto la dependencia Gradle de la librería RecyclerView.

Pero si tu proyecto tiene cierto tiempo de creado o no fue creado por ti, entonces asegúrate de incluirla con la última versión en el archivo build.gradle del módulo:

dependencies {
    implementation "androidx.recyclerview:recyclerview:ultima-version"
}

2. Definir Datos A Mostrar

El primer componente a proveer es el tipo de datos que deseas mostrar en el RecyclerView. Este puede ser un tipo básico como un String o un tipo complejo como la instancia de una clase.

Para nuestro ejemplo usaremos una clase de datos que representará a los artículos del aplicativo. Por lo que agregamos el archivo Article.kt al proyecto e implementamos las propiedades de: ID, título, descripción y la imagen destacada:

data class Article(
    val id: Int,
    val title: String,
    val description: String,
    val featuredImage: Int
) 

Por simplicidad, le añadiremos un objeto compañero para exponer una lista prefabricada de Article en memoria.

companion object {
    val data
        get() = listOf(
            Article(
                1,
                "10 Tips Para Estudiar",
                "Descubre cómo aumentar tu productividad al estudiar",
                R.drawable.learning1
            ),
            Article(
                2,
                "Guía para escribir tu primer cuento",
                "Incursiona en el mundo de la narración infantil",
                R.drawable.learning2
            ),
            Article(
                3,
                "Optimizar trabajos grupales",
                "Aplica estas estrategias para mejorar tus trabajos en grupo",
                R.drawable.learning3
            ),
            Article(
                4,
                "Libros que nunca habías escuchado",
                "Te presentamos la lista de los libros más raros",
                R.drawable.learning4
            ),
            Article(
                5,
                "Cómo mejorar en la universidad",
                "En este artículo una actitud adecuada para la U",
                R.drawable.learning5
            ),
            Article(
                6,
                "40 buscadores de artículos científicos",
                "Descubre los buscadores más importantes para cada área del conocimiento",
                R.drawable.learning6
            ),
            Article(
                7,
                "Pautas para escribir un ensayo",
                "Karla te explica un marco de trabajo para hace ensayos",
                R.drawable.learning7
            ),
            Article(
                8,
                "Crear un ambiente de estudio para llegar a \"la zona\"",
                "Aprende a modificar tu entorno para sacar el máximo beneficio de tu mente",
                R.drawable.learning8
            ),
            Article(
                9,
                "Estudiar 80 horas semanales",
                "Como Carlos logró estudiar 80 horas sin agotarse",
                R.drawable.learning9
            ),
            Article(
                10,
                "Lo que tu tutor de tesis no te dice",
                "Consejos para terminar trabajos de grado rápido",
                R.drawable.learning10
            )
        )
}

3. Añadir RecyclerView Al Layout

El paso siguiente es añadir el RecyclerView al layout de la actividad o fragmento donde será mostrada la lista.

Así que abre tu layout y desde el editor de Android Studio arrastra un ejemplar desde la paleta de views:

Agregar RecyclerView En Android Studio

Si pasas a la pestaña de código podrás ver que se ha generado una etiqueta <androidx.recyclerview.widget.RecyclerView> en tu layout.

RecyclerView en Layout

Por ejemplo, nuestro archivo article_item.xml tendría la siguiente definición XML:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:clipToPadding="false"
        android:paddingVertical="8dp"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Ya que es necesario definir el LayoutManager que usará el RecyclerView, asignamos al atributo app:layoutManager el nombre completamente calificado de la clase LinearLayoutManager.

Este descendiente organiza a los ítems para proyectarlos de forma lineal sobre el RecyclerView.

4. Crear Layout Para Los Ítems

Nuestro próximo material es el layout para los ítems puesto que el adaptador requiere su uso posteriormente.

Layout de ítem de lista

En concreto diseñaremos un ítem de dos líneas con una imagen en la parte izquierda para presentar a cada artículo. Es decir, dos TextViews junto a un ImageView:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="88dp">

    <ImageView
        android:id="@+id/featured_image"
        android:layout_width="100dp"
        android:layout_height="0dp"
        android:layout_marginTop="16dp"
        android:layout_marginBottom="16dp"
        android:contentDescription="@string/article_image_desc"
        android:scaleType="centerCrop"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:srcCompat="@tools:sample/backgrounds/scenic" />

    <TextView
        android:id="@+id/article_title"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="20dp"
        android:layout_marginLeft="20dp"
        android:layout_marginTop="16dp"
        android:layout_marginEnd="16dp"
        android:layout_marginRight="16dp"
        android:ellipsize="end"
        android:maxLines="1"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toEndOf="@+id/featured_image"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="@tools:sample/lorem" />

    <TextView
        android:id="@+id/article_description"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginEnd="16dp"
        android:layout_marginRight="16dp"
        android:layout_marginBottom="16dp"
        android:ellipsize="end"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="@+id/article_title"
        app:layout_constraintTop_toBottomOf="@+id/article_title"
        app:layout_constraintVertical_bias="0.0"
        tools:text="@tools:sample/lorem/random" />
</androidx.constraintlayout.widget.ConstraintLayout>

El Atributo tools:listitem

Puedes previsualizar el aspecto que tendrá tu lista en la pantalla del dispositivo usando el atributo tools:listitem sobre el RecyclerView.

Previsualización de items de lista con atributo tools:listitem

Solo asigna la referencia del recurso del layout del ítem y pasa a la pestaña Split o Design para renderizar el inflado de los artículos:

tools:listitem="@layout/article_item"

5. Crear Adaptador Del RecyclerView

Ahora procedemos a crear una implementacion del adaptador que usará el RecyclerView, aplicando una herencia desde RecyclerView.Adapter.

Al mismo tiempo añadimos una clase anidada que extienda de RecyclerView.ViewHolder, ya que el adaptador va ligado a esta declaración. De manera que, añade una nueva clase llamada ArticleAdapter:

class ArticleAdapter: RecyclerView.Adapter<ArticleAdapter.ArticleViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
        TODO("Not yet implemented")
    }

    override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
        TODO("Not yet implemented")
    }

    override fun getItemCount(): Int {
        TODO("Not yet implemented")
    }

    class ArticleViewHolder(view: View) : RecyclerView.ViewHolder(view) {

    }
}

Luego declararemos una lista de elementos Article como propiedad mutable del adaptador con el objetivo de establecer un origen de datos:

var articles = listOf<Article>()
    set(value) {
        field = value
        notifyDataSetChanged()
    }

La lista articles comienza vacía, pero personalizamos su set() para actualizar el campo de respaldo normalmente y luego invocar a notifiyDataSetChanged().

Este método le notifica al adaptador que los datos cambiaron, por lo que se debe redibujar a todos los elementos en la lista.

Implementar onCreateViewHolder()

Este método es llamado por el RecyclerView cuando requiere crear un ViewHolder para un ítem. Tu trabajo es inflar el layout del ítem, o crear programáticamente su view, para pasarlo como parámetro a una nueva instancia del view holder.

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
    val view = LayoutInflater
        .from(parent.context)
        .inflate(R.layout.item_list, parent, false)
    return ArticleViewHolder(view)
}

Implementar onBindViewHolder()

El RecyclerView llama a este método para vincular los datos al ViewHolder en una posición dada. Tu tarea es hacer la asignación del modelo a cada view de la jerarquía del ítem.

override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
    val article = articles[position]
    holder.bind(article)
}

class ArticleViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    val title: TextView = view.findViewById(R.id.article_title)
    val description: TextView = view.findViewById(R.id.article_description)
    val featuredImage: ImageView = view.findViewById(R.id.featured_image)

    fun bind(article: Article) {
        title.text = article.title
        description.text = article.description
        featuredImage.setImageResource(article.featuredImage)
    }
}

Aunque podríamos hacer la asignación de valores desde onBindViewHolder(), podemos encapsular esta acción en un nuevo método bind() donde pasamos el artículo en la posición actual.

Implementar getItemCount()

Le permite saber al RecyclerView cuál es el tamaño actual de tu colección de datos. Claramente aquí retornas un entero con la propiedad de tamaño de tu conjunto.

override fun getItemCount() = articles.size

6. Asignar Adaptador Al RecyclerView

Terminamos la implementación tomando la referencia del RecyclerView y diciéndole que adaptador será el encargado de hacer la transformación del conjunto de datos.

Ejemplo de LinearLayoutManager en RecyclerView

En términos de código Kotlin esto quiere decir:

  1. Vamos a nuestra actividad o fragmento y tomamos la referencia del RecyclerView
  2. Creamos una nueva instancia de ArticleAdapter
  3. Asignamos el adaptador a la propiedad mutable RecyclerView.adapter
  4. Actualizamos los datos del adaptador con la lista Article.data
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.articles)

        val articleList: RecyclerView = findViewById(R.id.list) // (1)

        val articleAdapter = ArticleAdapter() // (2)
        articleList.adapter = articleAdapter // (3)

        articleAdapter.articles = Article.data // (4)
    }
}

También es posible asignar el objeto LayouManager en tiempo de ejecución si así lo deseas:

articleList.layoutManager = LinearLayoutManager(this) // (3.1)

Optimizar RecyclerView Con Tamaño Fijo

Si la lista que deseas mostrar tiene una cantidad de ítems fija a lo largo del caso de uso, puedes usar el método setHasFixedSize() con true para acotar el comportamiento del RecyclerView y mejorar el rendimiento de dibujado:

articleList.setHasFixedSize(true) // (3.2)

Manejar Eventos De Click En Ítems

Tenemos presente que hacer los ítems clickables es de gran importancia para que el usuario interactúe con los datos en nuestras Apps.

Manejar eventos de click en RecyclerView

Por simplicidad, nuestro ejemplo solo mostrará un Toast al momento de hacer click en un ítem. No obstante, la forma de conseguir esa pequeña acción es exactamente igual para tareas más complejas como navegar a otras pantallas.

¿Cómo escuchar clicks de un ítem?

1. La solución consiste en asignarle una escucha View.OnClickListener al view almacenado por ArticleViewHolder. Al detectar el click se ejecuta un tipo función (Article) -> Unit que pasamos en su constructor:

class ArticleViewHolder(view: View, val onClick: (Article) -> Unit) :
    RecyclerView.ViewHolder(view) {
    //..

    private var currentArticle: Article? = null

    init {
        view.setOnClickListener {
            currentArticle?.let {
                onClick(it)
            }
        }
    }

    fun bind(article: Article) {
        currentArticle = article

        //..
    }
}

2. Luego puedes elegir manejar el evento de click al interior del ViewHolder o desde el exterior según tu conveniencia. En nuestro caso lo haremos desde el exterior.

Esto podemos lograrlo fácilmente pasando una función del tipo (Article) -> Unit a través del constructor del adaptador:

class ArticleAdapter(private val onClick: (Article) -> Unit)

En el caso de que usaras un ViewModel, lo correcto sería pasarle el manejo del evento, ya que esa es su responsabilidad.

3. En seguida, vamos a la creación del adaptador en la actividad y pasamos como argumento una función lambda que represente el consumo del tipo función:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    //..

    val articleAdapter = ArticleAdapter { article -> onItemClick(article) } 

    //..

}

private fun onItemClick(article: Article) {
    Toast.makeText(this, article.title, Toast.LENGTH_SHORT).show()
}

De esta forma, cada que se realice un click en un ítem, tendremos a mano el modelo de datos asociado para realizar las acciones correspondientes.

Aplicar Efecto Ripple A Los Ítems

Si deseas presentar un efecto de superficie al clickear los ítems del RecyclerView usa el atributo android:background="?attr/selectableItemBackground" sobre el nodo raíz del layout del mismo:

Efecto Ripple en ítems de RecyclerView

Concretamente sería sobre el ConstraintLayout que sostiene la jerarquía de cada artículo:

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:padding="16dp"
    android:background="?attr/selectableItemBackground"
    android:layout_height="88dp">

Optimizar Listas Largas

En nuestra App de artículos de estudio usamos pocos ítems en la lista para simplificar el caso de uso en el tutorial, por eso llamamos al método notifyDataSetChanged() con tanta comodidad sobre el set() de articles del adaptador.

Sin embargo, realizar notificaciones desde el adaptador cuando existen grandes cantidades de elementos, comprometería el rendimiento y fluidez de la interfaz.

Veamos como mejorar esta situación.

1. Crear Callback A Partir De DiffUtil

Es justo aquí donde entra la clase DiffUtil para ejecutar la comparación entre el conjunto de datos actual del adaptador versus el nuevo estado.

Esto permitirá optimizar la cantidad de notificaciones que se realizan y mejora el rendimiento en casos donde existen grandes cantidades de ítems.

¿Cómo usas esta clase?

Para obtener los comportamientos para la optimización creamos una clase que implemente a DiffUtil.ItemCallback<T>, donde el argumento de tipo sea nuestro modelo (Article):

class ArticleDiffCallback : DiffUtil.ItemCallback<Article>() {
    override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean {
        return oldItem == newItem
    }
}

Los métodos areItemsTheSame() y areContentsTheSame() ejecutan una comparación de igualdad de la identidad y contenido entre el ítem (oldItem) existente y el nuevo (newItem).

Cabe destacar que al marcar como data class a Article, el operador de igualdad comparará el contenido propiedad a propiedad como es necesitado en areContentsTheSame().

2. Extender Adaptador De ListAdapter

La librería nos otorga una clase llamada ListAdapter que implementa internamente todas las notificaciones de cambio del conjunto de datos de forma eficiente.

Partiendo con esta ventaja, tan solo heredamos a ArticleAdapter desde ListAdapter<T, VH> y luego pasamos una instancia de la callback al constructor:

class ArticleAdapter(private val onClick: (Article) -> Unit) :
    ListAdapter<Article, ArticleAdapter.ArticleViewHolder>(ArticleDiffCallback()) 

El parámetro de tipo T se refiere al tipo que usaran las listas a comparar. Y VH es el parámetro para el tipo de ViewHolder asociado.

Gracias a este cambio ocurren tres cosas:

  1. Removemos a la propiedad articles ya que ListAdapter se encarga de manejar las listas internamente.
  2. Reemplazamos a articles[position] por ListAdapter.getItem() para obtener el item actual en onBindViewHolder()
  3. Removemos el método getItemCount() porque es manejado internamente

De esta forma nuestro adaptador quedaría listo para optimizar las notificaciones a partir del cálculo de diferencias.

override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
    val article = getItem(position)
    holder.bind(article)
}

3. Actualizar Lista

Para terminar esta implementación debes cambiar la forma de actualizar la lista de artículos.

La clase ListAdapter también nos otorga el método submitList() para enterar al adaptador de una nueva actualización de los datos.

Así que vamos a la creación del adaptador y reemplazamos la actualización anterior por la llamada de este método:

articleAdapter.submitList(Article.data)

Modificar El Layout

Además de el LinearLayoutManager usado para ítems de lista también existen dos estructuras más prefabricadas: GridLayoutManager y StaggeredLayoutManager.

Veamos como modificar en tiempo de ejecución nuestra lista actual para que tome estas formas.

Crear Grilla Con GridLayouManager

Afortunadamente podemos reutilizar todo lo que hicimos hasta el momento para implementar una grilla desde el mismo modelo de datos.

¿Cuál es la única diferencia?

La forma en que se comporta la organización de los ítems. El GridLayoutManager usa una propiedad llamada span para la cantidad de columnas o filas en el layout:

Ejemplo de GridLayoutManager con RecyclerView

Obviamente al cambiar los espacios y geometría de los ítems, necesitaremos actualizar el layout del ítem para mostrar una presentación acorde:

Layout para ítem de grilla

Ubicaremos la imagen en la parte superior de cada ítem y pasaremos el título a la parte inferior. La idea es conseguir una forma de recuadros coleccionables:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="194dp"
    android:background="?attr/selectableItemBackground"
    android:foreground="?attr/selectableItemBackground"
    android:orientation="vertical"
    tools:layout_width="190dp">

    <ImageView
        android:id="@+id/featured_image"
        android:layout_width="match_parent"
        android:layout_height="140dp"
        android:contentDescription="@string/article_image_desc"
        android:scaleType="centerCrop"
        tools:srcCompat="@tools:sample/backgrounds/scenic" />

    <TextView
        android:id="@+id/article_title"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:ellipsize="end"
        android:gravity="center_vertical"
        android:maxLines="2"
        android:paddingHorizontal="16dp"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
        tools:text="@tools:sample/lorem" />

</LinearLayout>

Luego asignamos una instancia GridLayoutManager con 2 columnas en la configuración del RecyclerView. Recuerda que puedes usar apply() para empaquetar en un mismo contexto la configuración:

val gutter = resources.getDimensionPixelSize(R.dimen.grid_gutter)

articleList.apply {
    adapter = articleAdapter
    layoutManager = GridLayoutManager(this@MainActivity, 2)
    addItemDecoration(GutterDecoration(gutter))
}

Debido a que la grilla sin espacios se ve mal, usaremos un decorador que añada canales entre los bordes de cada ítem. El código Kotlin de decorador será el siguiente:

class GutterDecoration(private val gutter: Int) : RecyclerView.ItemDecoration() {

    override fun getItemOffsets(
        outRect: Rect, view: View,
        parent: RecyclerView, state: RecyclerView.State
    ) {
        outRect.left = gutter / 2
        outRect.right = gutter / 2

        outRect.bottom = gutter
    }
}

La variable gutter recibe la conversión en pixeles a partir del recurso R.dimen.grid_gutter (16dp) a través del método getDimensionsPixelSize().

Ocupar Span Completo

Puede presentarse la situación en la que desees que un item ocupe un span completo de la grilla como se ve en la siguiente captura:

Para hacerlo efectivo, usaremos la clase SpanSizeLookup, la cual define cuantos spans ocupa un ítem en el layout. Obviamente como hemos experimentado su valor por defecto es 1.

Sin embargo, como deseamos que el primer elemento ocupe 2 espacios, entonces debemos crear una instancia de SpanSizeLookup que aplique esta condición.

Así que ve a la creación del adaptador e intervén la creación del GridLayoutManager para asignarle un nuevo objeto anónimo de la clase en la propiedad spanSizeLookup:

gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
    override fun getSpanSize(position: Int) = when (position) {
        0 -> 2
        else -> 1
    }
}

La expresión when nos permite formular para la posición 0 el tamaño 2 y para los demás el valor por defecto 1.

Crear Grilla Escalonada Con StaggereedLayoutManager

Como último layout tenemos la formación con grilla escalonada representada por el StaggeredLayoutManager.

Este administrador permite añadir espacios entre los bordes de los ítems en la grilla que puedes aplicar de forma independiente o uniforme entre toda la distribución. Por ejemplo:

Ejemplo de StaggeredGridLayouManager con RecyclerView

A causa de que ya tenemos un layout para ítems de grilla que podemos reutilizar, entonces solo quedaría cambiar el LayoutManager en tiempo de ejecución desde la actividad:

val staggeredManager =
    StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)
val gutter = resources.getDimensionPixelSize(R.dimen.grid_gutter)
articleList.apply {
    adapter = articleAdapter
    layoutManager = staggeredManager
    addItemDecoration(StaggeredGutterDecoration(gutter))
}

Cabe aclarar que el StaggeredGridLayoutManager usa la capacidad de adaptación del layout de tus ítems de grilla. Si fijas las alturas en un tamaño determinado, no verás introducción de espacios.

En nuestro ejemplo modificamos la altura del ítem desde el adaptador a partir de las posiciones:

fun bind(article: Article) {
    currentArticle = article

    title.text = article.title
    featuredImage.setImageResource(article.featuredImage)
    itemView.updateLayoutParams {
        val res = if (isDoubleHeightPosition(adapterPosition))
            R.dimen.image_height1
        else
            R.dimen.image_height2
        height = itemView.resources.getDimensionPixelSize(res)
    }
}

El método updateLayoutParams() nos permite cambiar la propiedad height de itemView en el ArticleViewHolder.

Donde el método de utilidad isDoubleHeightPosition() calcula si la posición actual es un indice para introducir elementos con el doble te tamaño:

object AsymmetricCalculator {

    fun isDoubleHeightPosition(position: Int): Boolean {
        val distanceLeftToRight = 4
        val distanceRightToLeft = 2
        val positions = generateSequence(Pair(0, distanceLeftToRight)) {
            val newFirst = it.second + distanceRightToLeft
            val newSecond = newFirst + distanceLeftToRight
            Pair(newFirst, newSecond)
        }
        return positions.flatMap { it.toList() }.take(7).any { it == position }
    }
}

Ya que los índices no están distribuídos linealmente, generamos una secuencia de Kotlin para contar 4 elementos de izquierda a derecha para introducir ítems con el doble de tamaño.

Y contamos 2 posiciones para insertar ítems con doble tamaño en el canal izquierdo. Luego aplanamos los pares con flatMap(), tomamos 7 elementos con take() y luego determinamos con any() si el índice entrante hace parte de ellos.


Añadir Ítems Con Diferente Layout

En ocasiones necesitarás incluir ítems a la lista con diferentes diseños en posiciones particulares del RecyclerView. Ítems especiales tales como cabeceras, anuncios, elementos destacados, etc.:

RecyclerView con ítem de cabecera

Uno de nuestros ejemplos muestra una cabecera inicial con el título «Artículos recientes».

Aunque podríamos ponerlo como un view externo al RecyclerView, vale la pena ver cómo integrarlo con el fin de pensar en casos más avanzados cuando lo requieras:

1. Lo primero que haremos será crear una clase sellada que contenga la representación de las cabeceras y los artículos. Este diseño permitirá facilitar el casting al interior del adaptador:

sealed class DataItem {
    abstract val id: Int

    object HeaderItem : DataItem() {
        override val id = Int.MIN_VALUE
    }

    data class ArticleItem(val article: Article) : DataItem() {
        override val id = article.id
    }
}

Como ves, HeaderItem es una definición de objeto que expresa la existencia de una sola cabecera y ArticleItem envuelve la instancia de Article para sostener su funcionalidad en esta jerarquía de clases.

La propiedad abstracta id será de utilidad para realizar las comparaciones con ArticleDiffCallback.

2. El paso siguiente es sobrescribir el método getItemViewType() en el adaptador y retornar enteros que representen los diferentes tipos de items que existirán:

private val HEADER_TYPE = 0
private val ITEM_TYPE = 1

override fun getItemViewType(position: Int): Int {
    return when (getItem(position)) {
        is DataItem.HeaderItem -> HEADER_TYPE
        is DataItem.ArticleItem -> ITEM_TYPE
    }
}

El valor retornado por getItemViewType() es recibido en onCreateViewHolder(), lo que significa que se podrán crear diferentes view holders asociados al tipo.

3. Ahora bien, crea un nuevo ViewHolder para la cabecera que infle el layout correspondiente:

class HeaderViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    companion object {
        fun create(parent: ViewGroup): HeaderViewHolder {
            val view = LayoutInflater.from(parent.context)
                .inflate(R.layout.header, parent, false)
            return HeaderViewHolder(view)
        }
    }
}

Como solo necesitas texto, el layout puede ser un TextView sin más:

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/name"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="16dp"
    android:text="@string/lastest_articles"
    android:textAppearance="@style/TextAppearance.MaterialComponents.Body2" />

4. Luego actualizamos onCreatViewHolder() y onBindViewHolder() para dirigir el curso dependiendo del tipo:

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
    return when (viewType) {
        HEADER_TYPE -> HeaderViewHolder.create(parent)
        ITEM_TYPE -> ArticleViewHolder.create(parent, onClick)
        else -> throw IllegalArgumentException("Tipo de ítem desconocido")
    }
}

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
    when (holder) {
        is ArticleViewHolder -> {
            val item = getItem(position) as DataItem.ArticleItem
            holder.bind(item)
        }
    }
}

El uso de la expresión when es fundamental para dirigir el curso de inflado y de binding de datos dependiendo de viewType y del casting realizado a holder.

5. Claro está que al usar a DataItem como modelo del adaptador, ArticleAdapter debe usarlo como parámetro de tipo al igual que ArticleDiffCallbak:

class ArticleAdapter(private val onClick: (Article) -> Unit) :
    ListAdapter<DataItem, RecyclerView.ViewHolder>(ArticleDiffCallback()) {

    //..
}

class ArticleDiffCallback : DiffUtil.ItemCallback<DataItem>() {
    override fun areItemsTheSame(oldItem: DataItem, newItem: DataItem): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: DataItem, newItem: DataItem): Boolean {
        return oldItem == newItem
    }
}

Finalmente ve a la actividad y crea una lista cuyo primer elemento sea del tipo HeaderItem; y el resto un mapeo hacia ArticleItem desde los datos en Article:

val list = listOf(DataItem.HeaderItem) + Article.data.map { DataItem.ArticleItem(it) }
articleAdapter.submitList(list)

Más Tutoriales Android

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!