实现一个通用的 RecyclerView 点击监听器

490 阅读5分钟

给 RecyclerView 设置点击监听器的常用方式有两种:第一种就是在 Adapter 里给 View 设置 OnClickListener,这种方式会在内存里生成多个 OnClickListener,绑定过程也稍显繁琐;第二种就是实现 OnItemTouchListener 接口,统一处理点击事件,这种方式对于条目的点击处理非常方便,但是对于一个 ItemView 有多个可点击控件的情况需要做些额外的处理。本文使用第二种方式实现并试图解决一个 ItemView 有多个控件需要监听点击的情况。

ItemView 的点击监听

实现思路就是向 RecyclerView 添加 OnItemTouchListener 以监听触摸事件,然后使用 RecyclerView.findChildViewUnder(x, y) 函数获取被点击的 ItemView,再使用 getChildLayoutPosition(view) 获取 position(position 是为了方便业务层获取对应位置的数据),整个功能就实现了。下面我们一步一步来:

  1. 实现 OnItemTouchListener 接口,涉及的方法就一个 onInterceptTouchEvent,先看文档对该方法的描述:

image.png
翻译过来的意思就是先于 RecyclerView 和它的子 View 监听或者接管发给 RecyclerView 的 MotionEvent,该函数的返回值是个 boolean,和我们常用的 View 的 onTouch 族函数的返回值一个含义,所以在我们的需求里,这里应该始终返回 false,否则会引发 RecyclerView 各种滑动问题

override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
    if (disallowIntercept) {
        // 不允许拦截触摸事件,直接返回
        return false
    }
    if (itemClickListener == null) {
        // 没有设置点击监听器,无需处理
        return false
    }
    // 判断触摸位置是否有有效的 ItemView
    val childView = rv.findChildViewUnder(e.x, e.y) ?: return false
    if (rv.getChildLayoutPosition(childView) == NO_POSITION) {
        return false
    }
    // 我们不关心手势识别器的返回结果,应该始终返回 false,否则会引发 RecyclerView 各种滑动问题
    gestureDetector.onTouchEvent(e)
    return false
}

override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
    // 当 onInterceptTouchEvent 返回 True 时,后续事件都会交由该函数处理,
    // RecyclerView 就没有机会接收后续事件了,从而引发各种滑动问题
}

override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
    // 保存是否允许拦截触摸事件配置
    this.disallowIntercept = disallowIntercept
}
  1. 使用 GestureDetector 识别手势,逻辑很简单就直接贴代码了:
private val gestureDetector = GestureDetector(recyclerView.context, object : SimpleOnGestureListener() {
    override fun onSingleTapUp(e: MotionEvent): Boolean {
        // 单击事件
        if (itemClickListener == null) {
            return false
        }
        return performClick(e)
    }
})

private fun performClick(e: MotionEvent): Boolean {
    // 查询 ItemView 并检验合法性
    val itemView = recyclerView.findChildViewUnder(e.x, e.y) ?: return false
    val itemPosition = recyclerView.getChildLayoutPosition(itemView)
    if (itemPosition == NO_POSITION) {
        return false
    }
    // 回调点击事件监听器
    val itemViewId = itemView.id
    itemClickListener?.onItemClick(itemView, itemViewId, itemPosition) ?: return false
    return true
}

ItemView 多个控件的点击监听

经过上面的步骤,我们已经成功识别出具体点击的是哪个 ItemView 了,如果 ItemView 里有多个控件的点击事件需要监听,我们需要两个信息:一是哪些控件需要监听,这个我们肯定是知道的;二是我们点击的位置是否在这些控件上。由于控件之间存在层级关系,所以我们真正需要的是在点击位置是否有在监听列表里且位于最顶层的 View,若有就是该 View 被点击了,若没有就是整个 ItemView 被点击了,需求就演变成了查找目标 View。

private fun performClick(e: MotionEvent): Boolean {
    // 查询 ItemView 并检验合法性
    val itemView = recyclerView.findChildViewUnder(e.x, e.y) ?: return false
    val itemPosition = recyclerView.getChildLayoutPosition(itemView)
    if (itemPosition == NO_POSITION) {
        return false
    }
    // clickableChildren 是要监听的 View 的 ID 列表
    if (clickableChildren.isNotEmpty() && itemView is ViewGroup) {
        // 查找最上层的目标 View
        val targetView = findTargetView(e, itemView)
        if (targetView != null) {
            itemClickListener?.onItemClick(targetView, targetView.id, itemPosition) ?: return false
            return true
        }
    }
    val itemViewId = itemView.id
    itemClickListener?.onItemClick(itemView, itemViewId, itemPosition) ?: return false
    return true
}

/** 递归查找最上层的目标 View */
private fun findTargetView(e: MotionEvent, group: ViewGroup): View? {
    group.children.forEach { childView ->
        if (!childView.isTouchIn(e)) {
            return@forEach
        }
        if (childView is ViewGroup) {
            // 如果是 ViewGroup 继续向等层查找
            val targetView = findTargetView(e, childView)
            if (targetView == null) {
                return@forEach
            } else {
                return targetView
            }
        } else if (clickableChildren.contains(childView.id)) {
            return childView
        }
    }
    return if (clickableChildren.contains(group.id)) {
        group
    } else {
        null
    }
}

