[入门向]关于RecyclerView的事件拦截机制

声明:本文章已独家授权郭霖公众号

目录

一、发现问题

二、原因分析

1、事件分发的机制

2、原因猜测

三、RecyclerView事件拦截机制

switch (action)部分

返回值

ACTION_DOWN

ACTION_MOVE

ACTION_UP

小结

switch (action) 之前

OnItemTouchListener接口

拦截过程

小结

四、解决问题

方案一

方案二

五、总结


一、发现问题

最近在利用RecyclerView做开发的时候,遇到了一点问题:

给RecyclerView的子项添加事件监听的时候,发现ACITON_DOWN能得到处理,ACITON_UPACTION_MOVE却得不到处理。

二、原因分析

在刚开始开发需求的时候我还不太了解事件分发的机制。所以我先去学习了一下事件分发,这里对事件分发做一个简单的总结。

1、事件分发的机制

事件分发是由三个方法配合完成的:

  • dispatchTouchEvent() 分发事件

  • onInterceptTouchEvent() 拦截事件

  • onTouchEvent() 处理事件

而且事件分发的顺序是:

Activity -> ViewGroup -> View

借助一张图来配合理解:

(图源:Android事件分发机制详解:史上最全面、最易懂 - 天涯海角路 - 博客园 (cnblogs.com))

通过图片我们可以看到,ViewGroup是比较特殊的。onInterceptTouchEven()是他独有方法,他可以将事件拦截下来选择不分发给下一层的View而是自己处理。

2、原因猜测

在了解了事件分发的机制过后,我就猜测会不会是因为RecyclerView将事件拦截了下来。因为RecyclerView肯定有他自己的事件监听,当ACTION_MOVE的时候应该会触发滚动,加载数据然后显示到屏幕上。

那如果真的是被RecyclerView给拦截了,那我又产生了新的疑问:

  • 根据事件分发的机制,再ACITON_DOWN的时候应该就决定了targetViewitemView,为什么在ACITON_MOVE的时候会目标View又变成了RecyclerView

  • RecyclerView是怎么做到只拦截ACTION_MOVEACTION_UP而不拦截ACTION_DOWN的呢?

  • 那如果想要实现子项自己处理ACTION_MOVEACTION_UP要怎么处理呢?

为了验证我的猜想解决这些疑问,我决定去RecyclerView的源码里一探究竟。

三、RecyclerView事件拦截机制

既然我们要分析的是拦截机制,那么当然应该去onTouchEvent()这个方法里去看。

这里先说明一下,以下贴出来的源码并不是全部。我一直觉得分析源码不能一行一行的扣,不然思路会很混乱。在这篇文章里,我只把对解决问题有用的部分贴了出来,也能让大家更好理解。如果有小伙伴有看不懂的地方,可以再配合所有源码来理解。

@Override
    public boolean onInterceptTouchEvent(MotionEvent e) {
        ...
​
        mInterceptingOnItemTouchListener = null;
        if (findInterceptingOnItemTouchListener(e)) {
            cancelScroll();
            return true;
        }
        
        ...
            
        final int action = e.getActionMasked();
        
        ...
​
        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                ...
            } break
                
            ...
                
            case MotionEvent.ACTION_MOVE: {
                ...
            } break;
​
            ...
​
            case MotionEvent.ACTION_UP: {
                ..
            } break;
​
            ...
        }
        return mScrollState == SCROLL_STATE_DRGING;
    }

总的来说这个函数我把他分为两个部分,switch(aciton)之前switch(aciton)部分。我们按倒序分析一下。

switch (action)部分

返回值

这部分呢我们需要先看一下最后的返回值,因为返回值决定了是否拦截。

return mScrollState == SCROLL_STATE_DRGING;

解释一下mScrollState这个变量。这个变量是用来记录滑动状态的,有下面三个值:

//停止滚动
public static final int SCROLL_STATE_IDLE = 0;
​
//正在被外部拖拽,一般为用户正在用手指滚动
public static final int SCROLL_STATE_DRAGGING = 1;
​
//自动滚动开始
public static final int SCROLL_STATE_SETTLING = 2;

