RecyclerView 事件分发原理实战分析

4,074 阅读4分钟

您的点赞和关注是我坚持写作的最大动力,本人正在寻找测试开发的工作机会,欢迎微信联系: gyx764884989

前言

最近在解决 RecyclerView 滑动冲突问题时,遇到了使用 OnItemTouchLister 无法解决问题的场景,本篇文章将结合实际案例,重点介绍如下几个问题:

  1. RecyclerView 事件分发执行流程简要分析
  2. 添加 OnItemTouchListener 为什么不能解决问题?
  3. 该场景下最终的解决方案

业务需求

在一个视频通话界面中,放置一个发言方列表,这个列表支持横向滑动,称为小窗列表, 处于背景的窗口称之大窗,当用户想将小窗列表中的某一个 item 切换到大窗时,可以使用手指触摸想要切换的 item, 并向上方滑动,即可将选定的小窗切换至大窗位置,而且上滑需要支持垂直向上和斜向上的方向。

原始解决方案

解决方案

原始解决方案是为 item view 设置 OnTouchListener 方法, 在其 onTouch() 方法中的 ACTION_MOVE 事件中判断 dy (Y 坐标偏移量) 是否大于某个阈值。

遇到的问题

遇到的问题是当在 item 斜向上滑动时,item view 收到的 ACTION_MOVE 事件的 dy 总是特别小,即使你确定已经滑动了很多时

问题定位 & 怀疑

  • 该问题定位为 在横向滑动时,RecyclerView 与 item 发生了嵌套滑动冲突
  • 怀疑是 RecyclerView 消费了部分滑动事件,导致 item view 收到的滑动距离特别小。

尝试新的解决方案

通过翻阅源码发现,RecyclerView 内部提供了 OnItemTouchListener, 介绍如下:

    /**
     * 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.
     *
     * <p>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.</p>
     *
     * @see SimpleOnItemTouchListener
     */
    public static interface OnItemTouchListener{
        /**
         * Silently observe and/or take over touch events sent to the RecyclerView
         * before they are handled by either the RecyclerView itself or its child views.
         *
         * <p>The onInterceptTouchEvent methods of each attached OnItemTouchListener will be run
         * in the order in which each listener was added, before any other touch processing
         * by the RecyclerView itself or child views occurs.</p>
         *
         * @param e MotionEvent describing the touch event. All coordinates are in
         *          the RecyclerView's coordinate system.
         * @return true if this OnItemTouchListener wishes to begin intercepting touch events, false
         *         to continue with the current behavior and continue observing future events in
         *         the gesture.
         */
        public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e);

        ...
    }

OnItemTouchListener 的作用主要有两个:

  1. 在 RecyclerView 对事件消费之前,给予开发者自定义事件分发算法的权利。
  2. 当 RecyclerView 已经在对事件消费过程中时,可以通过本类对 RecylerView 正在处理的事件序列进行拦截。

本文提到的问题看似可以解决,思路就是为 RecyclerView 添加 OnItemTouchListener, 在其 onInterceptTouchEvent(RecyclerView rv, MotionEvent e) 调用时判断,如果 Y 轴的偏移量大于某一阈值,表明当前用户想触发窗口置换操作,那么就在 onInterceptTouchEvent() 中返回 false, 我们期望 RecyclerView 完全不消费事件,使事件下沉到 RecyclerView 的 item view 中,那么 item 就可以正常获取到 MOVE 事件,部分代码如下:

    
    /**
     * 纵坐标偏移量阈值
     */
    private final int Y_AXIS_MOVE_THRESHOLD = 15;
    private int downY = 0;

    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {

        if (e.getAction() == MotionEvent.ACTION_DOWN) {
            downY = (int) e.getRawY();

        } else if (e.getAction() == MotionEvent.ACTION_MOVE) {
            int realtimeY = (int) e.getRawY();
            int dy = Math.abs(downY - realtimeY);

            if (dy > Y_AXIS_MOVE_THRESHOLD) {
                return false;
            }
        }
        return true;
    }

但其实这样是无法实现需求的,因为如果按照我们目前的实现方案,是期望在 dy 大于阈值时,RecyclerView 可以完全对 MOVE 事件放手,将事件下沉到 item view 中去处理,根据事件分发规则,这就需要 RecyclerView 的 onInterceptTouchEvent() return false,然后子 View 即 item view 的 onTouchEvent() 会被调用。进而实现窗口置换,下面我们来通过源码分析为什么这种方案不能实现。

RecyclerView 事件分发代码分析

    @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 boolean canScrollHorizontally = mLayout.canScrollHorizontally();
        final boolean canScrollVertically = mLayout.canScrollVertically();

        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(e);

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

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

                if (mScrollState == SCROLL_STATE_SETTLING) {
                    getParent().requestDisallowInterceptTouchEvent(true);
                    setScrollState(SCROLL_STATE_DRAGGING);
                }

                // Clear the nested offsets
                mNestedOffsets[0] = mNestedOffsets[1] = 0;

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

            case MotionEventCompat.ACTION_POINTER_DOWN:
                mScrollPointerId = e.getPointerId(actionIndex);
                mInitialTouchX = mLastTouchX = (int) (e.getX(actionIndex) + 0.5f);
                mInitialTouchY = mLastTouchY = (int) (e.getY(actionIndex) + 0.5f);
                break;

            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;
                }

                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 = mInitialTouchX + mTouchSlop * (dx < 0 ? -1 : 1);
                        startScroll = true;
                    }
                    if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
                        mLastTouchY = mInitialTouchY + mTouchSlop * (dy < 0 ? -1 : 1);
                        startScroll = true;
                    }
                    if (startScroll) {
                        setScrollState(SCROLL_STATE_DRAGGING);
                    }
                }
            } break;

            case MotionEventCompat.ACTION_POINTER_UP: {
                onPointerUp(e);
            } break;

            case MotionEvent.ACTION_UP: {
                mVelocityTracker.clear();
                stopNestedScroll();
            } break;

            case MotionEvent.ACTION_CANCEL: {
                cancelTouch();
            }
        }
        return mScrollState == SCROLL_STATE_DRAGGING;
    }

