手把手教你如何寫事件處理的程式碼

大胃粥發表於2019-09-02

經過事件分發之View事件處理ViewGroup事件分發和處理原始碼分析這兩篇的的理論知識分析,我們已經大致的瞭解了事件的分發處理機制,但是這並不代表你就一定能寫好事件處理的程式碼。

既然我們有了基本功,那麼本文就通過一個案例來逐步分析事件處理的程式碼如何寫,事件衝突如何解決。

剖析事件分發的過程

為了模擬實際情況,我特意搞了一幅畫View各種巢狀的圖

View巢狀圖

圖中有一個MyViewGroup,它可以左右滑動,本文就用它來講解事件處理的程式碼如何寫。

後面的分析需要大家有前面兩篇文章的基礎,請務必理解清楚,否則你可能會覺得我在講天書。

ACTION_DOWN

由於我們操作的目標是MyViewGroup,因此我會把手指在MyViewGroup內容區域內按下,至於按在哪裡,其實無所謂,甚至在TextView上也行。此時系統會把ACTION_DOWN事件經過Activity傳遞給ViewGroup0,那麼問題來了ViewGroup0會不會截斷事件呢?

如果ViewGroup0截斷了ACTION_DOWN事件,那麼它的所有子View在這個事件序列結束前,將無法接收到任何事件,包括ACTION_DOWN事件。MyViewGroup就是ViewGroup0的子View,很顯然我們並不希望這樣的事情發生。如果真的發生從一開始就截斷ACTION_DOWN這樣的事情,那父View控制元件的程式碼寫的絕壁有問題。

事件序列是由ACTION_DOWN開始,由ACTION_UP或者ACTION_CANCEL結束,並且中間有0個或者多個ACTION_MOVE組成。

那麼有沒有截斷ACTION_DOWN事件的情況呢?當然有,ViewGroup必須處於一個合理的狀態,並且有理由截斷ACTION_DOWN事件。例如ViewPager,當手指在螢幕快速劃過後,頁面還處於滑動狀態,此時如果手指再次按下,ViewPager把這個ACTION_DOWN事件當做是停止滑動當前滑動並且重新開始滑動的指示,因此它有理由截斷這個ACTION_DOWN事件。

那麼,ViewGroup0在沒有任何合理狀態,並且還沒有任何合理理由的情況下,是絕不會截斷ACTION_DOWN事件的,因此它會把這個事件傳遞給MyViewGroup

MyViewGroup很高興接收到了第一個事件ACTION_DOWN,按照剛才講的規則,常規狀態下,是不截斷ACTION_DOWN事件的,但是如果MyViewGroup在滑動狀態中,並且手指已經離開螢幕,當再次按下手指的時候,我希望MyViewGroup截斷ACTION_DOWN事件的,因此onInterceptTouchEvent()方法的事件處理的框架程式碼應該這樣寫

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                // 1. 如果處於無狀態,預設不截斷
                // 2. 如果處於滑動狀態,截斷事件
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }
複製程式碼

現在討論的是事件處理的框架程式碼如何寫,因此沒有具體的程式碼。

你肯定以為ACTION_DOWN事件就這樣處理完了是吧,機智的我早已看穿一切

圖樣圖森破

MyViewGroup是需要實現滑動特性的,那麼它就必須要能接收到ACTION_MOVE事件。那麼ACTION_DOWN事件要如何處理,才能確保這個事情呢?必須滿足下面的一個條件

  1. MyViewGroup有一個子View處理了ACTION_DOWN事件。
  2. MyViewGroup自己處理ACTION_DOWN事件。

第一個條件呢,是最理想的情況,因為MyViewGroup在這種情況下,不用處理ACTION_DOWN事件就可以接收到ACTION_MOVE事件。

然而第一個條件,是不可控的,因此我們要做好最壞的打算,那就是MyViewGroup自己處理ACTION_DOWN。因此,在onTouchEvent()中處理ACTION_DOWN事件要返回true

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:

                // 自己處理ACTIOND_DOWN,必須返回true
                return true;
        }
        return super.onTouchEvent(event);
    }
複製程式碼

ACTION_MOVE

前面處理ACTION_DOWN已經確保了ACTION_MOVE可以順利接收,根據前面列出的2個保證條件,那麼接收ACTION_MOVE的情況如下

  1. MyViewGroup有一個子View處理了ACTION_DOWN,那麼ACTION_MOVE將會在onInterceptTouchEvent()中被接收。
  2. MyViewGroup自己處理了ACTION_DOWN,那麼ACTION_MOVE將會在onTouchEvent()中接收到。

