更好的 RecyclerView 表项子控件点击监听器

5,594 阅读6分钟

上一篇介绍了一种新的监听 RecyclerView 表项点击事件的方法,即判断触点坐标是否落在表项矩形区域内。实现了将点击事件和RecyclerView.Adapter解耦。

这一篇把该问题再往前推进一步,如果要监听 RecyclerView 表项的子控件点击事件怎么办?

层层传递点击事件回调

该方案是将点击事件的响应逻辑封装在接口中,业务层实现接口,接口实例途径RecyclerView.Adapter到达ViewHolder,最终在ViewHolder中完成点击事件回调:

//'定义点击事件回调接口'
public interface OnItemClickListener {
    void onContentClick(position: Int);
    void onTitleClick(position: Int)
}

Adapter持有回调:

public class MyAdapter extends RecyclerView.Adapter<MyViewHolder> {
    //'持有接口'
    private OnItemClickListener onItemClickListener;
    
    //'传递接口'
    public void setOnItemClickListener(OnItemClickListener onItemClickListener) {
        this.onItemClickListener = onItemClickListener;
    }

    //'继续将接口传递给ViewHolder'
    @Override
    public void onBindViewHolder(MyViewHolder holder, int position) {
        holder.bind(onItemClickListener);
    }
}

然后就能在ViewHolder中回调接口:

public class MyViewHolder extends RecyclerView.ViewHolder {
	private TextView tvContent;
    private TextView tvTitle;
    
    public MyViewHolder(View itemView) {
        super(itemView);
        tvContent = itemView.findViewById(R.id.tvContent);
        tvTitle = itemView.findViewById(R.id.tvTitle);
    }

    public void bind(final OnItemClickListener onItemClickListener){
        //'为tvContent设置点击事件'
        tvContent.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if (onItemClickListener != null) {
                    onItemClickListener.onContentClick(getAdapterPosition());
                }
            }
        });
        //'为tvTitle设置点击事件'
        tvTitle.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if (onItemClickListener != null) {
                    onItemClickListener.onTitleClick(getAdapterPosition());
                }
            }
        });
    }
}

这样写的缺点是:“对扩展不开放”。当需求变化时,比如表项新增一个带点击事件的子控件,就必须修改OnItemClickListenerMyViewHolder

还有一个缺点是:“增加表项视图层级”,如果要为下图中整个绿色区域设置点击事件(聊天和人数中间空白区域也得响应点击事件),就不得不把聊天室和人数用一个父控件包起来,然后为父控件设置点击事件。

更致命的是这个方案存在bug,因为“快照机制”,作为参数传入onItemClick()的索引值是在调用onBindViewHolder()那一刻生成的快照,如果数据发生增删,但因为各种原因没有及时刷新对应位置的视图(onBindViewHolder()没有被再次调用),此时发生的点击事件拿到的索引就是错的。

更灵活的表项子控件点击监听器

上一篇已经为RecyclerView扩展了一个方法用于监听单个表项的点击事件:

//'为 RecyclerView 扩展表项点击监听器'
fun RecyclerView.setOnItemClickListener(listener: (View, Int) -> Unit) {
    //'为 RecyclerView 子控件设置触摸监听器'
    addOnItemTouchListener(object : RecyclerView.OnItemTouchListener {
        //'构造手势探测器,用于解析单击事件'
        val gestureDetector = GestureDetector(context, object : GestureDetector.OnGestureListener {
            override fun onShowPress(e: MotionEvent?) {
            }

            override fun onSingleTapUp(e: MotionEvent?): Boolean {
                //'当单击事件发生时,寻找单击坐标下的子控件,并回调监听器'
                e?.let {
                    findChildViewUnder(it.x, it.y)?.let { child ->
                        listener(child, getChildAdapterPosition(child))
                    }
                }
                return false
            }

            override fun onDown(e: MotionEvent?): Boolean {
                return false
            }

            override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
                return false
            }

            override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
                return false
            }

            override fun onLongPress(e: MotionEvent?) {
            }
        })

        override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {

        }

        //'在拦截触摸事件时,解析触摸事件'
        override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
            gestureDetector.onTouchEvent(e)
            return false
        }

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

然后就可以像这样监听 RecyclerView 表项点击事件了:

recyclerView.setOnItemClickListener { view, pos ->
	// view 是表项根视图,pos是表项在adapter中的位置
}

RecyclerView表项点击监听器的思路是“判断点击坐标是否落在表项矩形区域内”。是不是可以将同样的思路沿用到“表项子控件”的点击事件?

监听表项点击事件时得到的触点坐标是相对于RecyclerView坐标系原点(RecyclerView 左上角)的坐标,而表项矩形区域也是在基于该坐标系。它们在同一坐标系中,所以才能比较。

如果将触点所在的RecyclerView坐标系转换成“表项坐标系”,得到的触点坐标就和表项子控件在同一坐标系中,就能判断触点是否落在子控件矩形区域内。将上述代码稍作修改:

