OnClick en RecyclerView

Este es un artículo bastante antiguo. Ahora, y más con Compose, la forma de abordar este tema es muy diferente. Lo dejo porque me costó bastante hacerlo en su momento, disfruté buscando alternativas, y puse en práctica varias de las que aquí reflejo en proyectos reales. En definitiva le tengo cariño. Estaba empezando a programar cosas serias por entonces como quien dice.

Hace unas semanas (posiblemente ya meses) publicaba Antonio Leiva en su blog un artículo sobre cómo él implementa en los ítems de los RecyclerView los manejadores de eventos, en concreto el más típico y útil, el OnItemClickListener. Es algo que casi cualquier aplicación de Android requiere al no ser una funcionalidad que venga de fábrica. Como en su momento tuve que lidiar con ello, y como además encontré y/o reinventé la rueda un par de veces, voy a hablar un poquito sobre las cinco formas (en realidad cuatro más una) que he probado, sus pros y contras.

Todo lo que necesitas saber de kotlin y jamás te atreviste a preguntar

El código que uso en los ejemplos está extraido de este repositorio de GitHub. Podéis ver que el lenguaje usado no es Java, sino Kotlin. Si queréis aprender, nada mejor que la referencia que encontraréis en su página o en el blog que tienen los desarrolladores con las novedades que van incluyendo en el lenguaje (que en el momento de escribir estas líneas por fin ha alcanzado la versión 1.0.0). De todas formas voy a dar cuatro pinceladas rápidas, aunque el código debería ser (casi) completamente transparente para cualquier persona que sepa Java.

Definición de variables Podemos hacer que cualquier variable que se defina sea inmutable (bonito oxímoron) usando el modificador val, por ejemplo val a = 5. Para trabajar con variables de verdad, simplemente optamos por var. Como se puede ver tiene inferencia de tipos (en tiempo de compilación, seguimos trabajando con la JVM).

Definición de funciones Las funciones y métodos se definen con fun:

fun factorial(n: Int) : Long {
    if (n < 2) return 1
    else       return n * factorial(n-1)
}

El tipo que devuelve la función se especifica de la forma fun foo() : Object.

Tiene recursión por cola empleando el modificador tailRec:

tailRec fun factorial(n: Int) : Long {
    fun inner(val n: Int, val acc: Long) : Long {
        if (n < 2) return acc
        else       return factorial(n-1, acc*n)
    }
    return inner(n, 1)
}

Las funciones son ciudadanos de primera clase. Como podéis ver pueden anidarse y tampoco necesitan estar dentro de un objeto para poder existir (en Kotlin todo es un objeto). Por lo tanto si necesitamos crear la típica clase llena de métodos estáticos, lo que haremos aquí será crear un archivo con dichas funciones (como haríamos en Python).

Clases y similares Tenemos clases abstractas e interfaces como en Java.

El denominado Constructor primario se explicita en la propia definición de la clase:

class GithubAccount(val user: String) {}

Esto es equivalente en Java a:

class GithubAccount {
    private final String user;
    public GithubAccount(String user) {
        this.user = user;
    }
}

Puede haber más constructores a parte del primario.

Toda clase es no heredable salvo que lo declaremos explícitamente con open. Lo mismo pasa con sus métodos. Podemos definir clases especificas para almacenar datos con las data class. Perfectas cuando parseamos desde un Json o similares:

data class Github(val login: String, val blog: String, val repos: Int)

De los métodos getter y setter se encarga el compilador, salvo en este caso en que hemos definido los parámetros con val.

No hay clases estáticas, pero en cambio tenemos el patrón Singleton implementado en la librería estándar (object), que no es lo mismo pero nos servirá para casi todo lo que usásemos aquéllas. Si necesitamos métodos estáticos dentro de una clase podemos crear un object interno y referirnos a sus métodos. No me gusta especialmente pero no está de más saberlo.

En las nuevas versiones han implementado las Sealed Class, si nadie me dice lo contrario equivalentes más o menos a las Case Class de Scala (disculpas sinceras si acabo de decir una burrada). Su uso es muy específico para, cuando hacemos Pattern Matching, no tener que contemplar el caso else, al restringirse las comparaciones a las suclases de la Sealed Class. De la documentación de Kotlin:

sealed class Expr {
    class Const(val number: Double) : Expr()
    class Sum(val e1: Expr, val e2: Expr) : Expr()
    object NotANumber : Expr()
}
fun eval(expr: Expr): Double = when(expr) {
    is Const -> expr.number
    is Sum -> eval(expr.e1) + eval(expr.e2)
    NotANumber -> Double.NaN
    // the `else` clause is not required because we've covered all the cases
}

