自定義View以及事件分發總結

ztq發表於2019-03-03

一些零碎的知識的。

  1. 座標系原點預設是螢幕左上角,向右為X軸正方向,向下為Y軸正方向。

  2. View的getTop()、getLeft()、getBottom()、getRight()是相對父View來說的。

  3. 注意區分View的座標系Canvas的座標系。View座標系的原點是View的左上角;Canvas的座標系預設是與View的重合,但是通過平移、旋轉、縮放可以進行操作。

  4. 觸控事件MotionEvent的getX()、getY()是相對於View座標系的;getRawX()、getRawY()是相對於螢幕座標系的。

  5. 0°角與X軸正方向重合,角度沿著順時針增大。

  6. A R G B 的取值範圍均為0~255(即16進位制的0x00~0xff),A 從0x00到0xff表示從透明到不透明;RGB 從0x00到0xff表示顏色從淺到深。

  7. bitmap大小計算公式,單位為Byte: bitmap.getWidth()*bitmap.getHeight()*(1/inSampleSize)^2*(目標裝置解析度/dpi資料夾解析度)^2*色彩空間

  8. merge標籤必須是xml檔案的根標籤,merge標籤與include標籤一起用;通過LayoutInflater填充merge標籤時必須指定父ViewGroup。

  9. getMeasureWidth()方法在measure()過程結束後就可以獲取到了,而getWidth()方法要在layout()過程結束後才能獲取到。另外,getMeasureWidth()方法中的值是通過setMeasuredDimension()方法來進行設定的,而getWidth()方法中的值則是通過檢視右邊的座標減去左邊的座標計算出來的。

  10. onMeasure()用於使用父View傳過來的widthMeasureSpec,heightMeasureSpec來確定自身能達到的最大尺寸(可以超,但是超過這個尺寸的部分將顯示不出來)。至於View真正的尺寸還需要onLayout()的過程去確定,onLayout()中父View指定的矩形引數有可能使getWidth()比getMeasureWidth()大。最佳實踐:遵守規範,通過數學計算控制引數把內容控制在螢幕之內。

  11. View的繪製流程從ViewRootImpl的performTraversals()開始,performTraversals()裡面會依次執行measure()、layout()、draw()的流程。

    measure():先會獲取根佈局的MeasureSpec,如果是match_parent則為EXACTLY,如果是wrap_content則為AT_MOST,大小皆為視窗(window)的大小。也就意味著根檢視總是會充滿全屏的。View的onMeasure()接受父View傳來的寬高引數,onMeasure的預設實現是呼叫getDefaultSize()來獲取View的大小,如果MeasureSpec的mode為EXACTLY和AT_MOST則獲取MeasureSpec中的尺寸資訊。覆寫onMesure()函式進行測量的時候記得呼叫setMeasuredDimension()。ViewGroup需要呼叫measureChildren()(最後會呼叫measureChild())去觸發子View進行測量。

    layout():接收四個引數,分別代表著左、上、右、下的座標,當然這個座標是相對於當前檢視的父檢視而言的。正如其名字所描述的一樣,這個方法是用於給檢視進行佈局的,也就是確定檢視的位置。ViewGroup中的onLayout()方法竟然是一個抽象方法,這就意味著所有ViewGroup的子類都必須重寫這個方法。View中的onLayout()是空方法。

    draw():measure和layout的過程都結束後,接下來就進入到draw的過程了。

  12. 呼叫順序:onMeasure()-->onSizeChanged()-->onLayout()-->onDraw()。

  13. 檢視重繪:invalidate()中先呼叫skipInvalidate()方法來判斷當前View是否需要重繪,判斷的邏輯也比較簡單,如果View是不可見的且沒有執行任何動畫,就認為不需要重繪了。接著迴圈請求自己的父View去重繪,直到迴圈到最外層的根View後,呼叫ViewRoot的invalidateChildInParent()去重繪。最後會通過Handler傳送一條DO_TRAVERSAL的訊息給ViewRoot自身,再次呼叫performTraversals()進行繪製。

  14. invalidate()方法雖然最終會呼叫到performTraversals()方法中,但這時measure和layout流程是不會重新執行的,因為檢視沒有強制重新測量的標誌位,而且大小也沒有發生過變化,所以這時只有draw流程可以得到執行。而如果你希望檢視的繪製流程可以完完整整地重新走一遍,就不能使用invalidate()方法,而應該呼叫**requestLayout()**了。

  15. 自定義View有三種:自繪控制元件、組合控制元件、繼承控制元件。

  16. onDraw()自繪製圖形的時候,通常會把Canvas座標系移動到中央(或者是旋轉等會使Canvas座標系變化的操作),因為這樣進行引數計算比較簡單,但是這樣會存在一個問題:如果想實現View內區域點選事件監控的話,存在座標不一致的問題。因為MotionEvent的getX()、getY()是相對View的,如果取觸控點的getX()、getY()去跟圓的Region去判斷的話,將會被錯誤的判斷為觸控了圓,而事實上在Canvas座標系中該觸控點為(-x,-y)。解決方法:對Canvas座標系進行了變化的時候記錄下它的逆矩陣,用逆矩陣對MotionEvent的**getRawX()、getRawY()**進行轉化即可得到觸控點相對於該Canvas的座標。原理:在繪製的時候Canvas會把自己的座標轉化為螢幕座標進行繪製,所以想要還原回Canvas繪製狀態時的座標可以用它的逆矩陣進行逆向操作。