分析

  1. mLayoutFrozen 用于标识 RecyclerView 是否禁用了 layout 过程和 scroll 能力,RecyclerView 提供了对其设置的方法setLayoutFrozen(boolean frozen), 如果 mLayoutFrozen 被标识为 true, RecyclreView 会发生如下变化:
  • 所有对 RecyclerView 的 Layout 请求会被推迟执行,直到 mLayoutFrozen 再度被设置 false
  • 子 View 也不会被刷新
  • RecyclerView 也不会响应滑动的请求,即不会响应 smoothScrollBy(int, int), scrollBy(int, int), scrollToPosition(int), smoothScrollToPosition(int)
  • 不响应 Touch Event 和 GenericMotionEvents
  1. 如果 RecyclerView 设置了 OnItemTouchListener, 则在 RecyclerView 自身滑动前,调用 dispatchOnItemTouchIntercept(MotionEvent e) 进行分发,代码如下:
    private boolean dispatchOnItemTouchIntercept(MotionEvent e) {
        final int action = e.getAction();
        if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_DOWN) {
            mActiveOnItemTouchListener = null;
        }

        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) {
                mActiveOnItemTouchListener = listener;
                return true;
            }
        }
        return false;
    }

a. mActiveOnItemTouchListenerOnItemTouchListener 类型的对象,如果收到了 ACTION_CANCEL 或者 ACTION_DOWN 事件,则将回调置 null, 清除上个事件序列对本次事件序列的影响,那我们什么时候会收到 ACTION_CANCEL 事件呢?答案是当子 View 正在消费 ACTION_MOVE 事件时,如果父 View 在 onInterceptTouchEvent() 中 return true, 那么子 View 会收到 ACTION_CANCEL 事件,而且这个 ACTION_CANCEL 事件无法被父 View 拦截。

b. 遍历所有注册过的 OnItemTouchListener,如果当前事件不是 ACTION_CANCEL ,调用 OnItemTouchListeneronInterceptTouchEvent() , 并 return true, 表示 RecyclerView 拦截了这个 事件序列,根据事件分发规则,事件被分发到 RecyclerView 的 onTouchEvent() 中,如果满足滑动条件,RecyclerView 会对其进行消费,使自身滑动。

添加 OnItemTouchListener 为什么不能解决问题?

通过以上线索,我们得到了答案,为什么在 OnItemTouchListener 的方案会失败会失败,

  1. 如果 listener.onInterceptTouchEvent(this, e) return true, 则 RecyclerView 的 onInterceptTouchEvent() 会 return true, 事件转向了 RecyclerView 的 onTouchEvent() 被消费。
  2. 如果 listener.onInterceptTouchEvent(this, e) return false, 则 RecyclerView 还是继续会对这组 MOVE 事件做处理,最终事件转向了 RecyclerView 的 onTouchEvent() 被消费。

最终解决方案

最终结局方案其实和使用 OnItemTouchListeneronInterceptTouchEvent 一致,不同的是,这次我们新建一个 RecyclerView 的子类,重写RecyclerView的 onInterceptTouchEvent,具体代码如下:


/**
 * 自定义 RecyclerView ,在某些场景下拦截其横向水平移动
 * Designed by 0xCAFEBOY
 */
public class InterceptHScrollRecyclerView extends RecyclerView {
    private final String TAG = InterceptHScrollRecyclerView.class.getSimpleName();
    /**
     * 纵坐标偏移量阈值,超过这个
     */
    private final int Y_AXIS_MOVE_THRESHOLD = 15;

    public InterceptHScrollRecyclerView(Context context) {
        super(context);
    }

    public InterceptHScrollRecyclerView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public InterceptHScrollRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    int downY = 0;

    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {


        if (e.getAction() == MotionEvent.ACTION_DOWN) {
            downY = (int) e.getRawY();

        } else if (e.getAction() == MotionEvent.ACTION_MOVE) {
            int realtimeY = (int) e.getRawY();
            int dy = Math.abs(downY - realtimeY);


            if (dy > Y_AXIS_MOVE_THRESHOLD) {
                return false;
            }

        }


        return super.onInterceptTouchEvent(e);
    }
}

为什么这个方案可以解决问题,是因为如果使用继承的话,这段代码相当于在 RecyclerView 执行事件分发流程之前插入了一段代码,有点 AOP 的感觉,如果 return false, 可以彻底避免 RecyclerView 接管事件,从而实现目的,注意最后这行代码,

    return super.onInterceptTouchEvent(e);

不能直接返回 true, 因为如果不拦截的话,具体的返回值还是 RecyclerView 内部抉择。


坚持不易,您的点赞是我写作的最大动力!