Con esto será suficiente para entender el ejemplo. Os animo a bucear en la documentación del lenguaje, es muy esquemática, pero también muy clara y va directa al grano.

Todos los Adapter del ejemplo herederán del siguiente:

abstract class DoListAdapter(var items: List<Item>?) : RecyclerView.Adapter<DoListAdapter.ViewHolder>() {
    override fun getItemCount() = items?.size ?: 0

    open inner class ViewHolder(val item: View?) : RecyclerView.ViewHolder(item) {
        val title : TextView by bindView(R.id.txv_title)
        val date : TextView by bindView(R.id.txv_date)
        val itemLayout : LinearLayout by bindView(R.id.item_layout)

        open fun bindItem(it: Item) {
            title.text = it.title
            date.text = it.date
        }
    }
}

Opción 1

La más sencilla y obvia, añadimos dentro del RecyclerView.Adapter los correspondientes Listeners, en el método onBindViewHolder():

open class AdapterModo1(items: List<Item>?) : DoListAdapter(items) {
    override fun onCreateViewHolder(p0: ViewGroup?, p1: Int): ViewHolder? {
        return ViewHolder(p0!!.context.layoutInflater.inflate(R.layout.item, p0, false))
    }

    override fun onBindViewHolder(p0: ViewHolder?, p1: Int) {
        p0!!.itemLayout.setOnClickListener { view -> view.context.toast("Click Modo 1")}
        p0!!.itemLayout.setOnLongClickListener { it.context.toast("Long Click Modo 1"); true }
        p0?.bindItem(items!!.get(p1))
    }
}

El principal inconveniente también es bastante claro. Creamos una cantidad absurda de objetos que el recolector de basura tendrá que estar continuamente eliminando. Cada vez que mostremos un ítem nuevo se hará la conexión con su ViewHolder, y cada vez crearemos un nuevo Listener. Pensad que:

p0!!.itemLayout.setOnClickListener { view -> view.context.toast("Click Modo 1")}

es equivalente a (más o menos, lo hago de memoria, y debido a mi patológica dispersión mental pocas cosas me sé al dedillo):

p0.itemLayout.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClickListener(View view) {
        Context context = view.getContext()
        context.Toast("Click Modo 1", Toast.LENGHT_SHORT).show();
    }
})

Opción 1a

Una primera mejora es evitar la creación de tantos objetos anónimos empleando una única instancia usando la característica ya comentada de Kotlin:

class AdapterModo1a(items: List<Item>?) : AdapterModo1(items) {
    override fun onBindViewHolder(p0: ViewHolder?, p1: Int) {
        p0!!.itemLayout.setOnClickListener(ClickListener)
        p0!!.itemLayout.setOnLongClickListener(LongClickListener)
        p0?.bindItem(items!!.get(p1))
    }
}

object ClickListener : View.OnClickListener {
    override fun onClick(p0: View?) {
        p0!!.context.toast("Click Modo 1a")
    }
}

object LongClickListener : View.OnLongClickListener {
    override fun onLongClick(p0: View?): Boolean {
        p0!!.context.toast("Long Click Modo 1a")
        return true
    }
}

Opción 2

Otra opción mejor y más sencilla consiste en implementar el Listener en el propio ViewHolder:

class AdapterModo2(items: List<Item>?) : DoListAdapter(items) {
    override fun onCreateViewHolder(p0: ViewGroup?, p1: Int): ViewHolder? {
        return ViewHolderModo2(p0!!.context.layoutInflater.inflate(R.layout.item, p0, false))
    }

    override fun onBindViewHolder(p0: ViewHolder?, p1: Int) {
        p0?.bindItem(items!!.get(p1))
    }

    inner class ViewHolderModo2(item: View?) : ViewHolder(item), View.OnClickListener, View.OnLongClickListener {
        init {
            item?.setOnClickListener(this)
            item?.setOnLongClickListener(this)
        }

        override fun bindItem(it: Item) {
            title.text = it.title
            date.text = it.date
        }

        override fun onClick(p0: View?) {
            p0!!.context.toast("Click Modo 2")
        }

        override fun onLongClick(p0: View?): Boolean {
            p0!!.context.toast("Long Click Modo 2")
            return true
        }
    }
}

El inconveniente, al igual que pasaba con las otras dos, es que estamos acoplando funcionalidades, es decir, no conseguimos separar bien negocio (el código que se lance tras la ocurrencia del evento) del modelo y la vista. Tampoco es algo que deba preocuparnos en exceso, puesto que el propio RecyclerView nos obliga a mezclar estos dos. Lo único que hacemos es echar un poquito más de leña al fuego.