自定義View以及事件分發總結

注意:如果Canvas座標系與View座標系重合則直接用MotionEvent的getX()、getY()即可。

  1. 事件分發流程:Activity->ViewGroup->View;對每個接收物件來說:dispatchTouchEvent()->onInterceptTouchEvent()->onTouchEvent()。
型別 函式 Activity ViewGroup View 返回值
事件分發 dispatchTouchEvent() true:不繼續向下分發
事件攔截 onInterceptTouchEvent() x x true:攔截事件
事件消費 onTouchEvent() true:消費掉事件
  1. ViewGroup事件分發虛擬碼:

    public boolean dispatchTouchEvent(MotionEvent ev) {
        boolean result = false;             // 預設狀態為沒有消費過
    
        if (!onInterceptTouchEvent(ev)) {   // 如果沒有攔截交給子View
            result = child.dispatchTouchEvent(ev);
        }
    
        if (!result) {                      // 如果事件沒有被消費,詢問自身onTouchEvent
            result = onTouchEvent(ev);
        }
    
        return result;
    }
    複製程式碼

    可以看到只有onInterceptTouchEvent()的返回值沒有賦給result,所以onInterceptTouchEvent()返回true只會中斷事件dispatch,還是會繼續從當前的View進行onTouch()反向回撥。而dispatchTouchEvent()返回true中斷掉所有流程;onTouchEvent()返回true則會中斷掉回撥過程,也即是表示事件被消費了。

  2. View相關事件呼叫順序:onTouchListener>onTouchEvent>onLongClickListener>onClickListener

    虛擬碼:

    public boolean dispatchTouchEvent(MotionEvent event) {
      if (mOnTouchListener.onTouch(this, event)) {
          return true;
      } else if (onTouchEvent(event)) {
          return true;
      }
      return false;
    }
    複製程式碼

    onTouchEvent()中處理onClickListener和onLongClickListener。

    精簡版原始碼:

    public boolean onTouchEvent(MotionEvent event) {
        ...
        final int action = event.getAction();
      	// 檢查各種 clickable
        if (((viewFlags & CLICKABLE) == CLICKABLE ||
                (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
                (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
            switch (action) {
                case MotionEvent.ACTION_UP:
                    ...
                    removeLongPressCallback();  // 移除長按
                    ...
                    performClick();             // 檢查單擊
                    ...
                    break;
                case MotionEvent.ACTION_DOWN:
                    ...
                    checkForLongClick(0);       // 檢測長按
                    ...
                    break;
                ...
            }
            return true;                        // ◀︎表示事件被消費
        }
        return false;
    }
    複製程式碼

    上面可以看出:只要 View 可點選onTouchEvent()就返回 true,就表示事件被消費了。

    舉例,

    <RelativeLayout
        android:background="#CCC"
        android:id="@+id/layout"
        android:onClick="myClick"
        android:layout_width="200dp"
        android:layout_height="200dp">
        <View
            android:clickable="true"
            android:layout_width="200dp"
            android:layout_height="200dp" />
    </RelativeLayout>
    複製程式碼

    現在你有了一個 RelativeLayout - View 你開開心心的為 RelativeLayout 設定了一個點選事件myClick,然而你會發現不論怎麼點都不會接收到資訊,仔細一看,發現內部的 View 有一個屬性 android:clickable="true" 正是這個看似不起眼的屬性把事件給消費掉了,由此我們可以得出如下結論: 1. 不論 View 自身是否註冊點選事件,只要 View 是可點選的就會消費事件。 2. 事件是否被消費由返回值決定,true 表示消費,false 表示不消費,與是否使用了事件無關。

  3. 事件分發核心要點:

    • 事件分發原理: 責任鏈模式,事件層層傳遞,直到被消費。
    • View 的 dispatchTouchEvent 主要用於排程自身的監聽器和 onTouchEvent。
    • View的事件的排程順序是 onTouchListener > onTouchEvent > onLongClickListener > onClickListener 。
    • 不論 View 自身是否註冊點選事件,只要 View 是可點選的就會消費事件。
    • 事件是否被消費由返回值決定,true 表示消費,false 表示不消費,與是否使用了事件無關。
    • ViewGroup 中可能有多個 ChildView 時,將事件分配給包含點選位置的 ChildView。
    • ViewGroup 和 ChildView 同時註冊了事件監聽器(onClick等),由 ChildView 消費。
    • 一次觸控流程中產生事件應被同一 View 消費,全部接收或者全部拒絕。
    • 只要接受 ACTION_DOWN 就意味著接受所有的事件,拒絕 ACTION_DOWN 則不會收到後續內容。
    • 如果當前正在處理的事件被上層 View 攔截,會收到一個 ACTION_CANCEL,後續事件不會再傳遞過來

參考資料

GcsSloop-Android自定義View教程目錄

郭林的部落格

相關文章