(4.0.22.8)RecyclerView的空白区域点击响应

在项目中使用RecyclerView嵌套RecyclerView,其中内部RecyclerView使用到了GridLayoutManager,在遇到item个数不满足一行时,会在页面右侧透出空白位, 如下图所示.

20190625140544.jpg

目前点击空白位是没有点击响应事件的,我们想实现点击响应以扩大用户可以进入LandingPage的机会,在实现角度可以通过以下三种方式去实现:

  1. 重写RecyclerView的onMeasure实现宽度自适应
  2. 实现EmptyItem占位空白区域并给出点击事件
  3. 针对RecyclerView实现空白区域点击

本着最小改动的原则,我们采用了第三种方案进行探索。

一、前置知识

我们先来回顾一下Android控件事件转发流程:

20190625141843.jpg

  • 点击事件自上而下传递,当点击事件产生后由Activity来处理,传递给PhoneWindows,再传递给DecorView,最后传给指定ViewGroup

  • boolean dispatchTouchEvent(event)实现了整个迭代回调过程,其中调用onInterceptTouchEvent、onTouchEvent和child.dispatchTouchEvent

    • Down方式通过dispatchTouchEvent分发,分发的目的是为了找到真正需要处理完整Touch请求的View。当某个View或者ViewGroup的onTouchEvent事件返回true时,便表示它是真正要处理这次请求的View,之后的Aciton_UP和Action_MOVE将由它处理
  • ViewGroup#dispatchTouchEvent 实现 整个分发链和消费链的串联过程

    • 事件分发链只触及点击位置穿透的控件,由父到子,由上到下. 具体的实现在于 Gropu#dispatchTouchEvent中会倒序遍历 Childrens, 遍历过程中会校验 触摸点位置是否在子View范围内或者子view是否在播放动画
    • 消费链中一旦被消费(返回true)就终止整个事件分发流程
    • ViewGroup 和 ChildView 同时注册了事件监听器(onClick等),事件优先给 ChildView,会被 ChildView消费掉,ViewGroup 不会响应。因为 ChildView位于消费链的前端
    • onInterceptTouchEvent有两个作用:1.拦截Down事件的分发。2.中止Up和Move事件向目标View传递,使得目标View所在的ViewGroup捕获Up和Move事件
  • View#dispatchTouchEvent 处理单击事件(onClick)、长按事件(onLongClick)、触摸事件(onTouch),和View自身 onTouchEvent 方法的调度流程

    • 调度顺序应该是 onTouchListener > onTouchEvent > onLongClickListener > onClickListener
      1. 给 View 注册 OnTouchListener 不会影响 View 的可点击状态。即使给 View 注册 OnTouchListener ,只要不返回 true 就不会消费事件
    • 只要View是CLICKABLE,LONG_CLICKABLE,CONTEXT_CLICKABLE就会消费该点击事件。无论点击回调和长按回调中如何处理,都会消费点击事件(返回true)
      1. 点击包括很多种情况:譬如给View注册了 onClickListener、onLongClickListener、OnContextClickListener 其中的任何一个监听器或者设置了 android:clickable=”true”
      2. 某些 View 默认就是可点击的,例如,Button,CheckBox 等

更详细的内容可参看Android控件事件转发流程全解析

二、歧路一:给RecyclerView的父容器设置OnClickListener

第一个想法其实就是直接给父布局设置ClickListener,认为:在点击RecyclerView的空白区域时,没有子控件消费touch事件,RecyclerView也没消费触摸事件,那么自然就能回调给父容器的OnClickListener

然而,在实际尝试过程中,并没有触发父容器的OnClickListener。 我们先来看RecyclerView#onInterceptTouchEvent的源码:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {

        if (mLayoutFrozen) {
            // When layout is frozen,  RV does not intercept the motion event.
            // A child view e.g. a button may still get the click.
            return false;
        }
        if (dispatchOnItemTouchIntercept(e)) {
            cancelTouch();
            return true;
        }

        if (mLayout == null) {
            return false;
        }

        final int action = e.getActionMasked();
        final int actionIndex = e.getActionIndex();

        switch (action) {
            case MotionEvent.ACTION_DOWN:
            ....
            case MotionEvent.ACTION_UP: {
                mVelocityTracker.clear();
                stopNestedScroll(TYPE_TOUCH);
            } break;

        }

         return mScrollState == SCROLL_STATE_DRAGGING;
    }

从最后一行代码上我们可以看到,RecycleView并没有说强制拦截touch向子控件的传递,那么我们可以基本断定,之所以没有回调父布局的ClickListener,肯定是由于空白区域引发了:

  1. 没有子View消耗事件
  2. 事件被传回RecycleView#onTouchEvent函数,该函数必然消耗了该事件

我们把视线转向RecycleView#onTouchEvent的源码:

    @Override
    public boolean onTouchEvent(MotionEvent e) {

         ...
         switch (action) {
            case MotionEvent.ACTION_DOWN: {
                mScrollPointerId = e.getPointerId(0);
                mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
                mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);

                int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
                if (canScrollHorizontally) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
                }
                if (canScrollVertically) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
                }
                startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
            } break;
            ....

        }
        ....
         if (!eventAddedToVelocityTracker) {
            mVelocityTracker.addMovement(vtev);
        }
        vtev.recycle();

        return true;

    }

从最后一行代码可以看出,RecycleView应该是默认会消耗掉触摸事件的,这也是为什么我们设置父容器的点击事件不起作用

三、歧路二:给RecyclerView设置OnClickListener

