學習總結 -- View 事件分發機制和滑動衝突

ljuns發表於2017-03-21

終於到了 View 這一關卡了,之前也有實踐過自定義 View:圓弧刻度溫度進度條,但是對於 View 底層的東西沒什麼瞭解,只是會用而已,抱著“知其然知其所以然”的心態,很多時候都會先去嘗試使用,然後才來究其原因。這次會分兩個部分來敘述本篇:事件分發機制、滑動衝突;自己本身對原始碼也不熟悉,所以本篇主要是理論概述,儘量不出現原始碼的東西。

事件分發機制

首先要宣告這裡用來分析的物件是 MotionEvent,即點選事件。

所謂點選事件的事件分發其實就是對 MotionEvent 事件的分發過程,即當一個 MotionEvent 產生了以後,系統需要把這個事件傳遞給一個具體的 View,而這個傳遞過程就是分發機制。

瞭解了分發機制後就來了解另一個概念,同一個事件序列:從手指接觸螢幕的那一刻起到手指離開螢幕的那一刻結束,在這個過程中所產生的一系列事件就叫同一個事件序列。這個事件序列以 down 事件開始,中間含有 n 個 move 事件,最終以 up 事件結束。

知道什麼是同一個事件序列對後面的分析有很大的幫助,因為後續很多都是針對同一個事件序列來進行分析的。接下來看看點選事件的分發過程中三個很重要的方法:

public boolean dispatchTouchEvent(MotionEvent event)

public boolean onInterceptTouchEvent(MotionEvent event)

public boolean onTouchEvent(MotionEvent event)

這三個方法的關係就如下面的虛擬碼:

public boolean dispatchTouchEvent(MotionEvent event) {
  boolean consume = false;
  if (onInterceptTouchEvent(event)) {
    consume = onTouchEvent(event);
  } else {
    consume = child.dispatchTouchEvent(ev);
  }
  return consume;
}複製程式碼

通過這個虛擬碼可以很清晰的知道這三個方法之間的關係。它們的傳遞規則:對於一個根 ViewGroup 來說,點選事件產生後,首先會傳遞給 ViewGroup 本身,此時 ViewGroup 的 dispatchTouchEvent() 方法就會被呼叫,接著會呼叫 onInterceptTouchEvent() 方法,如果 onInterceptTouchEvent() 方法返回 true 就表示 ViewGroup 要攔截當前事件;接著再呼叫 onTouchEvent() 方法來處理該事件;但是如果 onInterceptTouchEvent() 方法返回 false 就表示 ViewGroup 不攔截當前事件,此時 ViewGroup 的 onTouchEvent() 方法就不會被呼叫,而是呼叫子 View 的 dispatchTouchEvent() 方法,如此反覆直到事件最終被處理。

通常來說那三個方法的執行流程就如上所說的,但是還會有一些比較特殊的情況,比如設定 OnTouchListener、OnClickListener。

當一個 View 需要處理事件時,如果 View 設定了 OnTouchListener,那麼 OnTouchListener 中的 onTouch() 方法就會被呼叫。如果 onTouch() 方法返回 false,則當前 View 的 onTouchEvent() 方法就會被呼叫;如果返回 true,當前 View 的 onTouchEvent() 方法將不會被呼叫。

在 onTouchEvent() 方法中,如果有設定 OnClickListener,那麼 onClick() 方法是一定會被呼叫的。

曾經見過一道面試題,詳細描述記不清了,大概意思是這樣的:onTouch() 和 onTouchEvent() 誰先執行 ?有評論者說如果對 View 進行過比較深入的瞭解是接觸不到這些的。

那麼通過前面幾段敘述,對事件分發機制應該有個比較清晰的理解了,還有以下幾點需要注意:

  1. 當一個點選事件產生後,該點選事件的傳遞遵循如下順序:Activity -> Window -> View,即事件總是先傳遞給 Activity,Activity 再傳遞給 Window,最後 Window 再傳遞給頂級 View。頂級 View 接收到事件後就會按照事件分發機制去分發事件。
  2. ViewGroup 預設不攔截任何事件,即 ViewGroup 的 onInterceptTouchEvent() 方法預設返回false。
  3. View 的 onTouchEvent() 預設都會處理事件(返回 true)。
  4. 子 View 可以通過 requestDisallowInterceptTouchEvent() 方法干預父 View 的事件分發過程,ACTION_DOWN 事件除外。

