Android事件傳遞、多點觸控及滑動衝突的處理

浪淘沙xud發表於2019-01-13

Android事件傳遞、多點觸控及滑動衝突的處理

基本概念

  1. 所有Touch事件都會被封裝MotionEvent, 包括Touch的型別、位置(相對螢幕的絕對位置,相對View的相對位置)、時間、歷史記錄以及第幾個手指(多點觸控)等;
  2. 事件有多種型別,常用的事件型別有:ACTION_DOWN,ACTION_UP,ACTION_MOVE,ACTION_CANCEL 等;
  3. 對事件的處理包括三類: 事件傳遞,dispatchTouchEvent(); 攔截,onInterceptTouchEvent(); 消費,onTouchEvent()、OnTouchListener;

傳遞過程

網上有很多資料對事件的分發過程做了詳盡的程式碼追蹤,比如 www.jianshu.com/p/38015afcd…

有興趣的同學可以參考並去詳細走一下,這裡我做一個文字性描述:

傳遞細節描述

  1. 事件從 Activity.dispatchTouchEvent() 開始傳遞, 依次通過getWindow().superDispatchTouchEvent(event)、mDecor.superDispatchTouchEvent(event) 傳遞,即從Activity-> PhoneWindow ->DecorView, DecorView 是整個 ViewTree 的頂層 ViewGroup ;
  2. 在整個 ViewGroup 中,事件從頂層開始,依次往子View傳遞;
  3. 父 ViewGroup 可以通過 onInterceptTouchEvent() 對事件做攔截,阻止其往下傳遞;
  4. 如果未被攔截,則子 View 可以通過 onTouchEvent() 消費(處理)事件;
  5. 如果事件從上往下傳遞過程中一直沒有被攔截,且最底層子 View 沒有消費事件,事件會反向往上傳遞,這時父 ViewGroup 可以在 onTouchEvent() 中消費該事件,如果還是沒有被消費的話,最後會到 Activity 的 onTouchEvent() 函式;
  6. 底層View是具有事件的優先消費權的;
  7. 如果View 沒有對 ACTION_DOWN 進行消費,此次點選的後續事件不會傳遞過來;
  8. 如果 View 消費了 ACTION_DOWN ,此次點選的後續事件會直接給這個 View,這裡的後續事件指的是 ACTION_MOVE 和 ACTION_UP 事件;此時,其父 ViewGroup 的 onIntercept 函式仍會被呼叫,仍能進行攔截,但它自己的 onIntercept 不會被呼叫了;
  9. 子 View 可以在 onTouchEvent 中呼叫 getParent().requestDisallowInterceptTouchEvent(true),這樣父 ViewGroup 的 onIntercept 在後續的事件中就不會被呼叫了;
  10. 如果第一個事件即 ACTION_DOWN 就被父 ViewGroup 攔截了,子 View 將不會獲取到消費事件的機會;
  11. OnTouchListener 優先於 onTouchEvent() 對事件進行消費;
  12. 消費指的是相應的函式返回 true ;
  13. ViewGroup 才有 onIntercept 方法,View 是沒有的,即View不可以攔截事件;
  14. 所有的事件處理過程都是以 ACTION_DOWN 開始,ACTION_UP 或者 ACTION_CANCEL 結束,ACTION_UP 是事件正常處理邏輯的結束標誌,ACTION_CANCEL 是由父 ViewGroup 主動發出,當父 ViewGroup 攔截了除 ACTION_DOWN 之外的事件,會給正在消費 ACTION_DOWN 並等待後續事件的子 View 傳送一個 ACTION_CANCEL 事件,通知子 View 結束自己的事件等待;

TouchTarget

關於第7、8兩點,ViewGroup是如何在 dispatchTouchEvent 過程中快速命中並分發到對應子 View 的呢?這裡是通過 TouchTarget 這個結構來實現的。

private static final class TouchTarget {
        private static final int MAX_RECYCLED = 32;
        
        // 用於控制同步的鎖
        private static final Object sRecycleLock = new Object[0];
        
        // 注意這是static型別的,內部可複用例項連結串列表頭
        private static TouchTarget sRecycleBin;
        
        // 內部可複用的例項連結串列的長度
        private static int sRecycledCount;

        public static final int ALL_POINTER_IDS = -1; // all ones

        // 當前被觸控的 View
        public View child;
        
        // 對目標捕獲的所有指標的指標id的組合位掩碼
        public int pointerIdBits;

        // 連結串列中指向的下一個目標
        public TouchTarget next;