fun RecyclerView.setOnItemClickListener(listener: (View, Int, Float, Float) -> Unit) {
    addOnItemTouchListener(object : RecyclerView.OnItemTouchListener {
        val gestureDetector = GestureDetector(context, object : GestureDetector.OnGestureListener {
            override fun onShowPress(e: MotionEvent?) {
            }

            override fun onSingleTapUp(e: MotionEvent?): Boolean {
                e?.let {
                    findChildViewUnder(it.x, it.y)?.let { child ->
                    	// 计算相对于表项左上角的触点横坐标
                        val x = it.x - child.left
                        // 计算相对于表项左上角的触点纵坐标坐标
                        val y = it.y - child.top
                        // 将表项坐标系中的触点坐标与点击事件一并传递出去
                        listener(child, getChildAdapterPosition(child), x, y)
                    }
                }
                return false
            }

            override fun onDown(e: MotionEvent?): Boolean {
                return false
            }

            override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
                return false
            }

            override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
                return false
            }

            override fun onLongPress(e: MotionEvent?) {
            }
        })

        override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {

        }

        override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
            gestureDetector.onTouchEvent(e)
            return false
        }

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

然后就可以这样监听 RecyclerView 表项中子控件的点击事件了:

recyclerView?.setOnItemClickListener { view, i, x, y ->
    view.onChildViewClick("tvChatroom", "tvCount", x = x, y = y) {
    	// 表项子控件tvChatroom和tvCount组成的并集矩形区域被点击
        return@setOnItemClickListener
    }
}

其中onChildViewClick()View的扩展方法,用于检测输入坐标是否落在指定子控件内:

inline fun View.onChildViewClick(
    vararg layoutId: String, // View的子控件Id(若输入多个则表示多个控件所组成的并集矩形区域)
    x: Float, // 触点横坐标
    y: Float,// 触点纵坐标
    clickAction: ((View?) -> Unit) // 子控件点击响应事件
) {
    var clickedView: View? = null
    // 遍历所有子控件id
    layoutId
        .map { id ->
        	// 根据id查找出子控件实例
            find<View>(id)?.let { view ->
            	// 获取子控件相对于父控件的矩形区域
                view.getRelativeRectTo(this).also { rect ->
                	// 如果矩形区域包含触点则表示子控件被点击(记录被点击的子控件)
                    if (rect.contains(x.toInt(), y.toInt())) {
                        clickedView = view
                    }
                }
            } ?: Rect()
        }
        // 将所有子控件矩形区域做并集
        .fold(Rect()) { init, rect -> init.apply { union(rect) } }
        // 如果并集中包含触摸点,则表示并集所对应的大矩形区域被点击
        .takeIf { it.contains(x.toInt(), y.toInt()) }
        ?.let { clickAction.invoke(clickedView) }
}

其中getRelativeRectTo()View的扩展方法,它用于计算一个 View 相对于另一个 View 的位置。本例用于计算表项子控件相对于表项的位置。


fun View.getRelativeRectTo(otherView: View): Rect {
    val parentRect = Rect().also { otherView.getGlobalVisibleRect(it) }
    val childRect = Rect().also { getGlobalVisibleRect(it) }
    // 将 2个 Rect 做相对运算后返回一个新的 Rect
    return childRect.relativeTo(parentRect)
}

// Rect 相对运算(可以理解为将坐标原点进行平移)
fun Rect.relativeTo(otherRect: Rect): Rect {
    val relativeLeft = left - otherRect.left
    val relativeTop = top - otherRect.top
    val relativeRight = relativeLeft + right - left
    val relativeBottom = relativeTop + bottom - top
    return Rect(relativeLeft, relativeTop, relativeRight, relativeBottom)
}

Talk is cheap, show me the code

预告

下一篇会介绍如何更高效地刷新列表

推荐阅读

RecyclerView 系列文章目录如下:

  1. RecyclerView 缓存机制 | 如何复用表项?

  2. RecyclerView 缓存机制 | 回收些什么?

  3. RecyclerView 缓存机制 | 回收到哪去?

  4. RecyclerView缓存机制 | scrap view 的生命周期

  5. 读源码长知识 | 更好的RecyclerView点击监听器

  6. 代理模式应用 | 每当为 RecyclerView 新增类型时就很抓狂

  7. 更好的 RecyclerView 表项子控件点击监听器

  8. 更高效地刷新 RecyclerView | DiffUtil二次封装

  9. 换一个思路,超简单的RecyclerView预加载

  10. RecyclerView 动画原理 | 换个姿势看源码(pre-layout)

  11. RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系

  12. RecyclerView 动画原理 | 如何存储并应用动画属性值?

  13. RecyclerView 面试题 | 列表滚动时,表项是如何被填充或回收的?

  14. RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?

  15. RecyclerView 性能优化 | 把加载表项耗时减半 (一)

  16. RecyclerView 性能优化 | 把加载表项耗时减半 (二)

  17. RecyclerView 性能优化 | 把加载表项耗时减半 (三)

  18. RecyclerView 的滚动是怎么实现的?(一)| 解锁阅读源码新姿势

  19. RecyclerView 的滚动时怎么实现的?(二)| Fling

  20. RecyclerView 刷新列表数据的 notifyDataSetChanged() 为什么是昂贵的?