3 formas de pasar varios Listeners a un RecyclerView
Antonio Leiva

Seguro que has visto muchos ejemplos donde un RecyclerView recibe un listener para, por ejemplo realizar, una acción cuando se hace click en el elemento.

class MoviesAdapter(private val listener: (Movie) -> Unit) 
    : ListAdapter<Movie, MoviesAdapter.ViewHolder>(...) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        ...
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val movie = getItem(position)
        holder.bind(movie)
        holder.itemView.setOnClickListener { listener(movie) }
    }

}

Pero imagina que ahora queremos tener una acción extra, que nos permita marcar un elemento como favorito. ¿Qué hacemos?

El otro día justo recibía esta pregunta en la comunidad de Discord:

Te voy a mostrar tres opciones, y la tercera seguro que te va a sorprender 🤯

Opción 1: Pasar un nuevo listener

La opción más evidente puede ser añadir otra lambda al constructor, que nos permita gestionar esa acción de forma independiente:

class MoviesAdapter(
    private val onClick: (Movie) -> Unit, 
    private val onFavorite: (Movie) -> Unit
)

Esto está muy bien, ahora le podemos pasar este listener al ViewHolder, que se llame cuando se haga click en la acción, y desde fuera hacer lo que necesitemos:

private val adapter = MoviesAdapter(
    onClick = { ... },
    onFavorite = { viewModel.onFavorite(it)}
)

Pero te puedes estar preguntando… ¿qué pasa si aparece una nueva acción, como por ejemplo «Compartir»? ¿Y qué pasa si hay 10 más? ¿Le añado 12 listeners al constructor?

Obviamente esta solución no escala bien, y solo te la recomendaría si tienes la seguridad de que no van a crecer el número de acciones

Opción 2: Modelar las acciones con una sealed class

En vez de crear listeners para cada acción, ¿por qué no generalizamos la idea de que un RecyclerView puede tener un listado indeterminado de acciones?

De esta manera, la forma de la lambda sería:

onAction: (Action) -> Unit

Hay muchas formas de modelar esto. De hecho, podríamos pasarle 2 valores: la acción (que podría ser un enum), y la película sobre la que se realiza la acción.

Pero para no limitarnos, porque podría ser que una acción requiriera 0 o más de un valor, lo vamos a modelar como una sealed class. Así nos curamos en salud para el futuro.

Si queremos obligar a que al menos reciban el elemento sobre el que se ha realizado la acción, podemos usar efectivamente una sealed class:

sealed class Action(val movie: Movie) {
    class Click(movie: Movie) : Action(movie)
    class Favorite(movie: Movie) : Action(movie)
    class Share(movie: Movie) : Action(movie)
    class Delete(movie: Movie) : Action(movie)
}

Si por el contrario, queremos dejar mayor flexibilidad, puede ser suficiente con una sealed interface:

sealed interface Action {
    class Click(val movie: Movie) : Action
    class Favorite(val movie: Movie) : Action
    class Share(val movie: Movie) : Action
    class Delete(val movie: Movie) : Action
}

En cualquier caso, la forma de usarla luego será similar. El ViewHolder recibirá la función de callback, y la llamará cuando sea necesario:

class ViewHolder(view: View, private val onAction: (Action) -> Unit) :
    RecyclerView.ViewHolder(view) {
    private val binding = ViewMovieBinding.bind(view)
    fun bind(movie: Movie) {
        binding.movie = movie
        binding.favorite.setOnClickListener { onAction(Action.Favorite(movie)) }
        binding.share.setOnClickListener { onAction(Action.Share(movie)) }
    }
}

Finalmente, la implementación de la función es bastante directa. Si estamos usando un ViewModel, podemos tener una función que directamente reciba la acción:

private val adapter = MoviesAdapter { 
    viewModel.onAction(it) 
}

Y luego la implementación del ViewModel sería:

fun onAction(action: Action) {
    when(action){
        is Action.Click -> TODO()
        is Action.Delete -> TODO()
        is Action.Favorite -> TODO()
        is Action.Share -> TODO()
    }
}

Aquí ya implementarías como necesitaras cada acción, probablemente llamando a un Repository o un UseCase.

Opción 3: Acciones en el UiState

Esta es una alternativa que, la verdad, no me la había planteado hasta que no la vi en las nuevas guías de arquitecturas Android.

Consiste en convertir el modelo en un estado de UI que, además, sepa cómo gestionar las acciones sobre el mismo.