對於第一種情況,其實有個限制條件,那就是子View必須允許MyViewGroup截斷事件,否則MyViewGroup將收不到ACTION_MOVE事件。如果出現這種情況,那你得檢查子控制元件的程式碼了是否寫的合理了。

首先討論第二種情況,如果ACTION_MOVEonTouchEvent()中接收到,那就代表MyViewGroup要自己處理事件來滑動,因此返回true

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_MOVE:

                // 自己處理ACTION_MOVE,返回true
                return true;
        }
        return super.onTouchEvent(event);
    }
複製程式碼

現在來繼續看第一種情況,ACTION_MOVE在傳送給處理了ACTION_DOWN的子View前,需要通過MyViewGrouponInterceptTouchEvent()方法,那麼MyViewGroup要不要截斷ACTION_MOVE事件呢?其實有很多種情況,我們來逐一分析可行性。

有人說,既然onInterceptTouchEvent()會一直接收ACTION_MOVE事件,那可以不截斷就直接執行滑動。表面上看MyViewGroup實現了滑動,但是在實際中可能遇到問題。假如子View也是一個滑動的控制元件,那麼在MyViewGroup滑動的時候,由於沒有截斷事件,因此子View同時也會根據自己的意願去滑動,這豈不是瞎搞嗎?又或者說子View在接收ACTION_MOVE事件後,請求父View不允許截斷後續的事件,那麼MyViewGroup後續就處理不了ACTION_MOVE事件了。

經過上面的分析,有人可能會說,一不做二不休,那就直接截斷得了。我只能說,這位施主你太沖動!

反思

如果直接粗暴的截斷,萬一遇上了不是完全垂直滑動的手勢,MyViewGroup卻在水平滑動,那豈不是尷尬了。

這時候,肯定有人忍不了了,截斷也不是,不截斷也不是,你想鬧哪樣!我們可以變通下嘛,我們要有條件的截斷,避免剛才的尷尬情況嘛,舉兩個常用的條件

  1. 達到滑動的臨界點
  2. 判斷手勢是水平滑動還是垂直滑動

那麼,在onInterceptTouchEvent()方法中關於是否截斷ACTION_MOVE的框架程式碼可以這樣寫

    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_MOVE:
                // 達到滑動標準就截斷,否則不截斷
                // 滑動標準如下
                // 1. 達到滑動的臨界距離
                // 2. 判斷手勢是水平滑動
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }
複製程式碼

ACTION_UP

我們先來討論下,ACTION_UP會在哪裡接收到

  1. MyViewGroup處理了ACTION_DOWNACTION_UP將會在onTouchEvent()中接收到。
  2. MyViewGroup在截斷ACTION_MOVE之前,ACTION_UP將會在onInterceptTouchEvent()中接收到。
  3. MyViewGroup截斷ACTION_MOVE後,ACTION_UP將會在onTouchEvent()中接收到。

第一種情況,返回true吧,因為畢竟是MyViewGroup自己處理了ACTION_UP事件。

第二種情況,返回false吧,因為此時MyViewGroup還沒有處理滑動事件呢。

第三種情況,返回true吧,因為畢竟是MyViewGroup自己處理了ACTION_UP事件。

從原始碼角度看,對於ACTION_UP事件的處理的返回值,好像並不太重要。 但是返回true還是false其實是向父View表明一個種態度,那就是我到底是不是處理了ACTION_UP事件。

ACTION_CANCEL

從前面文章分析可知,ACTION_CANCEL是在MyViewGroup的父View截斷了MyViewGroupACTION_MOVE事件後收到的,ACTION_CANCEL接收的地方其實和ACTION_UP是一樣,至於是處理還是不處理,根據實際中有沒有做實質的動作來相應的返回true或者false

完成案例程式碼

前面我們已經對每個事件到底處不處理進行了分析,並且寫出了事件處理的框架,那麼接下來,我們就可以在這個框架之下,很放心地完成MyViewGroup滑動特性的程式碼了。

ACTION_DOWN

在處理ACTION_DOWN的時候要做啥呢?當然是記錄手指按下時的座標。由於ACTION_DOWN一定會經過onInterceptTouchEvent(),所以在這裡記錄按下座標

    public boolean onInterceptTouchEvent(MotionEvent ev) {
        float x = ev.getX();
        float y = ev.getY();
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                // 記錄手指按下的座標
                mLastX = mStartX = x;
                mLastY = mStartY = y;
                // 1. 如果處於無狀態,預設不截斷
                // 2. 如果處於滑動狀態,截斷事件
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }
複製程式碼