// 判断点击点是否在 View 内部
internal fun View.isTouchIn(e: MotionEvent): Boolean {
    val location = IntArray(2)
    getLocationOnScreen(location)
    val left = location[0].toFloat()
    val top = location[1].toFloat()
    val right = left + measuredWidth
    val bottom = top + measuredHeight
    return e.rawX in left..right && e.rawY in top..bottom
}

Double Click

  • 单击改动

GestureDetector 回调里和单击事件关联的函数有两个:一个是 onSingleTapUp,是在触发单击的 ACTION_UP 事件发生时立马调用;另一个是 onSingleTapConfirmed,是在确认不会发生双击事件时调用的,显然该函数的调用是迟于 onSingleTapUp。所以在同时监听单击和双击事件时,单击事件的处理需要移至 onSingleTapConfirmed 函数。

override fun onSingleTapUp(e: MotionEvent): Boolean {
    if (itemClickListener == null) {
        return false
    }
    if (itemDoubleClickListener != null) {
        // 设置了双击监听,需要在 onSingleTapConfirmed 函数里处理单击
        return false
    }
    return performClick(e)
}

override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
    if (itemClickListener == null || itemDoubleClickListener == null) {
        return false
    }
    return performClick(e)
}
  • 双击

GestureDetector 回调里和双击事件关联的函数也有两个:一个是 onDoubleTap,是在触发双击的 ACTION_DOWN 事件发生时调用,一次双击只调用一次;另一个是 onDoubleTapEvent,会在触发双击的 ACTION_DOWN、ACTION_UP、ACTION_MOVE 事件发生时调用,一次双击会调用多次,大家使用时注意选择。

override fun onDoubleTap(e: MotionEvent): Boolean {
    if (itemDoubleClickListener == null) {
        return false
    }
    return performDoubleClick(e)
}

private fun performDoubleClick(e: MotionEvent): Boolean {
    // 查询 ItemView 并检验合法性
    val itemView = recyclerView.findChildViewUnder(e.x, e.y) ?: return false
    val itemPosition = recyclerView.getChildLayoutPosition(itemView)
    if (itemPosition == NO_POSITION) {
        return false
    }
    if (clickableChildren.isNotEmpty() && itemView is ViewGroup) {
        // 查找最上层的目标 View
        val targetView = findTargetView(e, itemView)
        if (targetView != null) {
            itemDoubleClickListener?.onItemDoubleClick(targetView, targetView.id, itemPosition)
                ?: return false
            return true
        }
    }
    val itemViewId = itemView.id
    itemDoubleClickListener?.onItemDoubleClick(itemView, itemViewId, itemPosition)
        ?: return false
    return true
}

Long Click

override fun onLongPress(e: MotionEvent) {
    if (itemLongClickListener == null) {
        return
    }
    val childView = recyclerView.findChildViewUnder(e.x, e.y) ?: return
    val position = recyclerView.getChildLayoutPosition(childView)
    if (position == NO_POSITION) {
        return
    }
    performLongClick(e, childView, position)
}

private fun performLongClick(e: MotionEvent, itemView: View, itemPosition: Int) {
    if (clickableChildren.isNotEmpty() && itemView is ViewGroup) {
        // 查找最上层的目标 View
        val targetView = findTargetView(e, itemView)
        if (targetView != null) {
            itemLongClickListener?.onItemLongClick(targetView, targetView.id, itemPosition)
            return
        }
    }
    val itemViewId = itemView.id
    itemLongClickListener?.onItemLongClick(itemView, itemViewId, itemPosition)
}

简单点

为了方便使用,我们给 RecyclerView 添加几个扩展函数:

/** Setting up multiple click listeners */
fun RecyclerView.setOnItemClickListener(
    itemClickListener: OnItemClickListener?,
    itemLongClickListener: OnItemLongClickListener?,
    itemDoubleClickListener: OnItemDoubleClickListener?,
    clickableChildren: List<Int> = emptyList()
) {
    addOnItemTouchListener(
        ItemClickHandler(
            this,
            itemClickListener,
            itemLongClickListener,
            itemDoubleClickListener,
            clickableChildren
        )
    )
}

/** Setting up a click listener */
fun RecyclerView.setOnItemClickListener(
    itemClickListener: OnItemClickListener,
    clickableChildren: List<Int> = emptyList()
) {
    setOnItemClickListener(itemClickListener, null, null, clickableChildren)
}

/** Setting up a long click listener */
fun RecyclerView.setOnItemLongClickListener(
    itemLongClickListener: OnItemLongClickListener,
    clickableChildren: List<Int> = emptyList()
) {
    setOnItemClickListener(null, itemLongClickListener, null, clickableChildren)
}

/** Setting up a double click listener */
fun RecyclerView.setOnItemDoubleClickListener(
    itemDoubleClickListener: OnItemDoubleClickListener?,
    clickableChildren: List<Int> = emptyList()
) {
    setOnItemClickListener(null, null, itemDoubleClickListener, clickableChildren)
}

真正需要的

github.com/lenebf/Recy…