RecyclerView 事件分發原理實戰分析

0xCAFEBOY發表於2019-07-14

前言

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

  1. RecyclerView 事件分發執行流程簡要分析
  2. 新增 OnItemTouchListener 為什麼不能解決問題?
  3. 該場景下最終的解決方案

業務需求

在一個視訊通話介面中,放置一個發言方列表,這個列表支援橫向滑動,稱為小窗列表, 處於背景的視窗稱之大窗,當使用者想將小窗列表中的某一個 item 切換到大窗時,可以使用手指觸控想要切換的 item, 並向上方滑動,即可將選定的小窗切換至大窗位置,而且上滑需要支援垂直向上和斜向上的方向。

RecyclerView 事件分發原理實戰分析

原始解決方案

解決方案

原始解決方案是為 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 內部抉擇。


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

相關文章