滑動衝突

在介面中只要內外兩層同時可以滑動,這個時候就會產生滑動衝突。常見的滑動衝突場景可以簡單的分為以下三種:

  1. 外部滑動方向和內部滑動方向不一致;
  2. 外部滑動方向和內部滑動方向一致;
  3. 上面兩種情況的巢狀。

場景一: 主要是 ViewPager 和 Fragment 配合使用所組成的頁面滑動效果。在這種效果中可以通過 ViewPager 提供的左右滑動來切換頁面,而每個頁面內部往往又是一個 RecyclerView。按說這種效果是會出現滑動衝突的,但卻不用我們去處理,原因是 ViewPager 內部已經處理了滑動衝突,所以我們使用的時候無須關注這個問題。

場景二: 如果在一個 ScrollView 中巢狀一個 RecyclerView,那麼內外兩層都在同一個方向可以滑動,當手指開始滑動的時候就會出現問題,因為系統不知道我們想要滑動的是哪一層。同理,如果內外兩層都可以在左右方向滑動也會出現這種情況。

場景三: 場景三是場景一和場景二兩種情況的巢狀,這種情況更為複雜。

比較常見的滑動衝突就是上面那三種,那麼該怎麼來解決呢?其實可以根據滑動型別是水平滑動還是豎直滑動來判斷由誰來攔截事件。至於如何判斷滑動型別就有比較多的方式了:可以依據滑動路徑和水平方向所形成的夾角;也可以依據水平方向和豎直方向上的距離差來判斷 … 。

滑動型別已經確定了,接下來就是確定滑動的接收者,究竟是誰來響應這個滑動型別?下面介紹兩種具體的解決方法:

外部攔截

所謂外部攔截就是指點選事件都先經過父容器的攔截處理,如果父容器需要該事件就攔截,否則就不攔截而是交給子容器。外部攔截需要重寫父容器的 onInterceptTouchEvent() 方法,在內部做響應的攔截即可。這種方法的虛擬碼如下:

public boolean oonInterceptTouchEvent(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;
}複製程式碼

在 onInterceptTouchEvent() 方法中,首先是 ACTION_DOWN 事件,父容器必須返回 false,即不攔截 ACTION_DOWN 事件,這是因為一旦父容器攔截了 ACTION_DOWN 事件,那麼後續的 ACTION_MOVE 和 ACTION_UP 事件都會直接交由父容器處理,此時事件就不能傳遞給子元素了;其次是 ACTION_MOVE 事件,這個事件可以根據需要來決定是否攔截,如果父容器需要攔截就返回 true,否則就返回 false;最後是 ACTION_UP 事件,這裡必須返回 false,因為 ACTION_UP 事件本身沒有太多意義。

內部攔截

內部攔截是指父容器預設不攔截任何事件,所有的事件都傳遞給子元素,如果子元素需要此事件就直接消耗處理掉,否則就返回給父容器進行處理。這種方法和外部攔截剛好相反,需要配合 requestDisallowInterceptTouchEvent() 方法才能正常工作,此時需要重寫子元素的 dispatchTouchEvent() 方法。虛擬碼如下:

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 MotinEvent.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);
}複製程式碼

使用內部攔截除了需要對子元素進行修改以外,父元素也要修改為攔截除了 ACTION_DOWN 以外的其他事件。事件父元素所做的修改如下:

public boolean onInterceptTouchEvent(MotionEvent event) {
  int action = event.getAction();
  if (action == MotionEvent.ACTION_DOWN) {
    return false;
  } else {
    return true;
  }
}複製程式碼

為什麼父元素不能攔截 ACTION_DOWN 事件呢?那是因為通過 requestDisallowInterceptTouchEvent() 方法會自動設定一個 FLAG_DISALLOW_INTERCEPT 標記,該標記會導致父元素無法攔截除了 ACTION_DOWN 以外的其他點選事件,而 ACTION_DOWN 事件會重置 FLAG_DISALLOW_INTERCEPT 標記,使之無效。

目前所理解的 View 事件分發機制和滑動衝突就這麼多了,只是一些理論概述,接下來還需要好好的實踐一番才能進一步掌握,同時也才能發現一些細節上的問題。

相關文章