仅接下来的想法肯定就是直接给RecyclerView设置ClickListener,认为:在点击RecyclerView的空白区域时,没有子控件消费touch事件,那么自然就能回调给自身的OnClickListener

然而,在实际尝试过程中,并也没有触发的OnClickListener。我们知道View的onTouchEvent是类似如下的结构:

public boolean onTouchEvent(MotionEvent event) {
    ...
    final int action = event.getAction();
    // 检查各种 clickable
    if (((viewFlags & CLICKABLE) == CLICKABLE ||
            (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
            (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
        switch (action) {
            case MotionEvent.ACTION_UP:
                ...
                removeLongPressCallback();  // 移除长按
                ...
                performClick();             // 检查单击
                ...
                break;
            case MotionEvent.ACTION_DOWN:
                ...
                checkForLongClick(0);       // 检测长按
                ...
                break;
            ...
        }
        return true;                        // ◀︎表示事件被消费
    }
    return false;
}

在检查单击的过程中去触发点击事件,然而我们来看RecyclerView#onTouchEvent的源码:

    @Override
    public boolean onTouchEvent(MotionEvent e) {
         ...
         switch (action) {
            ....
            case MotionEvent.ACTION_UP: {
                mVelocityTracker.addMovement(vtev);
                eventAddedToVelocityTracker = true;
                mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
                final float xvel = canScrollHorizontally
                        ? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
                final float yvel = canScrollVertically
                        ? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
                if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
                    setScrollState(SCROLL_STATE_IDLE);
                }
                resetTouch();
            } break;
            ....
         }

        ...
    }

呐呢~ 我的ClickListener.onClick呢? 在这里我们就也找到了RecyclerView的OnClickListener没被触发的原因:RecyclerView重写了onTouchEvent其中根本没去管点击监听的触发

四、实现过程

我们继续探索RecyclerView的源码,我们可以发现其并没有重写dispatchTouchEven函数,这意味着什么?

我们可以通过setOnTouchListener去实现对触摸事件的自定义监听

4.1 如何区分触摸触空白区域和item区域

第一个想法其实就是拿到点击位置的xy坐标,然后遍历判断是否在RecyclerView某个childview中,然而每一次去进行遍历判断看上去很消耗性能。

庆幸的是,在操作过程中,我们发现在setOnTouchListener的onTouch(View view, MotionEvent motionEvent)

** 如果触摸的是空白区域,则View会回调为RecyclerView **

因此,我们可以通过如下的方式去判断空白区域的触摸事件:

recycleView.setOnTouchListener(new View.OnTouchListener() {
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        if (v instanceof RecyclerView){
            //TODO 发现只有点击了空白处,v.getId,才能打印出东西

        }
        return false;
    }
});

4.2 如何鉴别点击事件

我们目前是监听了onTouch事件,其中down move up都会触发该事件,我们不能全部都响应,而是应该做出来类似点击的效果,这时候我们就要借助另外一个工具GestureDetector.OnGestureListener

private class gesturelistener implements GestureDetector.OnGestureListener{
 
    public boolean onDown(MotionEvent e) {
        // TODO Auto-generated method stub
        return false;
    }
 
    public void onShowPress(MotionEvent e) {
        // TODO Auto-generated method stub
        
    }
 
    public boolean onSingleTapUp(MotionEvent e) {
        // TODO Auto-generated method stub
        return false;
    }
 
    public boolean onScroll(MotionEvent e1, MotionEvent e2,
            float distanceX, float distanceY) {
        // TODO Auto-generated method stub
        return false;
    }
 
    public void onLongPress(MotionEvent e) {
        // TODO Auto-generated method stub
        
    }
 
    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
            float velocityY) {
        // TODO Auto-generated method stub
        return false;
    }
    
}

onSingleTapUp(MotionEvent e):从名子也可以看出,一次单独的轻击抬起操作,也就是轻击一下屏幕,立刻抬起来,才会有这个触发,当然,如果除了Down以外还有其它操作,那就不再算是Single操作了,所以也就不会触发这个事件。 触发顺序:

  • 点击一下非常快的(不滑动)Touchup:
    • onDown->onSingleTapUp->onSingleTapConfirmed
  • 点击一下稍微慢点的(不滑动)Touchup:
    • onDown->onShowPress->onSingleTapUp->onSingleTapConfirmed

五、最终方案

由于只针对内部recyclerView进行了onTouch监听,在性能上并不会有干扰。

public class RecyclerMarginClickHelper {


    public static   void setOnMarginClickListener(final RecyclerView recyclerView, final View.OnClickListener onClickListener){
        if(recyclerView == null || onClickListener == null){
            return;
        }

        final GestureDetector gestureDetector = new GestureDetector(recyclerView.getContext(), new GestureDetector.OnGestureListener() {
            @Override
            public boolean onDown(MotionEvent e) {
                return false;
            }

            @Override
            public void onShowPress(MotionEvent e) {

            }

            @Override
            public boolean onSingleTapUp(MotionEvent e) {
                if(onClickListener != null){
                    onClickListener.onClick(recyclerView);
                }
                return false;
            }

            @Override
            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
                return false;
            }

            @Override
            public void onLongPress(MotionEvent e) {

            }

            @Override
            public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
                return false;
            }
        });;

        recyclerView.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View view, MotionEvent motionEvent) {
                //发现只有点击了空白处,v是自身recyclerView
                if (view instanceof RecyclerView){
                    return  gestureDetector.onTouchEvent(motionEvent);
                }
                return false;
            }
        });
    }
}
  • 13
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值