        private TouchTarget() {
        }

        ...
}
複製程式碼

在ViewGroup中維護了一個變數:mFirstTouchTarget,這是在 ViewGroup 中維護的連結串列, 用於記錄當前響應事件序列的子 View (一個事件序列對應一個響應它的子View),mFirstTouchTarget 指向連結串列首部。

先看一下 mFirstTouchTarget 的賦值:

// 這是發生在ViewGroup中的dispatchTouchEvent方法中
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
    ...
    mLastTouchDownX = ev.getX();
    mLastTouchDownY = ev.getY();
    newTouchTarget = addTouchTarget(child, idBitsToAssign);
}

// 當響應事件的目標child View新增到連結串列中,同時讓 mFirstTouchTarget 指向連結串列的表頭
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
    final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
    target.next = mFirstTouchTarget;
    mFirstTouchTarget = target;
    return target;
}
複製程式碼

再看 mFirstTouchTarget 在 dispatchTouchEvent 方法中的使用:

 	@Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        
        if (onFilterTouchEventForSecurity(ev)) {
            final int action = ev.getAction();
            final int actionMasked = action & MotionEvent.ACTION_MASK;

            1、如果事件是 ACTION_DOWN 事件,重置 touchTargets 狀態,在 cancelAndClearTouchTargets 方法中會發出 ACTION_CANCEL 事件
            if (actionMasked == MotionEvent.ACTION_DOWN) {
                cancelAndClearTouchTargets(ev);
                resetTouchState();
            }

            2、對於一個事件序列,當其中某一個事件成功攔截時,那麼對於剩下的一系列事件也會被攔截,並且不會再次執行onInterceptTouchEvent方法。如果 ACTION_DOWN 事件被攔截了,即當前ViewGroup的 onInterceptTouchEvent(ev) return true;此時 mFirstTouchTarget 必然為null,後續的事件都會當前 ViewGroup 攔截不再傳遞
            final boolean intercepted;
            if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
                final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
                if (!disallowIntercept) {
                    intercepted = onInterceptTouchEvent(ev);
                    ev.setAction(action); // restore action in case it was changed
                } else {
                    intercepted = false;
                }
            } else {
                intercepted = true;
            }

            3、如果事件既沒有cancel,也沒有被 intercept,遍歷子View進行事件分發
            if (!canceled && !intercepted) {
                ...
            }

            4、事件分發過程中,如果dispatchTouchEvent返回了false,或者說當前的ViewGroup沒有子元素的話,會走到這個邏輯。mFirstTouchTarget == null說明子View並沒有消費事件,所以沒有對mFirstTouchTarget進行賦值。這裡child == null,程式碼會進一步執行super.dispatchTouchEvent(event),即 View 中的 dispatchTouchEvent 方法
            if (mFirstTouchTarget == null) {
                // No touch targets so treat this as an ordinary view.
                handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
            } else {

            5、mFirstTouchTarget != null, 說明事件被子View消費,此時會依次將事件分發到 mFirstTouchTarget 儲存的連結串列 View中
                TouchTarget predecessor = null;
                TouchTarget target = mFirstTouchTarget;
                while (target != null) {
                    final TouchTarget next = target.next;
                    ...
                    target = next;
                }
            }
        }

        if (!handled && mInputEventConsistencyVerifier != null) {
            mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
        }
        return handled;
    }
複製程式碼

這個地方重點關注一下1、2、3、4、5幾個註釋點。現在我們回到7 8兩點。

如果View 沒有對 ACTION_DOWN 進行消費,此次點選的後續事件不會傳遞過來。這個很顯然,如果沒有對 ACTION_DOWN 進行消費,就不會被儲存到 TouchTarget 連結串列中,後續事件的分發是直接往這個連結串列中進行分發的。

如果 View 消費了 ACTION_DOWN ,此次點選的後續事件會直接給這個 View,這裡的後續事件指的是 ACTION_MOVE 和 ACTION_UP 事件;此時,其父 ViewGroup 的 onIntercept 函式仍會被呼叫,仍能進行攔截,但它自己的 onIntercept 不會被呼叫了。這個可以從第2點註釋中找到答案,如果事件被消費了,mFirstTouchTarget != null, 後續事件可以從mFirstTouchTarget 連結串列中直接分發,同時後續事件過來的時候會跳過intercepted 的判斷,所以自己的 onIntercept 就不會呼叫了。

RecyclerView 的事件傳遞

這裡以點選 RecyclerView 中的某個Item中的 Button 為例:

