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

简介: 上篇介绍了一种新的监听 RecyclerView 表项点击事件的方法。实现了将点击事件和RecyclerView.Adapter解耦。这一篇介绍如何监听 RecyclerView 表项子控件点击事件。

上一篇介绍了一种新的监听 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() 为什么是昂贵的?
目录
相关文章
|
11月前
|
安全 算法 Java
Java“SSLException”错误解决
Java“SSLException”错误通常发生在SSL/TLS连接过程中,可能是由于证书问题、握手失败或加密套件不匹配等原因引起。解决方法包括检查服务器证书、配置信任库、确保JDK版本兼容等。
1907 4
|
11月前
|
自然语言处理 IDE Linux
就3步,用通义灵码写一个数字华容道小游戏
Hey,小伙伴!你是不是总是下定了学习编程的决心,但又因为枯燥、困难打起了退堂鼓?今天让我们跟着通义灵码边玩边练,只需要简单的几句话,就可以打造一款经典的数字华容道小游戏,即使没有代码基础也能快速上手,也许在这个过程中,你不经意间就掌握了一些编程知识。让我们开始吧!
1327 41
|
算法 Java Linux
java制作海报二:java使用Graphics2D 在图片上合成另一个照片,并将照片切割成头像,头像切割成圆形方法详解
这篇文章介绍了如何使用Java的Graphics2D类在图片上合成另一个照片,并将照片切割成圆形头像的方法。
209 1
java制作海报二:java使用Graphics2D 在图片上合成另一个照片,并将照片切割成头像,头像切割成圆形方法详解
|
安全 前端开发 Java
微服务网关及其配置
微服务网关及其配置
513 12
|
消息中间件 存储 容灾
RabbitMQ的故障恢复与容灾策略
【8月更文第28天】RabbitMQ是一个开源的消息代理软件,它支持多种消息协议,如AMQP(Advanced Message Queuing Protocol)。在实际应用中,为了保证服务的连续性,需要实施一系列的故障恢复与容灾策略。
689 2
|
Java 数据库连接 mybatis
Mybatis openSession.commit()手动提交数据和openSession.commit(true)自动动提交数据
Mybatis openSession.commit()手动提交数据和openSession.commit(true)自动动提交数据
166 9
|
前端开发 JavaScript 算法
Github 最受欢迎的 35 个项目一览
Github 最受欢迎的 35 个项目一览
701 0
|
存储 Java 编译器
C 语言指针完全指南:创建、解除引用、指针与数组关系解析
创建指针 我们可以使用引用运算符 & 获取变量的内存地址:
496 0
|
资源调度
如何科学地预估工时?
PERT(Program Evaluation and Review Technique)即计划评审技术,最早是由美国海军在计划和控制北极星导弹的研制时发展起来的。PERT技术使原先估计的、研制北极星潜艇的时间缩短了两年。
如何科学地预估工时?