View的事件分發機制和滑動沖突解決方案

本篇文章已授權微信公眾號 hongyangAndroid (鴻洋)獨家發布
這篇文章會先講Android中View的事件分發機制,然后再介紹Android滑動沖突的形成原因并給出解決方案。因水平有限,講的不會太過深入,只希望各位看了之后對事件分發機制的流程有個大概的概念,并且以后能自己解決有關滑動沖突的問題,用語淺薄,文筆生疏,見諒。

View的事件分發機制

View的事件分發機制說白了就是點擊事件的傳遞,也就是一個Down事件,若干個Move事件,一個Up事件構成的事件序列的傳遞。

當你手指按了屏幕,點擊事件就會遵循Activity->Window->View這一順序傳遞。

這一傳遞過程有三個重要的方法,分別是:
boolean dispatchTouchEcent(MotionEvent ev),
boolean onInterceptTouchEvent(MotionEvent event),
boolean onTouchEvent(MotionEvent event)

先一個一個簡單介紹下:

dispatchTouchEcent:

只要事件傳遞到了當前View,那么dispatchTouchEcent方法就一定會被調用。返回結果表示是否消耗當前事件。

onInterceptTouchEvent:

在dispatchTouchEcent方法內部調用此方法,用來判斷是否攔截某個事件。如果當前View攔截了某個事件,那么在這同一個事件序列中,此方法不會再次被調用。返回結果表示是否攔截當前事件。

onTouchEvent:

在dispatchTouchEcent方法內調用此方法,用來處理事件。返回結果表示是否處理當前事件,如果不處理,那么在同一個事件序列里面,當前View無法再收到后續的事件。

上面的解釋聽起來比較抽象,我們可以用一段偽代碼來表示上面三個方法的關系:

public boolean dispatchTouchEvent(MotionEvent ev){
    boolean consum = false;
    if(onInterceptTouchEvent(ev)){
        consum = onTouchEvent(ev);
    }else{
        consum = child.dispatchTouchEvent(ev);
    }
    
    return consum;
}

上面代碼很好的解釋了三個方法之間的關系,我們也可以從代碼中大致摸索到事件傳遞的順序規則:當點擊事件傳遞到根ViewGroup里,會執行dispatchTouchEvent,在其內部會先調用onInterceptTouchEvent詢問是否攔截事件,若攔截,則執行onTouchEvent方法處理這個事件;若不攔截,則執行子元素的dispatchTouchEvent,進入向下分發的傳遞,直到事件被處理。

在處理一個事件的時候,是有優先級的,如果設置了OnTouchListener,會先執行其內部的onTouch方法,這時若onTouch方法返回true,那么表示事件被處理了,不會向下傳遞了;如果返回了false,那么事件會繼續傳遞給onTouchEvent方法處理,在onTouchEvent方法中如果當前設置了OnClickListener,那么就會調用其onClick方法。所以其優先級為:OnTouchListen>onTouchEvent>OnClickListen。

這里有一種情況,如果一個View的onTouchEvent返回了false,那么它父容器的onTouchEvent方法將會被調用。我們寫個例子來試一下:

@Override
public boolean onTouchEvent(MotionEvent event) {
    Toast.makeText(mContext, "Button", Toast.LENGTH_SHORT).show();
    return false;
}

先自定義一個Button,重寫其onTouchEvent方法返回false。在自定義一個MyTouchView作為父布局。效果如下:

大家可以自己試試。

既然如此,在開頭我們說過事件的傳遞順序是Activity->Window->View,所以如果所有的元素都返回了false,那么最后事件就會再次傳遞到Activity里,由Activity的onTouchEvent方法來處理。

《Android開發藝術探索》這本書里總結了11條關于事件傳遞的結論:

1:同一個事件序列是指手機接觸屏幕那一刻起,到離開屏幕那一刻結束,有一個down事件,若干個move事件,一個up事件構成。

2:某個View一旦決定攔截事件,那么這個事件序列之后的事件都會由它來處理,并且不會再調用onInterceptTouchEvent。

3:正常情況下,一個事件序列只能被一個View攔截并消耗。這個原因可以參考第2條,因為一旦攔截了某個事件,那么這個事件序列里的其他事件都會交給這個View來處理,所以同一事件序列中的事件不能分別由兩個View同時處理,但是我們可以通過特殊手段做到,比如一個View將本該自己處理的事件通過onTouchEvent強行傳遞給其他View處理。