Para ello, nos creamos un objeto MoviesUiState que contenga exclusivamente lo que la UI necesita:

    data class MovieUiState(
        val id: Int,
        val title: String,
        val posterPath: String,
        val favorite: Boolean,
        val onFavorite: () -> Unit,
        val onDelete: () -> Unit,
        val onShare: () -> Unit
    )

El onClick() en este caso lo dejamos fuera, porque pertenece a la lógica de UI (al final lo que hará es navegar), y no nos interesa que pase por el ViewModel.

Después, convertimos los elementos que nos vienen a este nuevo estado:

movies = movies.map { it.toUiState() })

private fun Movie.toUiState() = MovieUiState(
    id = id,
    title = title,
    posterPath = posterPath,
    favorite = favorite,
    onFavorite = {
        viewModelScope.launch {
            switchMovieFavoriteUseCase(this@toUiState)
        }
    },
    onShare = { TODO() },
    onDelete = { TODO() }
)

Como este estado se crea en el propio ViewModel (o en el lugar donde hayas decidido tener la lógica de negocio), ya puedes llamar directamente a quien se encargue de ejecutar esa acción.

En mi caso, como ejemplo, llamo al caso de uso que gestiona los favoritos (es una función suspend, por eso necesita estar en un contexto de corrutina).

Te dejo los otros dos como ejercicio si quieres realizarlos.

Lo último que quedaría es modificar el adapter, para que pueda usar este nuevo estado. Te dejo el ViewHolder a modo de ejemplo:

class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    private val binding = ViewMovieBinding.bind(view)
    fun bind(movie: MainViewModel.MovieUiState) {
        binding.movie = movie
        binding.favorite.setOnClickListener { movie.onFavorite() }
        binding.share.setOnClickListener { movie.onShare() }
        binding.delete.setOnClickListener { movie.onDelete() }
    }
}

La verdad que esta solución, aunque en principio puede resultar más limpia, porque no tenemos que pasar por la Activity o el Fragment, también creo que presenta un par de inconvenientes.

  1. El código resultante es más verboso, y aunque hacer una transformación del modelo que no siempre es necesaria
  2. Nos limita un poco, porque si algunas de las acciones son lógica de UI, vamos a tener que mantener también una solución como los puntos 1 ó 2. Por ejemplo, la acción de compartir muchas veces será simplemente código de UI, y no es necesario pasar por el ViewModel.

Si me preguntas con cuál me quedaría, mi solución sería la 1 cuando solo hay 1 ó 2 listeners, y la 2 cuando ya hay más.

¿Y a ti qué te parecen estas soluciones? ¿Hay alguna que no conocieras de antes? ¿Conoces alguna otra que no haya mencionado?

Te leo en los comentarios.

Quizá también te interese…

Cómo simular una base de datos reactiva en Room con Fakes

Cómo simular una base de datos reactiva en Room con Fakes

En el desarrollo de aplicaciones móviles es muy común utilizar bases de datos para almacenar y gestionar la información que se utiliza en la aplicación. En el caso de Android, una de las opciones más populares es Room, una librería de persistencia de datos que...

Descargar una página web en Android con OkHttp

Descargar una página web en Android con OkHttp

En este tutorial vamos a aprender cómo descargar una página web en Android utilizando la librería OkHttp y la librería activity-ktx para facilitar el manejo de los ciclos de vida de nuestra aplicación. Configuración de la App Para empezar, necesitamos incluir las...

Usar Ktor Client para hacer peticiones HTTP en Android

Usar Ktor Client para hacer peticiones HTTP en Android

Ktor es un framework de servidor y cliente de Kotlin diseñado para crear aplicaciones web y móviles de forma rápida y fácil. En este artículo, veremos cómo usar Ktor client en una aplicación Android para hacer peticiones a una API. Configurar las dependencias de Ktor...

0 comentarios

Enviar un comentario

Los datos personales que proporciones a través de este formulario quedarán registrados en un fichero de DevExpert, S.L.U., con el fin de gestionar los comentarios que realizas en este blog. La legitimación se realiza a través del consentimiento de la parte interesada. Si no se acepta, no podrás comentar en este blog. Los datos que proporciona solo se utilizan para evitar el correo no deseado y no se usarán para nada más. Puede ejercer los derechos de acceso, rectificación, cancelación y oposición en contacto@devexperto.com.

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Acepto la política de privacidad *