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…

Clases y constructores en Kotlin con Android Studio

Clases y constructores en Kotlin con Android Studio

Veremos un repaso las clases y constructores de Kotlin para solventar esas inquietudes que nos surgen a la hora de seguir este curso, que pueden ser como funciona realmente y porque se presentan las clases de ese modo, como es la interacción y el constructor a la hora...

Temas, colores, tipografías y formas en Jetpack Compose

Temas, colores, tipografías y formas en Jetpack Compose

Si vienes del sistema clásico de vistas, recordarás que toda la definición de temas se hacía de una forma bastante tediosa a través de styles en XML. Si odias tu vida (o estás en una App donde mezclas XMLs y Jetpack Compose), aún puedes seguir usando esos temas...

Usando Cards de Material Design en Jetpack Compose

Usando Cards de Material Design en Jetpack Compose

Tenemos ya una App de lo más resultona después de todos los artículos que hemos visto hasta ahora. Puedes encontrarlos todos bien ordenaditos de forma gratuita en el área privada de Compose Expert. https://youtu.be/iZiXpWRIl3U Pero antes de finalizar nuestro camino,...

0 comentarios

Enviar un comentario

Los datos personales que proporciones a través de este formulario quedarán registrados en un fichero de Antonio Leiva Gordillo, 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.

Acepto la política de privacidad *