Cómo crear vistas distintas en un Adapter de RecyclerView según el tipo de datos

Avatar
4 min lectura

Antes de empezar, es importante mencionar que el RecyclerView es una vista contenedora que permite mostrar una lista de elementos en forma eficiente. Al utilizar un adapter, podemos controlar cómo se deben mostrar cada uno de esos elementos en la lista.

Imagina que estamos trabajando con los siguientes tipos de datos. Un conjunto de tipos multimedia, de los cuales tenemos por ejemplo los siguientes:

sealed class MediaItem(val title: String) {
    class Movie(title: String) : MediaItem(title)
    class TvShow(title: String) : MediaItem(title)
    class Album(title: String) : MediaItem(title)
}

Para crear un adapter que muestre distintas vistas en función del tipo de datos, deberemos seguir los siguientes pasos:

Crea el Adapter

Definir una clase que herede de RecyclerView.Adapter y que tenga una clase interna ViewHolder.

Esta será abstracta, y solo tendrá una función estática para crear un ViewHolder distinto en función del tipo de datos.

Cada ViewHolder específico sabrá cómo hacer bind de sus vistas específicas:

abstract class MediaViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    companion object {
        fun create(parent: ViewGroup, viewType: Int): MediaViewHolder {
            val type = Type.values()[viewType]
            return when (type) {
                Type.MOVIE -> MovieViewHolder(parent.inflate(R.layout.item_movie))
                Type.TV_SHOW -> TvShowViewHolder(parent.inflate(R.layout.item_tv_show))
                Type.ALBUM -> AlbumViewHolder(parent.inflate(R.layout.item_album))
            }
        }
    }
}

class MovieViewHolder(itemView: View) : MediaViewHolder(itemView) {
    val binding = ItemMovieBinding.bind(itemView)

    fun bind(item: MediaItem.Movie) {
        // TODO
    }
}

class TvShowViewHolder(itemView: View) : MediaViewHolder(itemView) {
    val binding = ItemTvShowBinding.bind(itemView)

    fun bind(item: MediaItem.TvShow) {
        // TODO
    }
}

class AlbumViewHolder(itemView: View) : MediaViewHolder(itemView) {
    val binding = ItemAlbumBinding.bind(itemView)

    fun bind(item: MediaItem.Album) {
        // TODO
    }
}

Implementar los métodos necesarios del Adapter

En la clase Adapter, implementar los métodos onCreateViewHolder y onBindViewHolder.

El método onCreateViewHolder se encargará de crear una nueva vista y un nuevo ViewHolder cada vez que sea necesario, mientras que el método onBindViewHolder se encargará de actualizar los datos de la vista con la información del elemento correspondiente.

Además, necesitas el método getItemViewType, que indicará según la posición, cuál es el tipo de datos.

Para hacerlo más sencillo, vamos a crear un enum class que indique los tipos de datos:

enum class Type {
    MOVIE, TV_SHOW, ALBUM
}

Y ahora ya puedes definir las funciones:

class MediaAdapter : ListAdapter<MediaItem, MediaViewHolder>(DiffCallback) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder =
        MediaViewHolder.create(parent, viewType)

    override fun onBindViewHolder(holder: MediaViewHolder, position: Int) {
        when (val item = getItem(position)) {
            is MediaItem.Movie -> (holder as MovieViewHolder).bind(item)
            is MediaItem.TvShow -> (holder as TvShowViewHolder).bind(item)
            is MediaItem.Album -> (holder as AlbumViewHolder).bind(item)
        }
    }

    override fun getItemViewType(position: Int): Int = when (getItem(position)) {
        is MediaItem.Movie -> Type.MOVIE
        is MediaItem.TvShow -> Type.TV_SHOW
        is MediaItem.Album -> Type.ALBUM
    }.ordinal
}

Trabajando con el concepto de Renderers

La solución anterior no es incorrecta, pero si el número de tipos es muy grande, puede ser muy difícil de escalar.

Una alternativa es crear un Adapter que entienda el concepto de Renderer. Un Renderer no es más que una clase que sabe cómo crear una vista y cómo hacerle el bind:

interface Renderer {
    fun createView(parent: ViewGroup): View
    fun bindView(view: View, item: Any)
}

Podemos crear un Renderer por tipo de datos:

class MovieRenderer : Renderer {
    override fun createView(parent: ViewGroup): View =
        parent.inflate(R.layout.item_movie)

    override fun bindView(view: View, item: Any) {
        // TODO
    }
}

class TvShowRenderer : Renderer {
    override fun createView(parent: ViewGroup): View =
        parent.inflate(R.layout.item_tv_show)

    override fun bindView(view: View, item: Any) {
        // TODO
    }
}

class AlbumRenderer : Renderer {
    override fun createView(parent: ViewGroup): View =
        parent.inflate(R.layout.item_album)

    override fun bindView(view: View, item: Any) {
        // TODO
    }
}

Y ya solo nos queda asignar el Renderer correspondiente a cada tipo de datos:

private val renderers = mapOf(
    MediaItem.Movie::class to MovieRenderer(),
    MediaItem.Movie::class to TvShowRenderer(),
    MediaItem.Movie::class to AlbumRenderer()
)

¿Cómo sería un Adapter que entienda estos renderers? Un código similar a este:

class RendererAdapter(private val renderers: Map<KClass<*>, Renderer>) :
    ListAdapter<MediaItem, RendererViewHolder>(DiffCallback) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RendererViewHolder {
        val renderer = renderers.values.toList()[viewType]
        return RendererViewHolder(parent, renderer)
    }

    override fun onBindViewHolder(holder: RendererViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    override fun getItemViewType(position: Int): Int {
        renderers.entries.forEachIndexed { index, entry ->
            if (entry.key == getItem(position)::class) return index
        }
        throw IllegalArgumentException("Unknown type")
    }
}

Ahora, simplemente pasándole distintos renderers al adapter, podemos crear vistas totalmente distintas con un único adapter.