上一篇介绍了一种新的监听 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());
}
}
});
}
}
这样写的缺点是:“对扩展不开放”。当需求变化时,比如表项新增一个带点击事件的子控件,就必须修改OnItemClickListener
和MyViewHolder
。
还有一个缺点是:“增加表项视图层级”,如果要为下图中整个绿色区域设置点击事件(聊天和人数中间空白区域也得响应点击事件),就不得不把聊天室和人数用一个父控件包起来,然后为父控件设置点击事件。
更致命的是这个方案存在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 系列文章目录如下:
- RecyclerView 缓存机制 | 如何复用表项?
- RecyclerView 缓存机制 | 回收些什么?
- RecyclerView 缓存机制 | 回收到哪去?
- RecyclerView缓存机制 | scrap view 的生命周期
- 读源码长知识 | 更好的RecyclerView点击监听器
- 代理模式应用 | 每当为 RecyclerView 新增类型时就很抓狂
- 更好的 RecyclerView 表项子控件点击监听器
- 更高效地刷新 RecyclerView | DiffUtil二次封装
- 换一个思路,超简单的RecyclerView预加载
- RecyclerView 动画原理 | 换个姿势看源码(pre-layout)
- RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系
- RecyclerView 动画原理 | 如何存储并应用动画属性值?
- RecyclerView 面试题 | 列表滚动时,表项是如何被填充或回收的?
- RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?
- RecyclerView 性能优化 | 把加载表项耗时减半 (一)
- RecyclerView 性能优化 | 把加载表项耗时减半 (二)
- RecyclerView 性能优化 | 把加载表项耗时减半 (三)
- RecyclerView 的滚动是怎么实现的?(一)| 解锁阅读源码新姿势
- RecyclerView 的滚动时怎么实现的?(二)| Fling
- RecyclerView 刷新列表数据的 notifyDataSetChanged() 为什么是昂贵的?