第一个和第二个都好理解,这里解释一下第三个状态。

整个RecyclerView里只有在fing()方法里会把mScrollState的值设置为SCROLL_STATE_SETTLING。而fling()这个函数呢,其实就是指当你手指在屏幕上快速滑动时,会触发自动滑动。就像下面这样:

                                           

这个功能其实大家日常使用中也经常会用到。大家知道这个状态的含义即可。

那这个返回值的意思就是判断最后RecyclerView是否是手指正在拖着滚动的状态。如果是正在滚动,那么就会拦截本次事件;反之则不拦截。

ACTION_DOWN

进入到ACTION_DOWN操作,前面部分和后面部分都是设置一些状态(触点的位置,布局滚动方向是竖直的还是垂直的)。最重要的是中间的判断。

case MotionEvent.ACTION_DOWN:
    ...
​
    if (mScrollState == SCROLL_STATE_SETTLING) {
    getParent().requestDisallowInterceptTouchEvent(true);
    setScrollState(SCROLL_STATE_DRAGGING);
    stopNestedScroll(TYPE_NON_TOUCH);
    }
​
    ...
    break;

如果目前的状态是在自动滚动的状态下,里面就会将mScrollState设置为SCROLL_STATE_DRAGGING

这里其实很好想明白。当你的列表在自动快速滚动的过程中,手指再按上去,是需要他立即停下来的。那么理所应当这里需要把事件拦截下来RecyclerView自己处理。就像下面这样就会拦截:

                                             

不拦截的话,就只能等他自己停下来,那这个自动滚动就是不可控的了。

那如果不是这种情况,便不会拦截。那么子项就可以接受到ACTION_DOWN事件啦。

ACTION_MOVE



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;
        if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
              mLastTouchX = x;
              startScroll = true;
        }
        if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
              mLastTouchY = y;
              startScroll = true;
        }
        if (startScroll) {
              setScrollState(SCROLL_STATE_DRAGGING);
        }
    }
} break;
 

ACTION_MOVE里面就很简单啦。

  1. 如果当前mScrollState的状态是正在滚动,那么就不做任何处理了。这个时候表示手指正在拖着列表滚动,自然是要拦截下来的。

  2. 如果当前mScrollState的状态不是滚动,那就会进行一个判断了。判断你手指的移动的距离是否在相应方向上超过了一个阈值。如果超过了这个阈值,说明你想要开始滑动了,那么这个时候又会调用setScrollState(SCROLL_STATE_DRAGGING)来将mScrollState的值设置为滚动,将事件拦截下来。

ACTION_UP

ACTION_UP里并没有对mScrollState进行修改和赋值。所以这个时候也就会根据是否正在滑动来判断是否拦截事件了。

小结

RecyclerView确实会拦截事件,会对最基本的三个事件根据情况拦截:

  • ACTION_DOWN:当列表在自动滚动的状态下会拦截,用于处理停止滚动。

  • ACTION_MOVE:当手指移动的距离在对应方向上超过了阈值,就会拦截掉事件,用于列表滚动。

  • ACTION_UP :根据当前列表是否处于滚动状态选择是否拦截。

这部分的内容其实就已经能证实我们的猜想了。

switch (action) 之前

看到这里你可能会好奇,前面不是已经能证实猜想了吗?别急,在分析源码的时候我还发现一个东西,短短的几行代码,展现出RecyclerView的灵活性。这也就是为什么我要把这部分放到后面来说。

    mInterceptingOnItemTouchListener = null;
    if (findInterceptingOnItemTouchListener(e)) {
        cancelScroll();
        return true;
    }

我们先从mInterceptingOnItemTouchListener的类型OnItemTouchListener接口开始说起吧。

OnItemTouchListener接口

熟悉ListView的同学都知道,ListView可以通过setOnItemClickListener()来给一个ItemView添加事件的监听器,而RecyclerView并没有这样的方法。