4:一個View如果開始處理事件,如果它不處理down事件(onTouchEvent里面返回了false),那么這個事件序列的其他事件就不會交給它來繼續處理了,而是會交給它的父元素去處理。

5:如果一個View處理了down事件,卻沒有處理其他事件,那么這些事件不會交給父元素處理,并且這個View還能繼續受到后續的事件。而這些未處理的事件,最終會交給Activity來處理。

6:ViewGroup的onInterceptToucheEvent默認返回false,也就是默認不攔截事件。

7:View沒有InterceptTouchEvent方法,如果有事件傳過來,就會直接調用onTouchEvent方法。

8:View的onTouchEvent方法默認都會消耗事件,也就是默認返回true,除非他是不可點擊的(longClickable和clickable同時為false)。

9:View的enable屬性不會影響onTouchEvent的默認返回值。就算一個View是不可見的,只要他是可點擊的(clickable或者longClickable有一個為true),它的onTouchEvent默認返回值也是true。

10:onClick方法會執行的前提是當前View是可點擊的,并且它收到了down和up事件。

11:事件傳遞過程是由外向內的,也就是事件會先傳給父元素在向下傳遞給子元素。但是子元素可以通過requestDisallowInterceptTouchEvent來干預父元素的分發過程,但是down事件除外(因為down事件方法里,會清除所有的標志位)。

滑動沖突

介紹完了事件分發機制的基本流程,我們來看看滑動沖突。滑動沖突的基本形式分為兩種,其他復雜的滑動沖突都可以拆成這兩種基本形式:

1:外部滑動方向與內部方向不一致。

2:外部方向與內部方向一致。

先來看第一種,比如你用ViewPaper和Fragment搭配,而Fragment里往往是一個豎直滑動的ListView,這種情況是就會產生滑動沖突,但是由于ViewPaper本身已經處理好了滑動沖突,所以我們無需考慮,不過若是換成ScrollView,我們就得自己處理滑動沖突了。圖示如下:

再看看第二種,這種情況下,因為內部和外部滑動方向一致,系統會分不清你要滑動哪個部分,所以會要么只有一層能滑動,要么兩層一起滑動得很卡頓。圖示如下:

對于這兩種情況,我們處理的方法也很簡單,并且都有相應的套路。
第一種:第一種的沖突主要是一個橫向一個豎向的,所以我們只要判斷滑動方向是豎向還是橫向的,再讓對應的View滑動即可。判斷的方法有很多,比如豎直距離與橫向距離的大小比較;滑動路徑與水平形成的夾角等等。

第二種:對于這種情況,比較特殊,我們沒有通用的規則,得根據業務邏輯來得出相應的處理規則。舉個最常見的例子,ListView下拉刷新,需要ListView自身滑動,但是當滑動到頭部時需要ListView和Header一起滑動,也就是整個父容器的滑動。如果不處理好滑動沖突,就會出現各種意想不到情況。

滑動沖突的處理方法

滑動沖突的攔截方法有兩種:
一種是讓事件都經過父容器的攔截處理,如果父容器需要則攔截,如果不需要則不攔截,成為外部攔截法,其偽代碼如下:

public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted = false;
    int x = (int)event.getX();
    int y = (int)event.getY();
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN: {
            intercepted = false;
           break;
       }
       case MotionEvent.ACTION_MOVE: {
           if (滿足父容器的攔截要求) {
                intercepted = true;
           } else {
                intercepted = false;
           }
           break;
       }
       case MotionEvent.ACTION_UP: {
           intercepted = false;
           break;
       }
       default:
           break;
       }
            mLastXIntercept = x;
            mLastYIntercept = y;
            return intercepted;
}

在這里,首先down事件父容器必須返回false ,因為若是返回true,也就是攔截了down事件,那么后續的move和up事件就都會傳遞給父容器,子元素就沒有機會處理事件了。其次是up事件也返回了false,一是因為up事件對父容器沒什么意義,其次是因為若事件是子元素處理的,卻沒有收到up事件會讓子元素的onClick事件無法觸發。

