在工作中碰到了一个易用性的问题,当一个横向滑动的HorizonRecycleView(注意这里只是一个普通的加了日志打印的RecycleView,并没有改动其自身逻辑),每个Item都包含了一个纵向滑动的VerticalRecycleView(同上)时,若此时想去滑动纵向的VerticalRecycleView,很容易触发到HorizonRecycleView的横向滑动。可能说起来有点绕,直接看图可能更明显点。
代码比较简单,A与B都使用的是LinearLayoutManager,这里展示一下他们item的layout文件
每个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>
只有一个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_DOWNI/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_DOWNI/wzt: [HorizonRecycleView][onInterceptTouchEvent] return fal //HorizonRecycleView不强制拦截I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_DOWNI/wzt: [VerticalRecycleView][onInterceptTouchEvent] e = = ACTION_DOWNI/wzt: [VerticalRecycleView][onInterceptTouchEvent] return fal //VerticalRecycleView不强制拦截I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_DOWNI/wzt: [VerticalRecycleView][onTouchEvent] return true //VerticalRecycleView消费此事件I/wzt: [VerticalRecycleView][dispatchTouchEvent] return trueI/wzt: [HorizonRecycleView][dispatchTouchEvent] return true//ACTION_DONW事件分发结束,被VerticalRecycleView消费//ACTION_MOVE事件分发I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVEI/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_MOVEI/wzt: [HorizonRecycleView][onInterceptTouchEvent] return falI/wzt:公务员面试真题及答案 [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_MOVEI/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_MOVEI/wzt: [VerticalRecycleView][onTouchEvent] return true //VerticalRecycleView消费此ACTION_MOVE事件I/wzt: [VerticalRecycleView][dispatchTouchEvent] return trueI/wzt: [HorizonRecycleView][dispatchTouchEvent] return true//ACTION_MOVE事件分发结束//ACTION_MOVE事件分发I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVEI/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_MOVE//...//上面省略N个MOVE事件分发//ACTION_UP事件分发I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_UPI/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_UPI/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_UPI/wzt: [VerticalRecycleView][onTouchEvent] return trueI/wzt: [VerticalRecycleView][dispatchTouchEvent] return trueI/wzt: [HorizonRecycleView][dispatchTouchEvent] return true//事件分发流程结束
//ACTION_DONW事件分发I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_DOWNI/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_DOWNI/wzt办公室制度: [HorizonRecycleView][onInterceptTouchEvent] return falI/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_DOWNI/wzt: [VerticalRecycleView][onInterceptTouchEvent] e = = ACTION_DOWNI/wzt: [VerticalRecycleView][onInterceptTouchEvent] return falI/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_DOWNI/wzt: [VerticalRecycleView][onTouchEvent] return true //VerticalRecycleView消费I/wzt: [VerticalRecycleView][dispatchTouchEvent] return trueI/wzt: [HorizonRecycleView][dispatchTouchEvent] return true//ACTION_DONW事件分发结束,流程与正常情况完全一致//ACTION_MOVE事件分发I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVEI/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_MOVEI/wzt: [HorizonRecycleView][onInterceptTouchEvent] return falI/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_MOVEI/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_MOVEI/wzt: [VerticalRecycleView][onTouchEvent] return true //VerticalRecycleView消费//ACTION_MOVE事件分发结束//ACTION_MOVE事件分发//...//上面省略了大概5个MOVE事件分发,都和正常竖直滑动时一致//注1:注意注意注意啦!!!:从这里开始就是重头戏//ACTION_MOVE事件分发I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVEI/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_MOVEI/wzt: [HorizonRecycleView][onInterceptTouchEvent] return true //注2:这里直接被HorizonRecycleView拦截//事件被父控件拦截,导致VerticalRecycleView只能收到一个ACTION_CANCEL事件I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_CANCEL I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_CANCELI/wzt: [VerticalRecycleView][onTouchEvent] return trueI/wzt: [VerticalRecycleView][dispatchTouchEvent] return true //VerticalRecycleView消费了ACTION_CANCEL事件之后,此次滑动序列再也没有收到任何事件I/wzt: [HorizonRecycleView][dispatchTouchEvent] return trueI/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE//之后的所有MOVE事件,不会再走onInterceptTouchEvent方法,直接交给HorizonRecycleView消费I/wzt: [HorizonRecycleView][onTouchEvent] e = = ACTION_MOVEI/wzt: [HorizonRecycleView][onTouchEvent] return trueI/wzt: [HorizonRecycleView][dispatchTouchEvent] return true//ACTION_MOVE事件分发开始I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVEI/wzt: [HorizonRecycleView][onTouchEvent] e = = ACTION_MOVEI/wzt: [HorizonRecycleView][onTouchEvent] return trueI/wzt: [HorizonRecycleView][dispatchTouchEvent] return true//...//省略N个MOVE事件分发//ACTION_UP事件分发,与正常现象一致I/wzt赚钱加盟: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_UPI/wzt: [HorizonRecycleView][onTouchEvent] e = = ACTION_UPI/wzt: [HorizonRecycleView][onTouchEvent] return trueI/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
通过如上两个日志对比我们发现,出现问题的原因在于<注2>部分,HorizonRecycleView拦截了一次MOVE事件,导致VerticalRecycleView后续除了一个CANCEL外无法收到任何事件。
这里稍微提一下我一直都没有理解的ACTION_CANCEL,从上面的日志我们就可以了解到ACTION_CANCEL出现的场景:当一个View在消费一个事件序列的过程中,父控件拦截了此次事件(父控件onInterceptTouchEvent返回true),这个View就会收到一个ACTION_CANCEL,并且View在此时进行内部状态的重置,如从常态恢复成点击态。并且此次事件序列的后续事件都会直接交给父控件处理。
从日志分析可得横向滑动的误触发是由于HorizonRecycleView的事件拦截引起,那么直接到RecycleView源码里分析一下为何会在MOVE过程中拦截。注意下面的源码省略了非关键的部分
//RecycleView.java@Overridepublic boolean onInterceptTouchEvent(MotionEvent e) { final int action = e.getActionMasked(); switch (action) { ca MotionEvent.ACTION_DOWN: { //在DOWN时记录手指点击的区域 //这里加0.5f的原因是为了转成int值时四舍五入 mInitialTouchX = (int) (e.getX() + 0.5f); mInitialTouchY = (int) (e.getY() + 0.5f); } ca 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 = fal; //注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 tScrollState(SCROLL_STATE_DRAGGING); } } } } //若为SCROLL_STATE_DRAGGING状态则return true拦截事件 return mScrollState == SCROLL_STATE_DRAGGING;}
从上面的源码<注1>可以看到,在MOVE事件中,若当前手指在HorizonRecycleView横向的滑动大于滑动临界值,则HorizonRecycleView 会直接不去判断其它任何条件置为滑动状态,直接拦截此事件。这就是问题根本原因所在了,HorizonRecycleView只是判断手指在x轴的移动距离超过了临界值就直接强行拦截后续事件。
知道了问题原因,解决方案很明显就是如何让HorizonRecycleView不去拦截此次MOVE事件呢。有两种方法
重写HorizonRecycleView的onInterceptTouchEvent方法逻辑,修改判断切换滑动状态的部分通过内部拦截法方案来自于 修复RecyclerView嵌套滚动问题,在大佬基础上有少量简化
直接在BetterRecyclerView照着RecycleView源码重写onInterceptTouchEvent,用BetterRecyclerView代替HorizonRecycleView原本的位置即可
//BetterRecyclerView.javapublic class BetterRecyclerView extend沧州的大学s 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();}@Overridepublic void tScrollingTouchSlop(int slopConstant) {super.tScrollingTouchSlop(slopConstant);final ViewConfiguration vc = ViewConfiguration.get(getContext());switch (slopConstant) {ca TOUCH_SLOP_DEFAULT:mTouchSlop = vc.getScaledTouchSlop();break;ca TOUCH_SLOP_PAGING:mTouchSlop = vc.getScaledPagingTouchSlop();break;default:break;}}@Overridepublic boolean onInterceptTouchEvent(MotionEvent e) {LayoutManager mLayout = getLayoutManager();if (mLayout == null) {return fal;}final boolean canScrollHorizontally = mLayout.canScrollHorizontally();final boolean canScrollVertically = mLayout.canScrollVertically();final int action = e.getActionMasked();final int actionIndex = e.getActionIndex();switch (action) {ca MotionEvent.ACTION_DOWN:mScrollPointerId = e.getPointerId(0);mInitialTouchX = (int) (e.getX() + 0.5f);mInitialTouchY = (int) (e.getY() + 0.5f);return super.onInterceptTouchEvent(e);ca 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);ca MotionEvent.ACTION_MOVE: {final int index = e.findPointerIndex(mScrollPointerId);if (index < 0) {return fal;}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 = fal;//注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逻辑,移除了一些其他判断条件,可能存在特殊情况下的隐藏风险(目前暂未发现)
内部拦截法步骤如下:
外部HorizonRecycleView拦截ACTION_DOWN以外的其它事件(ACTION_DOWN若拦截了会导致子控件无法收到任何焦点)内部VerticalRecycleView在ACTION_DOWN时调用requestDisallowInterceptTouchEvent(true)不允许父控件拦截,即之后MOVE事件都不会走外部HorizonRecycleView的拦截逻辑内部VerticalRecycleView在ACTION_MOVE时判断,若自己不需要滑动,则调用requestDisallowInterceptTouchEvent(fal)重新走父控件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或fal会导致滑动的瞬间瞬移或首次无法横移的问题。var result = super.onInterceptTouchEvent(e)when (e.action) {MotionEvent.ACTION_DOWN ->{result = fal}}return result}}
class VerticalRecycleView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : RecyclerView(context, attrs, defStyleAttr) {var downX = 0fvar downY = 0foverride fun dispatchTouchEvent(ev: MotionEvent?): Boolean {Log.i("wzt", "[VerticalRecycleView][dispatchTouchEvent] ev = ${MotionEvent.actionToString(ev!!.action)}")when (ev.action) {MotionEvent.ACTION_DOWN -> {downX = ev.xdownY = ev.yLog.i("wzt","[VerticalRecycleView][dispatchTouchEvent]不允许父控件拦截")getParentRecycleView()?.requestDisallowInterceptTouchEvent(true)}MotionEvent.ACTION_MOVE -> {val currentX = ev.xval currentY = ev.yval x = abs(currentX - downX)val y = abs(currentY - downY)if (y < x) {//表示我不需要消费此事件Log.i("wzt","允许拦截")getParentRecycleView()?.requestDisallowInterceptTouchEvent(fal)}}}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 RecyclerViewparent.parent is RecyclerView -> parent.parent as RecyclerViewparent.parent.parent is RecyclerView -> parent.parent.parent as RecyclerViewel -> null}}}
使用此方法需要注意:
子控件的判断逻辑需要放在dispatchTouchEvent或onTouchEvent中,因为若自己消费了事件,自身的onInterceptTouchEvent不会再被调用父控件需要调用super.onInterceptTouchEvent(e),若不调用会导致mInitialTouchX得不到初始化,从而在之后move走到如下流程中无法消费事件,导致无法滑动@Overridepublic boolean onTouchEvent(MotionEvent e) {ca 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 fal;}}}
通过在MOVE时判断x轴和y轴的移动距离来判断是否需要拦截
@Overridepublic boolean onInterceptTouchEvent(MotionEvent event) {switch (event.getAction()) {ca MotionEvent.ACTION_DOWN:downX = event.getX();downY = event.getY();break;ca 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
本文发布于:2023-04-07 12:49:45,感谢您对本站的认可!
本文链接:https://www.wtabcd.cn/fanwen/zuowen/a02a2af02c941712852a5589881a00e4.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文word下载地址:事件分发典型bug:RecycleView滑动嵌套问题解决.doc
本文 PDF 下载地址:事件分发典型bug:RecycleView滑动嵌套问题解决.pdf
留言与评论(共有 0 条评论) |