事件分发典型bug:RecycleView滑动嵌套问题解决
简介
现象
在工作中碰到了一个易用性的问题,当一个横向滑动的HorizonRecycleView(注意这里只是一个普通的加了日志打印的RecycleView,并没有改动其自身逻辑),每个Item都包含了一个纵向滑动的VerticalRecycleView(同上)时,若此时想去滑动纵向的VerticalRecycleView,很容易触发到HorizonRecycleView的横向滑动。可能说起来有点绕,直接看图可能更明显点。
代码
代码比较简单,A与B都使用的是LinearLayoutManager,这里展示一下他们item的layout文件
HorizonRecycleView的item
每个item左边是一个TextView,右边是一个VerticalRecycleView
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="400dp"
android:background="@android:color/holo_blue_light"
android:layout_marginEnd="20dp"
android:layout_height="match_parent">
<TextView
android:id="@+id/tv_title_horizon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Item"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/rv_vertical"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.kyrie.proj.blog.nestedscroll.VerticalRecycleView
android:id="@+id/rv_vertical"
android:layout_width="200dp"
android:layout_height="match_parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
VerticalRecycleView的item
只有一个TextView
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginBottom="20dp"
android:background="@android:color/holo_green_light">
<TextView
android:id="@+id/tv_title_vertical"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Item"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
问题分析
日志分析
我们把两个RecycleView的dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent都加上打印,来分别比较一下正常滑动VerticalRecycleView和误触发了HorizonRecycleView滑动的日志有什么区别
正常竖直滑动
//ACTION_DONW事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_DOWN
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_DOWN
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] return false //HorizonRecycleView不强制拦截
I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_DOWN
I/wzt: [VerticalRecycleView][onInterceptTouchEvent] e = = ACTION_DOWN
I/wzt: [VerticalRecycleView][onInterceptTouchEvent] return false //VerticalRecycleView不强制拦截
I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_DOWN
I/wzt: [VerticalRecycleView][onTouchEvent] return true //VerticalRecycleView消费此事件
I/wzt: [VerticalRecycleView][dispatchTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
//ACTION_DONW事件分发结束,被VerticalRecycleView消费
//ACTION_MOVE事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] return false
I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_MOVE
I/wzt: [VerticalRecycleView][onTouchEvent] return true //VerticalRecycleView消费此ACTION_MOVE事件
I/wzt: [VerticalRecycleView][dispatchTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
//ACTION_MOVE事件分发结束
//ACTION_MOVE事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_MOVE
//...
//上面省略N个MOVE事件分发
//ACTION_UP事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_UP
I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_UP
I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_UP
I/wzt: [VerticalRecycleView][onTouchEvent] return true
I/wzt: [VerticalRecycleView][dispatchTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
//事件分发流程结束
误触发了横向滑动
//ACTION_DONW事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_DOWN
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_DOWN
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] return false
I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_DOWN
I/wzt: [VerticalRecycleView][onInterceptTouchEvent] e = = ACTION_DOWN
I/wzt: [VerticalRecycleView][onInterceptTouchEvent] return false
I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_DOWN
I/wzt: [VerticalRecycleView][onTouchEvent] return true //VerticalRecycleView消费
I/wzt: [VerticalRecycleView][dispatchTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
//ACTION_DONW事件分发结束,流程与正常情况完全一致
//ACTION_MOVE事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] return false
I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_MOVE
I/wzt: [VerticalRecycleView][onTouchEvent] return true //VerticalRecycleView消费
//ACTION_MOVE事件分发结束
//ACTION_MOVE事件分发
//...
//上面省略了大概5个MOVE事件分发,都和正常竖直滑动时一致
//注1:注意注意注意啦!!!:从这里开始就是重头戏
//ACTION_MOVE事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] return true //注2:这里直接被HorizonRecycleView拦截
//事件被父控件拦截,导致VerticalRecycleView只能收到一个ACTION_CANCEL事件
I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_CANCEL
I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_CANCEL
I/wzt: [VerticalRecycleView][onTouchEvent] return true
I/wzt: [VerticalRecycleView][dispatchTouchEvent] return true
//VerticalRecycleView消费了ACTION_CANCEL事件之后,此次滑动序列再也没有收到任何事件
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
//之后的所有MOVE事件,不会再走onInterceptTouchEvent方法,直接交给HorizonRecycleView消费
I/wzt: [HorizonRecycleView][onTouchEvent] e = = ACTION_MOVE
I/wzt: [HorizonRecycleView][onTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
//ACTION_MOVE事件分发开始
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [HorizonRecycleView][onTouchEvent] e = = ACTION_MOVE
I/wzt: [HorizonRecycleView][onTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
//...
//省略N个MOVE事件分发
//ACTION_UP事件分发,与正常现象一致
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_UP
I/wzt: [HorizonRecycleView][onTouchEvent] e = = ACTION_UP
I/wzt: [HorizonRecycleView][onTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
日志分析总结
通过如上两个日志对比我们发现,出现问题的原因在于<注2>部分,HorizonRecycleView拦截了一次MOVE事件,导致VerticalRecycleView后续除了一个CANCEL外无法收到任何事件。
ACTION_CANCEL
这里稍微提一下我一直都没有理解的ACTION_CANCEL,从上面的日志我们就可以了解到ACTION_CANCEL出现的场景:当一个View在消费一个事件序列的过程中,父控件拦截了此次事件(父控件onInterceptTouchEvent返回true),这个View就会收到一个ACTION_CANCEL,并且View在此时进行内部状态的重置,如从常态恢复成点击态。并且此次事件序列的后续事件都会直接交给父控件处理。
原因
从日志分析可得横向滑动的误触发是由于HorizonRecycleView的事件拦截引起,那么直接到RecycleView源码里分析一下为何会在MOVE过程中拦截。注意下面的源码省略了非关键的部分
//RecycleView.java
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
final int action = e.getActionMasked();
switch (action) {
case MotionEvent.ACTION_DOWN: {
//在DOWN时记录手指点击的区域
//这里加0.5f的原因是为了转成int值时四舍五入
mInitialTouchX = (int) (e.getX() + 0.5f);
mInitialTouchY = (int) (e.getY() + 0.5f);
}
case MotionEvent.ACTION_MOVE: {
final int x = (int) (e.getX(index) + 0.5f);
final int y = (int) (e.getY(index) + 0.5f);
//当前不是拖动状态则进行判断
if (mScrollState != SCROLL_STATE_DRAGGING) {
//算出手指移动的距离
final int dx = x - mInitialTouchX;
final int dy = y - mInitialTouchY;
boolean startScroll = false;
//注1:能横向滚动并且手指移动的距离大于mTouchSlop
//这个mTouchSlop是在RecycleView初始化时确定的滑动临界值,大于这个值就从静止切换为滑动状态
if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
//这里标志位为true
startScroll = true;
}
//竖直方向,效果同上
if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
startScroll = true;
}
if (startScroll) {
//方法内部会把mScrollState置为SCROLL_STATE_DRAGGING
setScrollState(SCROLL_STATE_DRAGGING);
}
}
}
}
//若为SCROLL_STATE_DRAGGING状态则return true拦截事件
return mScrollState == SCROLL_STATE_DRAGGING;
}
从上面的源码<注1>可以看到,在MOVE事件中,若当前手指在HorizonRecycleView横向的滑动大于滑动临界值,则HorizonRecycleView 会直接不去判断其它任何条件置为滑动状态,直接拦截此事件。这就是问题根本原因所在了,HorizonRecycleView只是判断手指在x轴的移动距离超过了临界值就直接强行拦截后续事件。
解决方案
知道了问题原因,解决方案很明显就是如何让HorizonRecycleView不去拦截此次MOVE事件呢。有两种方法
- 重写HorizonRecycleView的onInterceptTouchEvent方法逻辑,修改判断切换滑动状态的部分
- 通过内部拦截法
方案1:重写HorizonRecycleView的onInterceptTouchEvent逻辑
方案来自于 修复RecyclerView嵌套滚动问题,在大佬基础上有少量简化
直接在BetterRecyclerView照着RecycleView源码重写onInterceptTouchEvent,用BetterRecyclerView代替HorizonRecycleView原本的位置即可
//BetterRecyclerView.java
public class BetterRecyclerView extends RecyclerView{
private static final int INVALID_POINTER = -1;
private int mScrollPointerId = INVALID_POINTER;
private int mInitialTouchX, mInitialTouchY;
private int mTouchSlop;
public BetterRecyclerView(Context context) {
this(context, null);
}
public BetterRecyclerView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public BetterRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
final ViewConfiguration vc = ViewConfiguration.get(getContext());
mTouchSlop = vc.getScaledTouchSlop();
}
@Override
public void setScrollingTouchSlop(int slopConstant) {
super.setScrollingTouchSlop(slopConstant);
final ViewConfiguration vc = ViewConfiguration.get(getContext());
switch (slopConstant) {
case TOUCH_SLOP_DEFAULT:
mTouchSlop = vc.getScaledTouchSlop();
break;
case TOUCH_SLOP_PAGING:
mTouchSlop = vc.getScaledPagingTouchSlop();
break;
default:
break;
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
LayoutManager mLayout = getLayoutManager();
if (mLayout == null) {
return false;
}
final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
final boolean canScrollVertically = mLayout.canScrollVertically();
final int action = e.getActionMasked();
final int actionIndex = e.getActionIndex();
switch (action) {
case MotionEvent.ACTION_DOWN:
mScrollPointerId = e.getPointerId(0);
mInitialTouchX = (int) (e.getX() + 0.5f);
mInitialTouchY = (int) (e.getY() + 0.5f);
return super.onInterceptTouchEvent(e);
case MotionEvent.ACTION_POINTER_DOWN:
mScrollPointerId = e.getPointerId(actionIndex);
mInitialTouchX = (int) (e.getX(actionIndex) + 0.5f);
mInitialTouchY = (int) (e.getY(actionIndex) + 0.5f);
return super.onInterceptTouchEvent(e);
case MotionEvent.ACTION_MOVE: {
final int index = e.findPointerIndex(mScrollPointerId);
if (index < 0) {
return false;
}
final int x = (int) (MotionEventCompat.getX(e, index) + 0.5f);
final int y = (int) (MotionEventCompat.getY(e, index) + 0.5f);
if (getScrollState() != SCROLL_STATE_DRAGGING) {
final int dx = x - mInitialTouchX;
final int dy = y - mInitialTouchY;
boolean startScroll = false;
//注1:注意这里,在原本的基础上加入了dx>dy的判断
if (canScrollHorizontally && Math.abs(dx) > mTouchSlop && Math.abs(dx) >= Math.abs(dy)) {
startScroll = true;
}
if (canScrollVertically && Math.abs(dy) > mTouchSlop && Math.abs(dy) >= Math.abs(dx)) {
startScroll = true;
}
return startScroll && super.onInterceptTouchEvent(e);
}
return super.onInterceptTouchEvent(e);
}
default:
return super.onInterceptTouchEvent(e);
}
}
}
从上面的代码<注1>看到,在原本的基础上加入了dx与dy绝对值比较的判断。只有当手指横向移动的距离大于纵向移动的距离,我们才去走原本的拦截逻辑。
效果
优点
只需重写父控件的onInterceptTouchEvent
缺点
由于重写时简化了RecycleView的onInterceptTouchEvent逻辑,移除了一些其他判断条件,可能存在特殊情况下的隐藏风险(目前暂未发现)
方案2:通过内部拦截法
内部拦截法步骤如下:
- 外部HorizonRecycleView拦截ACTION_DOWN以外的其它事件(ACTION_DOWN若拦截了会导致子控件无法收到任何焦点)
- 内部VerticalRecycleView在ACTION_DOWN时调用requestDisallowInterceptTouchEvent(true)不允许父控件拦截,即之后MOVE事件都不会走外部HorizonRecycleView的拦截逻辑
- 内部VerticalRecycleView在ACTION_MOVE时判断,若自己不需要滑动,则调用requestDisallowInterceptTouchEvent(false)重新走父控件HorizonRecycleView的拦截逻辑
代码实现如下
class HorizonRecycleView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
override fun onInterceptTouchEvent(e: MotionEvent?): Boolean {
//若不调用onInterceptTouchEvent,直接返回true或false会导致滑动的瞬间瞬移或首次无法横移的问题。
var result = super.onInterceptTouchEvent(e)
when (e.action) {
MotionEvent.ACTION_DOWN ->{
result = false
}
}
return result
}
}
class VerticalRecycleView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
var downX = 0f
var downY = 0f
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
Log.i("wzt", "[VerticalRecycleView][dispatchTouchEvent] ev = ${MotionEvent.actionToString(ev!!.action)}")
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
downX = ev.x
downY = ev.y
Log.i("wzt","[VerticalRecycleView][dispatchTouchEvent]不允许父控件拦截")
getParentRecycleView()?.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
val currentX = ev.x
val currentY = ev.y
val x = abs(currentX - downX)
val y = abs(currentY - downY)
if (y < x) {
//表示我不需要消费此事件
Log.i("wzt","允许拦截")
getParentRecycleView()?.requestDisallowInterceptTouchEvent(false)
}
}
}
val result = super.dispatchTouchEvent(ev)
Log.i("wzt", "[VerticalRecycleView][dispatchTouchEvent] return $result")
return result
}
/**
* 返回父RecycleView,这里直接往上级最高三层查找
*/
private fun getParentRecycleView() :RecyclerView? {
return when {
parent is RecyclerView -> parent as RecyclerView
parent.parent is RecyclerView -> parent.parent as RecyclerView
parent.parent.parent is RecyclerView -> parent.parent.parent as RecyclerView
else -> null
}
}
}
使用此方法需要注意:
- 子控件的判断逻辑需要放在dispatchTouchEvent或onTouchEvent中,因为若自己消费了事件,自身的onInterceptTouchEvent不会再被调用
- 父控件需要调用super.onInterceptTouchEvent(e),若不调用会导致mInitialTouchX得不到初始化,从而在之后move走到如下流程中无法消费事件,导致无法滑动
@Override
public boolean onTouchEvent(MotionEvent e) {
case MotionEvent.ACTION_MOVE: {
final int index = e.findPointerIndex(mScrollPointerId);
if (index < 0) {
Log.e(TAG, "Error processing scroll; pointer index for id "
+ mScrollPointerId + " not found. Did any MotionEvents get skipped?");
return false;
}
}
}
效果
优点
- 逻辑交给子控件自行处理,可操作性更高
- 可以在一个事件序列中先内部竖直滑动,再外部横向滑动
缺点
- 改动的类更多
- 需要注意的点较多
废弃方案:外部拦截法
通过在MOVE时判断x轴和y轴的移动距离来判断是否需要拦截
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
downY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
float currentX = event.getX();
float currentY = event.getY();
float x = Math.abs(currentX - downX);
float y = Math.abs(currentY - downY);
return x < y;
}
return super.onInterceptTouchEvent(event);
}
此方案为我最开始使用的方案,但是某个机型的mTouchSlop(滑动临界值)过小,导致若HorizonRecycleView的每个Item除了VerticalRecycleView之外若还有Button之类的控件。很容易触发onInterceptTouchEvent的return ture条件,从而拦截了Item上Button的touch事件,导致Button很难被点击到
总结
之前对事件分发机制一直理解比较模糊,在仔细通过日志、源码分析了这次的滑动嵌套问题后,的确学到了很多。但是RecycleView以及事件分发相关源码肯定不仅仅是我所描述的这么简单,如果文章中有写错的地方欢迎指出,有疑问的地方也欢迎交流~谢谢啦
测试工程链接:https://github.com/wangzici/blog
可回退到我修改前的代码自行尝试分析,更便于深入理解
本文地址:https://blog.csdn.net/wangzici/article/details/107479414
上一篇: Flutter自定义View——仿高德三级联动Drawer
下一篇: 微信涨粉之后期维护