那么你可能就有疑问了,为什么RecyclerView在各方面的设计都要优于ListView,偏偏在点击事件上却没有处理的非常好呢?其实不是这样的,ListView在点击事件上处理得并不人性化,setOnItemClickListener()方法注册的是子项的点击事件,但如果我想点击的是子项里具体的某一个按钮呢?虽然ListView也能做到,但是实现起来就相对比较麻烦了。为此,RecyclerView干脆直接摒弃了子项点击事件的监听器,让所有的点击事件都由具体的View去注册,就再没有这个困扰了。

(摘自郭霖《第一行代码》)

郭神在书中给出的方法,也是在AdapteronCreateViewHolder()方法里去给每一个子项绑定事件监听,这样做确实更灵活,但同时因为每个子项都绑定了事件监听,在内存上也会有一定的消耗。其实RecyclerView内部也有一个接口,能够实现对整个RecyclerView的监听。

    public interface OnItemTouchListener {
    
        boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e);
​
​
        void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e);
​
​
        void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept);
    }

前两个通过方法名我们可以轻易的猜出他们的目的,不就是ViewGroup里事件分发的两个函数吗。

我们重点说一下第三个函数。第三个函数在ViewGroup里也有实现,他的作用是设置ViewGroup是否开启事件拦截。也解释说,通过这个函数我们可以在子View里设置父ViewGroup关闭拦截,这样就能让子View自行处理事件了。

那对于整个接口的作用,这里我放一下官方的注释。

An OnItemTouchListener allows the application to intercept touch events in progress at the view hierarchy level of the RecyclerView before those touch events are considered for RecyclerView's own scrolling behavior.

This can be useful for applications that wish to implement various forms of gestural manipulation of item views within the RecyclerView. OnItemTouchListeners may intercept a touch interaction already in progress even if the RecyclerView is already handling that gesture stream itself for the purposes of scrolling.

翻译一下,大概意思就是这个监听器允许在RecyclerView考虑自己的滚动事件之前,在ViewGroup层面拦截事件。

说人话就是RecyclerView在处理事件的时候,得先看这个监听器要不要拦截这个事情,如果监听器要拦截,那么RecyclerView就没资格自己处理了。

实现了对RecyclerView整个视图的监听,允许我们自定义对一些特定手势的处理。

用这个接口有什么好处呢?

  1. 节省内存。在运行期间只有一个监听器,不像之前RecyclerView的每个子项都要设置一个监听器。

  2. 对于整个面板来说更加灵活。如果说我们需要对整个面板有一些自定义的手势操作,那么就只能通过实现这个接口,去子项里实现已经不太可能了。

拦截过程

因为本文篇幅原因,就不展示怎么去实现了。我们这里通过源码分析一下他是如何做到让RecyclerView没资格处理自己的滚动的。

    private final ArrayList<OnItemTouchListener> mOnItemTouchListeners =
            new ArrayList<>();
    private OnItemTouchListener mInterceptingOnItemTouchListener;

首先是有两个全局变量,一个用来存放所有的自定义实现的OnItemTouchListener。我们可以通过调用addOnItemTouchListener()来添加监听器。这里也说明了一个RecycerView里可以自定义多个监听器。另一个是用来记录拦截事件的监听器。可能这里有点懵,看到下面就能明白了。

public void addOnItemTouchListener(@NonNull OnItemTouchListener listener) {
    mOnItemTouchListeners.add(listener);
}

然后我们回到RecyclerViewonInterceptTouchEvent()那五行代码

    mInterceptingOnItemTouchListener = null;
    if (findInterceptingOnItemTouchListener(e)) {
        cancelScroll();
        return true;
    }

先将mInterceptingOnItemTouchListener置为null,是为了避免上一次赋值的mInterceptingOnItemTouchListener没有被销毁,导致出错。

然后我们到findInterceptingOnItemTouchListener()方法里去看看。

    private boolean findInterceptingOnItemTouchListener(MotionEvent e) {
        int action = e.getAction();
        final int listenerCount = mOnItemTouchListeners.size();
        for (int i = 0; i < listenerCount; i++) {
            final OnItemTouchListener listener = mOnItemTouchListeners.get(i);
            if (listener.onInterceptTouchEvent(this, e) && action != MotionEvent.ACTION_CANCEL) {
                mInterceptingOnItemTouchListener = listener;
                return true;
            }
        }
        return false;
    }