En el artículo referido al inicio del post se explica una implementación muy similar a esta. Lo que se hace allí es pasar el listener desde el Fragment o Activity al Adapter. Conseguimos desacoplar algo el código, pero tampoco es una gran mejora puesto que al final su ejecución sigue ocurriendo en el mismo lugar.

Opción 3

Junto a la anterior es la que más uso. Lo primero decir que es una variación de un código que encontré perdido en la web. Digo perdido porque no tengo el enlace original. Disculpad, es algo que me molesta especialmente, el no dar referencias de dónde se encuentra en origen la información.

Es una variación de aquél código perdido puesto que para conseguir que me funcionara bien tuve que separar el control del onItemClick() y del onLongItemClick(), de forma que uno lo hago a partir de un GestureDetector y el otro a partir de uno de los métodos proporcionados por la interfaz RecyclerView.OnItemTouchListener.

Básicamente en ambos métodos lo que hacemos es obtener las coordenadas del punto de la pantalla donde se produce el contacto, y a partir de ahí obtener el View clicado y su posición dentro del RecylerView. Es muy sencillo, es un poco mezclar varias cosas (en origen no era así, lo hice en su momento para que me funcionase correctamente) pero me ha funcionado siempre realmente bien.

Al igual que en la forma que se explica al final, conseguimos separar completamente el control del evento del RecyclerView, quedando el Adapter extremadamente simple (es el mismo para ambos modos):

class AdapterModo3_4(items: List<Item>?) : DoListAdapter(items) {
    override fun onCreateViewHolder(p0: ViewGroup?, p1: Int): ViewHolder? {
        return ViewHolder(p0!!.context.layoutInflater.inflate(R.layout.item, p0, false))
    }

    override fun onBindViewHolder(p0: ViewHolder?, p1: Int) {
        p0?.bindItem(items!!.get(p1))
    }
}

En esta opción (y en la siguiente) implementamos un Listener el cual se lo pasamos al RecyclerView. Además en este caso lo que hacemos es pasarle a éste otro Listener que implementarán la interface:

interface OnItemClickListener {
    fun onItemClick(view: View, pos: Int)
    fun onLongItemClick(view: View, pos: Int)
}

y en el cual delegaremos la ejecución del código. El código del listener en este caso es:

open class ListenerModo3(val context: Context, val listener: OnItemClickListener) : RecyclerView.OnItemTouchListener {
    var recycler: RecyclerView by Delegates.notNull()
    val gestureDetector: GestureDetector by lazy {
        GestureDetector(context, object: GestureDetector.SimpleOnGestureListener() {
            override fun onSingleTapUp(e: MotionEvent?): Boolean {
                return true
            }

            override fun onLongPress(e: MotionEvent?) {
                val view = recycler.findChildViewUnder(e!!.x, e.y)
                if (view != null)
                    listener.onLongItemClick(view, recycler.getChildLayoutPosition(view))
            }
        })
    }

    override fun onTouchEvent(rv: RecyclerView?, e: MotionEvent?) {
        throw UnsupportedOperationException()
    }

    override fun onInterceptTouchEvent(rv: RecyclerView?, e: MotionEvent?): Boolean {
        recycler = rv!!
        val item = rv!!.findChildViewUnder(e!!.x, e.y)
        if (item != null && gestureDetector.onTouchEvent(e))
            listener.onItemClick(item, rv.getChildLayoutPosition(item))

        return false
    }

    override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
        throw UnsupportedOperationException()
    }
}

Y solo nos quedará pasarlo al RecyclerView desde el Fragment:

recycler_view.addOnItemTouchListener(
    object: ListenerModo3(this.activity, object : OnItemClickListener {
        override fun onItemClick(view: View, pos: Int) {
            this@FragmentItems.activity.toast("Click Modo 3")
        }

        override fun onLongItemClick(view: View, pos: Int) {
            this@FragmentItems.activity.toast("Long Click Modo 3")
        }
    }){}
)

Opción 4

La opción más compleja, y a priori también la mejor. El problema es que nunca he conseguido que me funcione correctamente: en ocasiones hasta que la lista de ítems no se mueve en la pantalla no me reconoce los toques sobre la misma (la mayoría de las veces), y en ocasiones es aún peor (como en el ejemplo que os pongo) y sólo me reconoce el primer y último ítem. Nunca he investigado lo suficiente para saber cuál es el porqué. Fallo mío. La solución está en el blog de Hugo Visser. Os animo a que os metáis allí y leáis las explicaciones que el autor da para hacerla así y no de otra forma, siempre mucho mejores que lo que yo pueda explicar. Si queréis ver el código en Kotlin, en el proyecto en GitHub lo tenéis.