點下Button

  1. 產生了一個down事件,activity-->phoneWindow-->ViewGroup-->ListView-->botton,中間如果有重寫了攔截方法,則事件被該view攔截可能消耗;
  2. 沒攔截,事件到達了button,這個過程中建立了一條事件傳遞的view連結串列;
  3. 到button的dispatch方法-->onTouch-->view是否可用-->Touch代理;

移動點選按鈕的時候

  1. 產生move事件,RecyclerView 中會對move事件做攔截;
  2. 此時 RecyclerView 會將該滑動事件消費掉;
  3. 後續的滑動事件都會被 RecyclerView 消費掉;
  4. Button之前已經處理了 down 事件,現在還在等著後續事件,這個時候 RecyclerView 就會發出 cancel 事件通知Button不要再等了

手指抬起 前面建立了一個view連結串列,RecyclerView 的父view在獲取事件的時候,會直接取連結串列中的RecyclerView 讓其進行事件消耗

有興趣的同學可以帶著這個步驟去追蹤 RecyclerView 的原始碼。

多點觸控

多點觸控涉及到了多個手指點選事件的處理,這裡要增加兩個額外的事件

  1. ACTION_POINTER_DOWN:額外⼿手指按下(按下之前已經有別的⼿手指觸控到 View)
  2. ACTION_POINTER_UP:有⼿手指抬起,但不不是最後⼀一個(抬起之後,仍然還有別的⼿手指在觸控著 View)

事件型別: ACTION_POINTER_UP; active pointer index: 0; pointer: x: 200, y: 300, index: 0, id: 1; pointer: x: 300, y: 500, index: 1, id: 2

多點觸控觸控事件的結構

  1. 觸控事件是按序列列來分組的,每⼀一組事件必然以 ACTION_DOWN 開頭,以 ACTION_UP 或 ACTION_CANCEL 結束;
  2. ACTION_POINTER_DOWN 和 ACTION_POINTER_UP 和 ACTION_MOVE ⼀一樣,只是事件序列列中 的組成部分,並不不會單獨分出新的事件序列列;
  3. 同⼀一時刻,⼀一個 View 要麼沒有事件序列列,要麼只有⼀一個事件序列列;
  4. 多點觸控要解決的問題之一是:手指觸控的順序,手指的區分,這兩個問題通過 index 和 id 來區分;
  5. 多點觸控要解決的問題二:多點觸控時滑動了一個手指,這時候要知道動的是哪個

多點觸控的三種型別

  • 接⼒力力型 同⼀一時刻只有⼀一個 pointer 起作⽤用,即最新的 pointer。 典型:ListView、 RecyclerView。 實現⽅方式:在 ACTION_POINTER_DOWN 和 ACTION_POINTER_UP 時記錄下最 新的 pointer,在之後的 ACTION_MOVE 事件中使⽤用這個 pointer 來判斷位置。
  • 配合型 所有觸控到 View 的 pointer 共同起作⽤用。 典型:ScaleGestureDetector,以及 GestureDetector 的 onScroll() ⽅方法判斷。 實現⽅方式:在 每個 DOWN、POINTER_DOWN、POINTER_UP、UP 事件中使⽤用所有 pointer 的座標來共同更更新焦點座標,並在 MOVE 事件中使⽤用所有 pointer 的座標來判斷位置。
  • 各⾃自為戰型 各個 pointer 做不不同的事,互不不影響。 典型:⽀支援多畫筆的畫板應⽤用。 實現⽅方式: 在每個 DOWN、POINTER_DOWN 事件中記錄下每個 pointer 的 id,在 MOVE 事件中使⽤用 id 對 它們進⾏行行跟蹤。

滑動衝突處理

什麼是滑動衝突?就是父 View 和子 View 都需要處理滑動,例如父 View 需要左右滑動,子 View 需要上下滑動(ViewPager 巢狀 RecyclerView),一個點選事件,到底交給誰處理?

首先我們需要定義好處理規則,然後我們在父 View 的 onIntercept、子 View 的 onTouchEvent 以及父 View 的 onTouchEvent 函式中實現我們定義的規則即可。例如父 View 的 onIntercept 中,如果發現是左右滑動,那就攔截,否則不攔截。

NestedScrollView 巢狀 RecyclerView 也是一樣的道理,NestedScrollView 發現是上下滑動,就直接攔截並處理,RecyclerView 就沒有處理的機會了。

參考文章

Piasy:事件傳遞及滑動衝突的處理

簡書連結

相關文章