RecyclerView 事件分發原理實戰分析

前言

最近在解決 RecyclerView 滑動衝突問題時,遇到了使用 OnItemTouchLister 沒法解決問題的場景,本篇文章將結合實際案例,重點介紹以下幾個問題:java

  1. RecyclerView 事件分發執行流程簡要分析
  2. 添加 OnItemTouchListener 爲何不能解決問題?
  3. 該場景下最終的解決方案

業務需求

在一個視頻通話界面中,放置一個發言方列表,這個列表支持橫向滑動,稱爲小窗列表, 處於背景的窗口稱之大窗,當用戶想將小窗列表中的某一個 item 切換到大窗時,可使用手指觸摸想要切換的 item, 並向上方滑動,便可將選定的小窗切換至大窗位置,並且上滑須要支持垂直向上和斜向上的方向。 算法

原始解決方案

解決方案

原始解決方案是爲 item view 設置 OnTouchListener 方法, 在其 onTouch() 方法中的 ACTION_MOVE 事件中判斷 dy (Y 座標偏移量) 是否大於某個閾值。app

遇到的問題

遇到的問題是當在 item 斜向上滑動時,item view 收到的 ACTION_MOVE 事件的 dy 老是特別小,即便你肯定已經滑動了不少時ide

問題定位 & 懷疑

  • 該問題定位爲 在橫向滑動時,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 的做用主要有兩個:this

  1. 在 RecyclerView 對事件消費以前,給予開發者自定義事件分發算法的權利。
  2. 當 RecyclerView 已經在對事件消費過程當中時,能夠經過本類對 RecylerView 正在處理的事件序列進行攔截。

本文提到的問題看似能夠解決,思路就是爲 RecyclerView 添加 OnItemTouchListener, 在其 onInterceptTouchEvent(RecyclerView rv, MotionEvent e) 調用時判斷,若是 Y 軸的偏移量大於某一閾值,代表當前用戶想觸發窗口置換操做,那麼就在 onInterceptTouchEvent() 中返回 false, 咱們指望 RecyclerView 徹底不消費事件,使事件下沉到 RecyclerView 的 item view 中,那麼 item 就能夠正常獲取到 MOVE 事件,部分代碼以下:spa

/** * 縱座標偏移量閾值 */
    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() 會被調用。進而實現窗口置換,下面咱們來經過源碼分析爲何這種方案不能實現。code

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 攔截。orm

b. 遍歷全部註冊過的 OnItemTouchListener,若是當前事件不是 ACTION_CANCEL ,調用 OnItemTouchListeneronInterceptTouchEvent() , 並 return true, 表示 RecyclerView 攔截了這個 事件序列,根據事件分發規則,事件被分發到 RecyclerView 的 onTouchEvent() 中,若是知足滑動條件,RecyclerView 會對其進行消費,使自身滑動。cdn

添加 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 內部抉擇。


堅持不易,您的點贊是我寫做的最大動力!

相關文章
相關標籤/搜索