mStartXmStartY表示手指按下的座標,mLastXmLastY表示最近一次事件的座標。

ACTION_MOVE

根據前面的分析,處理ACTION_MOVE有情況有如下幾種

  1. 如果MyViewGroup存在一個子View處理了ACTION_DOWN

    1. MyViewGroup截斷ACTION_MOVE之前,ACTION_MOVE將會在onInterceptTouchEvent()中接收。
    2. MyViewGroup截斷ACTION_MOVE之後,ACTION_MOVE將會在onTouchEvent()中接收。
  2. 如果MyViewGroup處理了ACTION_DOWN,那麼ACTION_MOVE將會在onTouchEvent()中接收。

第一種情況,根據前面的分析,我們將在onInterceptTouchEvent()根據條件來截斷ACTION_MOVE事件。

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        float x = ev.getX();
        float y = ev.getY();
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_MOVE:
                // 計算從手指按下時滑動的距離
                float distanceX = Math.abs(x - mStartX);
                float distanceY = Math.abs(y - mStartY);
                if (distanceX > mScaledTouchSlop && distanceX > 2 * distanceY) {
                    // 設定拖拽狀態
                    setState(SCROLLING_STATE_DRAGGING);
                    // 不允許父View截斷後續事件
                    requestDisallowIntercept(true);
                    // 執行一次拖拽的滑動
                    performDrag(x);
                    // 更新最新事件座標
                    mLastX = x;
                    mLastY = y;
                    // 截斷後續的事件
                    return true;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }
複製程式碼

根據我們的分析,要達到截斷ACTION_MOVE的標準才截斷後續的ACTION_MOVE事件,從程式碼中可以看出這個標準有兩條

  1. 水平滑動的距離要大於一個臨界值。
  2. 水平滑動的距離要大於兩倍的垂直滑動距離,這樣就排除了一些不標準的手勢。

當我們認為這是一次有效的滑動的時候,就要截斷後續的ACTION_MOVE事件,這就是程式碼中看到的return true的原因。

然而事情還沒有完,我們還做了一些優化動作

第一步,設定拖拽狀態。這是因為在截斷後續的ACTION_MOVE後,後續的ACTION_MOVE事件就會分發給MyViewGrouponTouchEvent(),而onTouchEvent()也要處理其他情況的拖拽,因此需要這個狀態判斷值。

第二步,請求父View不允許截斷後續ACTION_MOVE事件。因為MyViewGroup馬上要執行以系列的滑動動作,如果父View此時截斷了事件那肯定是不合適的,因此要通知父View不要搞事情。

第三步,執行一次滑動。可能很多人不理解為何要在onInterceptTouchEvent()中執行滑動動作,這個方法名義上只是用來判斷是否截斷事件的。

其實這裡是有原因的,由於要截斷後續的ACTION_MOVE事件,那麼這次的ACTION_MOVE事件是不會傳送到MyViewGrouponTouchEvent()中的,而是把這個ACTION_MOVE事件變為ACTION_CANCEL事件發給處理了ACTION_DOWN事件的子View。因此當前的ACTION_MOVE如果不在onInterceptTouchEvent()處理,那麼就會丟失這一次滑動處理。

截斷後續的ACTION_MOVE後,MyViewGrouponTouchEvent()會接收後續的ACTION_MOVE,那麼在這裡要繼續執行滑動

    public boolean onTouchEvent(MotionEvent event) {
        float x = event.getX();
        float y = event.getY();
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_MOVE:
                if (mState == SCROLLING_STATE_DRAGGING) {
                    // 處於滑動狀態就繼續執行滑動
                    performDrag(x);
                    mLastX = x;
                }
                return true;
        }
        return super.onTouchEvent(event);
    }
複製程式碼

至此,處理ACTION_MOVE的第一種情況已經處理完畢,我們現在來看下第二種情況,那就是MyViewGroup處理了ACTION_DOWN,所有的ACTION_MOVE事件都將交給MyViewGrouponTouchEvent()處理。那麼此時MyViewGroup還沒有滑動,因此需要再次判斷是否達到滑動標準

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float x = event.getX();
        float y = event.getY();
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_MOVE:
                if (mState == SCROLLING_STATE_DRAGGING) {
                    // 處於滑動狀態就繼續執行滑動
                    performDrag(x);
                    // 更新最新座標點
                    mLastX = x;
                } else {
                    // 不處於滑動狀態,就再次檢測是否達滑動標準
                    float distanceX = Math.abs(x - mLastX);
                    float distanceY = Math.abs(y - mLastY);
                    if (distanceX > mScaledTouchSlop && distanceX > 2 * distanceY) {
                        setState(SCROLLING_STATE_DRAGGING);
                        requestDisallowIntercept(true);
                        performDrag(x);
                        mLastX = x;
                    }
                }
                return true;
        }
        return super.onTouchEvent(event);
    }