另一種是父容器不攔截任何事件,將所有事件傳遞給子元素,如果子元素需要則消耗掉,如果不需要則通過requestDisallowInterceptTouchEvent方法交給父容器處理,稱為內部攔截法,使用起來稍顯麻煩。偽代碼如下:
首先我們需要重寫子元素的dispatchTouchEvent方法:

  @Override
 public boolean dispatchTouchEvent(MotionEvent event) {
     int x = (int) event.getX();
     int y = (int) event.getY();

     switch (event.getAction()) {
     case MotionEvent.ACTION_DOWN: {
         parent.requestDisallowInterceptTouchEvent(true);
         break;
     }
     case MotionEvent.ACTION_MOVE: {
         int deltaX = x - mLastX;
         int deltaY = y - mLastY;
         if (父容器需要此類點擊事件) {
             parent.requestDisallowInterceptTouchEvent(false);
         }
         break;
     }
     case MotionEvent.ACTION_UP: {
         break;
     }
     default:
         break;
     }

     mLastX = x;
     mLastY = y;
     return super.dispatchTouchEvent(event);
 }  

然后修改父容器的onInterceptTouchEvent方法:

 @Override
 public boolean onInterceptTouchEvent(MotionEvent event) {

     int action = event.getAction();
     if (action == MotionEvent.ACTION_DOWN) {
         return false;
     } else {
         return true;
     }
 }  

這里父容器也不能攔截down事件。

看代碼看不出所以然,我們通過實例來看看滑動沖突是怎么樣的。我們先模擬第一種場景,內外滑動方向不一致,我們先自定義一個父控件,讓其可以左右滑動,類似于ViewPaper:

然后將里面換成三個ListView:


可以看到左右滑動失效了,說明確實沖突了。那么我們就來解決一下,首先我們要明白滑動規則是什么,這個例子中如果我們豎直滑動就讓ListView消耗事件,水平滑動就讓我們自定義的父容器滑動。知道了這個我們只需要將其替換到之前偽代碼里的攔截條件里即可。

先用外部攔截法:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted = false;
    int x = (int) event.getX();
    int y = (int) event.getY();

    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN: {
            Log.d(TAG, "onInterceptTouchEvent: ACTION_DOWN");
            intercepted = false;
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            Log.d(TAG, "onInterceptTouchEvent: ACTION_MOVE");
            int deltaX = x - mLastXIntercept;
            int deltaY = y - mLastYIntercept;
            if (Math.abs(deltaX) > Math.abs(deltaY)) {
                intercepted = true;
            } else {
                intercepted = false;
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            intercepted = false;
            break;
        }
        default:
            break;
    }

    Log.d(TAG, "intercepted=" + intercepted);
    mLastX = x;
    mLastY = y;
    mLastXIntercept = x;
    mLastYIntercept = y;

    return intercepted;
}  

這里我們判斷橫向滑動的距離與豎直滑動距離的長短。若是豎直滑動的長,則判斷為豎直滑動,那么就是ListView的滑動,就將intercepted置為false,讓父容器不攔截,交由子元素ListView處理。若是橫向,則intercepted置為true,交由父容器處理。

效果如下:

接下來看看內部攔截法:
先自定義一個MyListView繼承ListView,重寫其dispatchTouchEvent方法:

 @Override
 public boolean dispatchTouchEvent(MotionEvent event) {
     int x = (int) event.getX();
     int y = (int) event.getY();

     switch (event.getAction()) {
     case MotionEvent.ACTION_DOWN: {
         mHorizontalScrollViewEx.requestDisallowInterceptTouchEvent(true);
         break;
     }
     case MotionEvent.ACTION_MOVE: {
         int deltaX = x - mLastX;
         int deltaY = y - mLastY;
         Log.d(TAG, "dx:" + deltaX + " dy:" + deltaY);
         if (Math.abs(deltaX) > Math.abs(deltaY)) {
             mHorizontalScrollViewEx.requestDisallowInterceptTouchEvent(false);
         }
         break;
     }
     case MotionEvent.ACTION_UP: {
         break;
     }
     default:
         break;
     }

     mLastX = x;
     mLastY = y;
     return super.dispatchTouchEvent(event);
 }  

再重寫外部父容器的oninterceptTouchEvent方法:

  @Override
  public boolean onInterceptTouchEvent(MotionEvent event) {
      int x = (int) event.getX();
      int y = (int) event.getY();
      int action = event.getAction();
      if (action == MotionEvent.ACTION_DOWN) {
          mLastX = x;
          mLastY = y;
          return false;
      } else {
          return true;
      }
  }  

效果和外部攔截法一樣。

接下來看看同方向的滑動沖突,這里我們用一個豎直的ScrollView嵌套一個ListView做例子。首先看看沒有解決滑動沖突的時候是咋樣的:

