区块链技术博客
www.b2bchain.cn

事件分发典型bug:RecycleView滑动嵌套问题解决的讲解

这篇文章主要介绍了事件分发典型bug:RecycleView滑动嵌套问题解决的讲解,通过具体代码讲解7507并且分析了事件分发典型bug:RecycleView滑动嵌套问题解决的讲解的详细步骤与相关技巧,需要的朋友可以参考下

本文实例讲述了事件分发典型bug:RecycleView滑动嵌套问题解决的讲解。分享给大家供大家参考文章查询地址https://www.b2bchain.cn/7507.html。具体如下:

简介

现象

在工作中碰到了一个易用性的问题,当一个横向滑动的HorizonRecycleView(注意这里只是一个普通的加了日志打印的RecycleView,并没有改动其自身逻辑),每个Item都包含了一个纵向滑动的VerticalRecycleView(同上)时,若此时想去滑动纵向的VerticalRecycleView,很容易触发到HorizonRecycleView的横向滑动。可能说起来有点绕,直接看图可能更明显点。
事件分发典型bug:RecycleView滑动嵌套问题解决

代码

代码比较简单,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事件呢。有两种方法

  1. 重写HorizonRecycleView的onInterceptTouchEvent方法逻辑,修改判断切换滑动状态的部分
  2. 通过内部拦截法

方案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绝对值比较的判断。只有当手指横向移动的距离大于纵向移动的距离,我们才去走原本的拦截逻辑。

效果

事件分发典型bug:RecycleView滑动嵌套问题解决

优点

只需重写父控件的onInterceptTouchEvent

缺点

由于重写时简化了RecycleView的onInterceptTouchEvent逻辑,移除了一些其他判断条件,可能存在特殊情况下的隐藏风险(目前暂未发现)

方案2:通过内部拦截法

内部拦截法步骤如下:

  1. 外部HorizonRecycleView拦截ACTION_DOWN以外的其它事件(ACTION_DOWN若拦截了会导致子控件无法收到任何焦点)
  2. 内部VerticalRecycleView在ACTION_DOWN时调用requestDisallowInterceptTouchEvent(true)不允许父控件拦截,即之后MOVE事件都不会走外部HorizonRecycleView的拦截逻辑
  3. 内部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         }     } } 

使用此方法需要注意:

  1. 子控件的判断逻辑需要放在dispatchTouchEvent或onTouchEvent中,因为若自己消费了事件,自身的onInterceptTouchEvent不会再被调用
  2. 父控件需要调用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;         }     } } 

效果

事件分发典型bug:RecycleView滑动嵌套问题解决

优点

  1. 逻辑交给子控件自行处理,可操作性更高
  2. 可以在一个事件序列中先内部竖直滑动,再外部横向滑动

缺点

  1. 改动的类更多
  2. 需要注意的点较多

废弃方案:外部拦截法

通过在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://www.b2bchain.cn/7507.html

赞(0) 打赏
部分文章转自网络,侵权联系删除b2bchain区块链学习技术社区 » 事件分发典型bug:RecycleView滑动嵌套问题解决的讲解
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

b2b链

联系我们联系我们