複製程式碼

ACTION_UP

對於ACTION_UP事件,我們先來預想下發生的情況

  1. 沒有截斷ACTION_MOVE事件之前,ACTION_UP事件會先由onInterceptTouchEvent()處理。
  2. 截斷ACTION_MOVE事件之後,ACTION_UP事件會由onTouchEvent()處理。
  3. MyViewGroup處理了ACTION_DOWN事件,ACTION_UP事件全部會由onTouchEvent()處理。

第一種情況,由於MyViewGroup還沒有產生滑動,因此不需要處理此種情況下手指抬起事件。

第二種情況,MyViewGroup已經產生滑動,如果MyViewGroup是一個像ViewPager一樣的頁面式的滑動,那麼當手指抬起時,它需要進行一些頁面定位操作,也就是決定滑動到哪個頁面。

第三種情況,其實就是第一種情況和第二種情況的綜合版而已。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float x = event.getX();
        float y = event.getY();
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_UP:
                if (mState == SCROLLING_STATE_DRAGGING) {
                    setState(SCROLLING_STATE_SETTING);
                    // 使用Scroller進行定位操作
                    int contentWidth = getWidth() - getHorizontalPadding();
                    int scrollX = getScrollX();
                    int targetIndex = (scrollX + contentWidth / 2) / contentWidth;
                    mScroller.startScroll(scrollX, 0, targetIndex * contentWidth - scrollX, 0);
                    invalidate();
                }
                return true;
        }
        return super.onTouchEvent(event);
    }
複製程式碼

ACTION_CANCEL

ACTION_CANCEL這個事件比較特殊,按照正常流程看,是由於父View截斷了MyViewGroupACTION_MOVE事件後,把ACTION_MOVE變為了ACTION_CANCEL,然後傳送給MyViewGroup

如果MyViewGroup在進行滑動之前,會先請求父View不允許截斷它的事件,也就是說之後父View不可能截斷ACTION_MOVE事件,也就是不可能傳送ACTION_CANCEL事件。

如果MyViewGroup還沒開始滑動,那麼MyViewGroup就可能會收到ACTION_CANCEL事件,然而此時不用做任何處理動作,因為MyViewGroup還沒有滑動產生狀態呢。

這是一種正常情況下的純理論分析,不排除異常情況。

截斷ACTION_DOWN

現在,我們回過頭來處理MyViewGroup截斷ACTION_DOWN的情況,前面我們說過,如果手指抬起,MyViewGroup還是處於滑動狀態,在我們這個例子中叫做定位狀態,那麼當手指按下時,就需要截斷事件,因為MyViewGroup認為這個時候的按下動作是為了停止當前滑動,並用手指控制滑動

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        float x = ev.getX();
        float y = ev.getY();
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                // 重置狀態
                setState(SCROLLING_STATE_IDLE);
                // 記錄手指按下的座標
                mLastX = mStartX = x;
                mLastY = mStartY = y;
                // 1. 如果處於無狀態,預設不截斷
                // 2. 如果處於滑動狀態,截斷事件
                if (!mScroller.isFinished()) {
                    // 停止定位動作
                    mScroller.abortAnimation();
                    // 設定拖拽狀態
                    setState(SCROLLING_STATE_DRAGGING);
                    // 不允許父View截斷後續事件
                    requestDisallowIntercept(true);
                    return true;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }
複製程式碼

MyViewGroup截斷ACTION_DOWN事件後,那麼後續的的ACTION_MOVE事件就由onTouchEvent()來進行滑動處理,這個過程在前面已經實現。

結束

本文先從理論上搭建了事件處理的框架,然後用一個簡單的例子實現了這個框架。如果大家在看本文的時候有任何疑問,請先參考前面兩篇文章的分析,如果還是有疑問,歡迎在評論裡留言討論。

詳細原始碼請參考github,實現的效果如下

觸控事件

為了測試,我在第一個頁面放置了一個Button,然後點選Button開始滑動,可以看到Button並沒有相應點選事件。然後在第二個頁面返回第一個頁面時,只有滑動超過了一半的寬度,才會自動滑動到第一頁面。

相關文章