这里的逻辑非常简单,去遍历监听器数组,如果发现其中一个监听器拦截了此类事件并且事件不是ACTION_CANCEL ,那么就给mInterceptingOnItemTouchListener赋值。这里说明了mInterceptingOnItemTouchListener的用处,记录了拦截事件的监听器。

然后如果找到了这么一个监听器,返回true,那么RecyclerView就会取消滚动,帮监听器直接拦截下本次事件,确保不会往下分发。从这里就能看出这个监听器的优先级了。

而对事件的处理的入口呢,是在RecyclerViewonTouchEvnet()里面。

    @Override
    public boolean onTouchEvent(MotionEvent e) {
        ...
        if (dispatchToOnItemTouchListeners(e)) {
            cancelScroll();
            return true;
        }
        ...
    }

这里调用的是dispatchToOnItemTouchListeners()这个方法

    private boolean dispatchToOnItemTouchListeners(MotionEvent e) {
        if (mInterceptingOnItemTouchListener == null) {
            if (e.getAction() == MotionEvent.ACTION_DOWN) {
                return false;
            }
            return findInterceptingOnItemTouchListener(e);
        } else {
            mInterceptingOnItemTouchListener.onTouchEvent(this, e);
            final int action = e.getAction();
            if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
                mInterceptingOnItemTouchListener = null;
            }
            return true;
        }
    }

在这个方法里,如果mInterceptingOnItemTouchListener不为空,那么就在这里调用它的onTouchEvent()去处理。返回了true,ReyclerView自然就不会自己处理了。

小结

这个就是自定义的onItemTouchListener的拦截过程了。

  1. 我们自定义实现的onItemTouchListener,需要通过addOnItemTouchListener()添加到RecyclerView里。

  2. RecyclerView在判断拦截事件时,会优先判断有没有自定义的onItemTouchListener要拦截此次事件,如果有,则会帮他拦截下来。

  3. RecyclerView在处理事件时,也会优先判断判断有没有自定义的onItemTouchListener要处理该次事件。如果有,那就交给它处理,自己不再处理。

四、解决问题

在分析完整个拦截机制后,我们就可以有两套解决方案了。具体方案可以根据需求自行选择。

方案一

这种方案推荐用于针对子项的某一具体组件的事项,比如RecyclerView的子项是一个RelativeLayout,事件只针对其中的一个Button。

  • 如果子View不需要自己处理ACTION_MOVE,只需要在ACTION_UP里做一些收尾操作,那么可以把收尾操作添加一份到ACTION_CANCEL里。

  • 如果子View需要自己处理ACTION_MOVEACTION_UP,那么就可以通过requestDisallowInterceptTouchEvent(boolean disallowIntercept)来设置不让RecyclerView对事件进行拦截。不过这种方法不建议添加到ACTION_DOWN里,会导致列表无法滑动。

方案二

这种方案用于针对子项或者整个RecyclerView的事件。

通过实现onItemTouchListener接口来处理自己需要的事件,通过手指按下的位置获取到具体的子项。

五、总结

做一个整体的总结。

  1. ReyclerViewItemView的事件,特别是ACTION_MOVEACTION_UP容易被RecyclerView拦截,但是会发送一个ACTION_CANCEL给子View用来处理一些收尾工作。

  2. 如果ItemView不希望被RecyclerView给拦截,可以通过parent.requestDisallowInterceptTouchEvent(true)来设置,这样就不会被拦截。

  3. RecyclerView提供了一个内部接口onItemTouchListener用于对整个RecyclerView进行监听,可以实现更灵活的功能,优先级高于RecyclerView自己的事件处理。

最后,非常感谢你可以看到这里。我是一个即将大四的实习生,Android的知识体系还没有成为一个牢固的系统,在这之前我甚至都不知道什么是事件分发。本篇内容全是自己学习相关知识后读源码的理解,难免会有差错。如果发现了错误希望大家谅解并为我指出错误。也非常希望这篇文章能给你带来一点帮助。

感谢你的阅读

  • 18
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值