我們看到只要ScrollView可以滑動,內部的ListView是不能滑動的。那我們現在來解決這個問題,同向滑動沖突和與不同向滑動沖突不一樣,得根據實際的需求來確定攔截的規則。這里我們的需求是當ListView滑到頂部了,并且繼續向下滑就讓ScrollView攔截掉;當ListView滑到底部了,并且繼續向下滑,就讓ScrollView攔截掉,其余時候都交給ListView自身處理事件。
首先用外部攔截法,我們需要重寫ScrollView的onInterceptTouchEvent方法,代碼如下:

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted = false;
    int y = (int) event.getY();

    switch (event.getAction()) {

        case MotionEvent.ACTION_DOWN: {
            nowY = y;
            intercepted = super.onInterceptTouchEvent(event);
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            if(mListView.getFirstVisiblePosition()==0
                    && y>nowY){
                intercepted = true;
                break;
            }
            else if(mListView.getLastVisiblePosition()==mListView.getCount()-1
                    && y<nowY){
                intercepted = true;
                break;
            }
            intercepted = false;
            break;
        }
        case MotionEvent.ACTION_UP: {
            intercepted = false;
            break;
        }
        default:
            break;
    }

    return intercepted;
} 

這里我們看到Down事件里我們并沒有返回false而是返回了super.onInterceptTouchEvent(event),這是因為ScrollView在Down方法時需要初始化一些參數如果我們直接返回false,會導致滑動出現問題。并且前面說過ViewGroup
的onInterceptTouchEvent方法是默認返回false的,所以我們這里直接返回super方法即可。
處理了滑動沖突后效果如下:

接下來看看內部攔截法:
先重寫ScrollView的onInterceptTouchEvent方法,讓其攔截除了Down事件以外的其他方法:

@Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        int action = event.getAction();
        if (action == MotionEvent.ACTION_DOWN) {
            mLastX = x;
            mLastY = y;
            return super.onInterceptTouchEvent(event);
        } else {
            return true;
        }
    }

在重寫ListView的dispatchTouchEvent方法,規則已經說明過了:

 @Override
public boolean dispatchTouchEvent(MotionEvent event) {
    int y = (int) event.getY();

    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN: {
            nowY = y;
            mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(true);
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            if(this.getFirstVisiblePosition()==0
                    && y>nowY){
                mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
                break;
            }
            else if(this.getLastVisiblePosition()==this.getCount()-1
                    && y<nowY){
                mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
                break;
            }
            mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(true);
            break;
        }
        case MotionEvent.ACTION_UP: {
            break;
        }
        default:
            break;
    }
    
    return super.dispatchTouchEvent(event);
}

最終效果和外部攔截法一樣。

好了,這篇文章到此結束,希望各位看了能對事件分發機制有個大致的了解,并且遇到了滑動沖突的問題能夠迎刃而解。謝謝忍受我的文章。


2月17號更新:感謝 @ShadowXv所指出的問題,我已改正,改正如下:
將listView是否滑動到頂部或者底部的判斷改為

public boolean isBottom(final ListView listView) {
        boolean result=false;
        if (listView.getLastVisiblePosition() == (listView.getCount() - 1)) {
            final View bottomChildView = listView.getChildAt(listView.getLastVisiblePosition() - listView.getFirstVisiblePosition());
            result= (listView.getHeight()>=bottomChildView.getBottom());
        };
        return  result;
    }

    public boolean isTop(final ListView listView) {
        boolean result=false;
        if(listView.getFirstVisiblePosition()==0){
            final View topChildView = listView.getChildAt(0);
            result=topChildView.getTop()==0;
        }
        return result ;
    }

那么onInterceptTouchEvent方法也該改為如下所示:

@Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean intercepted = false;
        int y = (int) event.getY();

        switch (event.getAction()) {

            case MotionEvent.ACTION_DOWN: {
                nowY = y;
                intercepted = super.onInterceptTouchEvent(event);
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                if(isTop(mListView)
                        && y>nowY){
                    intercepted = true;
                    break;
                }
                else if(isBottom(mListView)
                        && y<nowY){
                    intercepted = true;
                    break;
                }
                intercepted = false;
                break;
            }
            case MotionEvent.ACTION_UP: {
                intercepted = false;
                break;
            }
            default:
                break;
        }

        return intercepted;
    }

內部攔截法也是將判斷條件換一下就可以了,這里就不貼代碼了。。。

最后再一次感謝指出問題!

最后的最后:

感謝我可愛的女朋友。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,572評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,071評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,409評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,569評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,360評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,895評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,979評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,123評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,643評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,559評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,742評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,250評論 5 356
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,981評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,363評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,622評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,354評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,707評論 2 370

推薦閱讀更多精彩內容