要點提煉|開發藝術之View

釐米姑娘發表於2017-12-21

在Android中的任何一個佈局、任何一個控制元件其實都是直接或間接繼承自View的,因此View是一個很重要的概念。本篇將深入學習View,內容如下:

  • View事件體系
    • View位置引數
    • View的觸控
    • View的滑動
    • View事件分發機制
    • View滑動衝突
  • View工作原理
    • View工作流程
    • 自定義View

簡介:在Android的世界中View是所有控制元件的基類,其中也包括ViewGroup在內,ViewGroup是代表著控制元件的集合,其中可以包含多個View控制元件。 從某種角度上來講Android中的控制元件可以分為兩大類:View與ViewGroup。通過ViewGroup,整個介面的控制元件形成了一個樹形結構,上層的控制元件要負責測量與繪製下層的控制元件,並傳遞互動事件。 在每棵控制元件樹的頂部都存在著一個ViewParent物件,它是整棵控制元件樹的核心所在,所有的互動管理事件都由它來統一排程和分配,從而對整個檢視進行整體控制。

要點提煉|開發藝術之View


一.View事件體系

1.View的位置引數

a.Android座標系:以螢幕的左上角為座標原點,向右為x軸增大方向,向下為y軸增大方向。

b.View的位置由它的四個頂點決定,分別對應View的四個屬性:top、left、right、bottom。其中left是左上角的橫座標,right是右下角的橫座標,top是左上角的縱座標,bottom是右下角的縱座標。注意這些座標是相對於view父容器而言,是一種相對的座標。具體關係見下圖:

要點提煉|開發藝術之View

因此,View的寬高和座標關係:width = right - left,height = top - bottom。

可利用View的get方法獲取上述屬性,如:

  • left = getLeft();
  • right = getRight();
  • top = getTop();
  • bottom = getBottom();
  • width=getWidth();
  • height=getHeight();

c.從android3.0開始,View增加了額外幾個引數:x,y,translationX、translationY。其中x和y是View左上角的座標,translationX和translationY是View左上角相對於父容器的偏移量,它們預設值是0。這些引數也是相對於View父容器。具體關係見下圖:

要點提煉|開發藝術之View

  • 存在關係:x = left + translationX,y = top + translationY
  • 由此可見,x和left不同體現在:left是View的初始座標,在繪製完畢後就不會再改變;而x是View偏移後的實時座標,是實際座標。y和top的區別同理。

類似地,安卓也提供了相應的get/set方法。需要注意的是,在onCreate()方法裡無法獲取到View的座標引數,這是因為此時View還未開始繪製,全部座標引數將都是0。

推薦閱讀Android應用座標系統全面詳解


2.觸控系列

a.MotionEvent:是手指觸控螢幕鎖產生的一系列事件。典型事件有:

  • ACTION_DOWN:手指剛接觸螢幕
  • ACTION_MOVE:手指在螢幕上滑動
  • ACTION_UP:手指在螢幕上鬆開的一瞬間

事件列:從手指接觸螢幕至手指離開螢幕,這個過程產生的一系列事件 任何事件列都是以DOWN事件開始,UP事件結束,中間有無數的MOVE事件。如圖:

要點提煉|開發藝術之View

通過MotionEvent 物件可以得到觸控事件的x、y座標。其中通過getX()、getY()可獲取相對於當前view左上角的x、y座標;通過getRawX()、getRawY()可獲取相對於手機螢幕左上角的x,y座標。具體關係見下圖:

要點提煉|開發藝術之View

b.TouchSlop:系統所能識別的被認為是滑動的最小距離。即當手指在螢幕上滑動時,如果兩次滑動之間的距離小於這個常量,那麼系統就不認為你是在進行滑動操作。

該常量和裝置有關,可用它來判斷使用者的滑動是否達到閾值,獲取方法:ViewConfiguration.get(getContext()).getScaledTouchSlop()

c.VelocityTracker:速度追蹤,用於追蹤手指在滑動過程中的速度,包括水平和豎直方向的速度。

使用過程:首先在view的onTouchEvent方法中追蹤當前單擊事件的速度:

VelocityTracker velocityTracker = VelocityTracker.obtain();//例項化一個VelocityTracker 物件
velocityTracker.addMovement(event);//新增追蹤事件
複製程式碼

接著在ACTION_UP事件中獲取當前的速度。注意這裡計算的是1000ms時間間隔移動的畫素值,假設畫素是100,即速度是每秒100畫素。另外,手指逆著座標系的正方向滑動,所產生的速度為負值,順著正反向滑動,所產生的速度為正值。

velocityTracker .computeCurrentVelocity(1000);//獲取速度前先計算速度,這裡計算的是在1000ms內
float xVelocity = velocityTracker .getXVelocity();//得到的是1000ms內手指在水平方向從左向右滑過的畫素數,即水平速度
float yVelocity = velocityTracker .getYVelocity();//得到的是1000ms內手指在水平方向從上向下滑過的畫素數,垂直速度
複製程式碼

最後,當不需要使用它的時候,需要呼叫clear方法來重置並回收記憶體:

velocityTracker.clear();
velocityTracker.recycle();
複製程式碼

推薦閱讀Android常用觸控類分析:MotionEvent 、 ViewConfiguration、VelocityTracker

d.GestureDetector:手勢檢測,用於輔助檢測使用者的單擊、滑動、長按、雙擊等行為。

使用過程:建立一個GestureDetecor物件並實現OnGestureListener介面,根據需要實現單擊等方法:

GestureDetector mGestureDetector = new GestureDetector(this);//例項化一個GestureDetector物件
mGestureDetector.setIsLongpressEnabled(false);// 解決長按螢幕後無法拖動的現象
複製程式碼

接著,接管目標view的onTouchEvent方法,在待監聽view的onTouchEvent方法中新增如下實現:

boolean consume = mGestureDetector.onTouchEvent(event);
return consume;
複製程式碼

然後,就可以有選擇的實現OnGestureListener和OnDoubleTapListener中的方法了。

建議:如果只是監聽滑動操作,建議在onTouchEvent中實現;如果要監聽雙擊這種行為,則使用GestureDetector 。

推薦閱讀:Android手勢檢測——GestureDetector全面分析


3.滑動系列

a.實現View滑動三種辦法:

①通過View本身提供的scrollTo/scrollBy方法

  • 兩者區別:scrollBy是內部呼叫了scrollTo的,它是基於當前位置的相對滑動;而scrollTo是絕對滑動,因此如果利用相同輸入引數多次呼叫scrollTo()方法,由於View初始位置是不變只會出現一次View滾動的效果而不是多次。
  • 注意:兩者都只能對view內容進行滑動,而不能使view本身滑動。

mScrollX和mScrollY分別表示View在X、Y方向的滾動距離。mScrollX:View的左邊緣減去View的內容的左邊緣;mScrollY:View的上邊緣減去View的內容的上邊緣。從右向左滑動,mScrollX為正值,反之為負值;從下往上滑動,mScrollY為正值,反之為負值。(更直觀感受:檢視下一張照片或者檢視長圖時手指滑動方向為正)

綠色邊框代表View在螢幕上對應的矩形區域,灰色陰影代表View的內容

推薦閱讀:scrollTo/scrollBy 使用詳解

②通過動畫給View施加平移效果:主要通過改變View的translationX和translationY引數來實現。可用view動畫,也可以採用屬性動畫,如果使用屬性動畫的話,為了能夠相容3.0以下版本,需要採用開源動畫庫nineoldandroids。注意View動畫的View移動只是位置移動,並不能真正的改變view的位置,而屬性動畫可以。

③通過改變View的LayoutParams使得View重新佈局:比如將一個View向右移動100畫素,向右,只需要把它的marginLeft引數增大即可,程式碼見下:

MarginLayoutParams params = (MarginLayoutParams) btn.getLayoutParams();
params.leftMargin += 100;
btn.requestLayout();// 請求重新對View進行measure、layout
複製程式碼

三種方式對比:

  • scrollTo/scrollBy:操作簡單,適合對view內容滑動。非平滑
  • 動畫:操作簡單,主要適用於沒有互動的view和實現複雜的動畫效果
  • 改變LayoutParams:操作稍微複雜,適用於有互動的view。非平滑

b.實現View彈性滑動三種方法:

①使用Scroller

  • 與scrollTo/scrollBy不同:scrollTo/scrollBy過程是瞬間完成的,非平滑;而Scroller則有過渡滑動的效果。
  • 注意:Scoller本身無法讓View彈性滑動,它需要和View的computerScroller方法配合使用。

Scroller慣用程式碼:

Scroller scroller = new Scroller(mContext); //例項化一個Scroller物件

private void smoothScrollTo(int dstX, int dstY) {
  int scrollX = getScrollX();//View的左邊緣到其內容左邊緣的距離
  int scrollY = getScrollY();//View的上邊緣到其內容上邊緣的距離
  int deltaX = dstX - scrollX;//x方向滑動的位移量
  int deltaY = dstY - scrollY;//y方向滑動的位移量
  scroller.startScroll(scrollX, scrollY, deltaX, deltaY, 1000); //開始滑動
  invalidate(); //重新整理介面
}

@Override//計算一段時間間隔內偏移的距離,並返回是否滾動結束的標記
public void computeScroll() {
  if (scroller.computeScrollOffset()) { 
    scrollTo(scroller.getCurrX(), scroller.getCurY());
    postInvalidate();//通過不斷的重繪不斷的呼叫computeScroll方法
  }
}
複製程式碼

其中startScroll原始碼如下,可見它並沒有進行實際的滑動操作,而是通過後續invalidate()方法去做滑動動作。

public void startScroll(int startX,int startY,int dx,int dy,int duration){
  mMode = SCROLL_MODE;
  mFinished = false;
  mDuration = duration;//滑動時間
  mStartTime = AnimationUtils.currentAminationTimeMills();//開始時間
  mStartX = startX;//滑動起點
  mStartY = startY;//滑動起點
  mFinalX = startX + dx;//滑動終點
  mFinalY = startY + dy;//滑動終點
  mDeltaX = dx;//滑動距離
  mDeltaY = dy;//滑動距離
  mDurationReciprocal = 1.0f / (float)mDuration;
 }
複製程式碼
  • 具體過程:在MotionEvent.ACTION_UP事件觸發時呼叫startScroll方法->馬上呼叫invalidate/postInvalidate方法->會請求View重繪,導致View.draw方法被執行->會呼叫View.computeScroll方法,此方法是空實現,需要自己處理邏輯。具體邏輯是:先判斷computeScrollOffset,若為true(表示滾動未結束),則執行scrollTo方法,它會再次呼叫postInvalidate,如此反覆執行,直到返回值為false。如圖所示:

要點提煉|開發藝術之View

  • 原理:Scroll的computeScrollOffset()根據時間的流逝動態計算一小段時間裡View滑動的距離,並得到當前View位置,再通過scrollTo繼續滑動。即把一次滑動拆分成無數次小距離滑動從而實現彈性滑動。

推薦閱讀: 站在原始碼的肩膀上全解Scroller工作機制

②通過動畫:動畫本身就是一種漸近的過程,故可通過動畫來實現彈性滑動。方法是:

ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();//在100ms內使得View從原始位置向右平移100畫素
複製程式碼

③使用延時策略:通過傳送一系列延時資訊從而達到一種漸近式的效果,具體可以通過Handler和View的postDelayed方法,也可使用執行緒的sleep方法。

對彈性滑動完成總時間有精確要求的使用場景下,使用延時策略是一個不太合適的選擇。

推薦閱讀View滑動與實現滑動的幾種方法


4.View事件分發機制

a.事件分發本質:就是對MotionEvent事件分發的過程。即當一個MotionEvent產生了以後,系統需要將這個點選事件傳遞到一個具體的View上。(關於MotionEvent介紹見本篇2.a)

b.點選事件的傳遞順序:Activity(Window) -> ViewGroup -> View

要點提煉|開發藝術之View

補充閱讀對Activity、View、Window的理解

c.需要的三個主要方法:

  • dispatchTouchEvent:進行事件的分發(傳遞)。返回值是 boolean 型別,受當前onTouchEvent和下級view的dispatchTouchEvent影響

  • onInterceptTouchEvent:對事件進行攔截。該方法只在ViewGroup中有,View(不包含 ViewGroup)是沒有的。一旦攔截,則執行ViewGroup的onTouchEvent,在ViewGroup中處理事件,而不接著分發給View。且只呼叫一次,所以後面的事件都會交給ViewGroup處理。

  • onTouchEvent:進行事件處理。

事件分發過程圖:

要點提煉|開發藝術之View

  • 事件分發是逐級下發的,目的是將事件傳遞給一個View。
  • ViewGroup一旦攔截事件,就不往下分發,同時呼叫onTouchEvent處理事件。

推薦閱讀:Android事件分發機制詳解(原始碼)


5.View滑動衝突

a.產生原因:

  • 一般情況下,在一個介面裡存在內外兩層可同時滑動的情況時,會出現滑動衝突現象。

b.可能場景:

  • 外部滑動和內部滑動方向不一致:如ViewPager巢狀ListView(實際這麼用沒問題,因為ViewPager內部已處理過)。
  • 外部滑動方向和內部滑動方向一致:如ScrollView巢狀ListView(實際上也已被解決)。
  • 上面兩種情況的巢狀

c.處理規則:

  • 對場景一:當使用者左右/上下滑動時讓外部View攔截點選事件,當使用者上下/左右滑動時讓內部View攔截點選事件。即根據滑動的方向判斷誰來攔截事件。關於判斷是上下滑動還是左右滑動,可根據滑動的距離或者滑動的角度去判斷。
  • 對場景二:一般從業務上找突破點。即根據業務需求,規定何時讓外部View攔截事件何時由內部View攔截事件。
  • 對場景三:相對複雜,可同樣根據需求在業務上找到突破點。

d.解決方式:

  • 法一:外部攔截法
    • 含義:指點選事件都先經過父容器的攔截處理,如果父容器需要此事件就攔截,否則就不攔截。
    • 方法:需要重寫父容器的onInterceptTouchEvent方法,在內部做出相應的攔截。以下是虛擬碼:
//重寫父容器的攔截方法
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://對於ACTION_DOWN事件必須返回false,一旦攔截後續事件將不能傳遞給子View
         intercepted = false;
         break;
      case MotionEvent.ACTION_MOVE://對於ACTION_MOVE事件根據需要決定是否攔截
         if (父容器需要當前事件) {
             intercepted = true;
         } else {
             intercepted = flase;
         }
         break;
   }
      case MotionEvent.ACTION_UP://對於ACTION_UP事件必須返回false,一旦攔截子View的onClick事件將不會觸發
         intercepted = false;
         break;
      default : break;
   }
    mLastXIntercept = x;
    mLastYIntercept = y;
    return intercepted;
   }
複製程式碼
  • 法二:內部攔截法
    • 含義:指父容器不攔截任何事件,而將所有的事件都傳遞給子容器,如果子容器需要此事件就直接消耗,否則就交由父容器進行處理。
    • 方法:需要配合requestDisallowInterceptTouchEvent方法。以下是子View的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);//為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);
}
複製程式碼

除子容器需要做處理外,父容器也要預設攔截除了ACTION_DOWN以外的其他事件,這樣當子容器呼叫parent.requestDisallowInterceptTouchEvent(false)方法時,父元素才能繼續攔截所需的事件。因此,父View需要重寫onInterceptTouchEvent方法:

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

內部攔截法要求父容器不能攔截ACTION_DOWN的原因:由於該事件並不受FLAG_DISALLOW_INTERCEPT(由requestDisallowInterceptTouchEvent方法設定)標記位控制,一旦ACTION_DOWN事件到來,該標記位會被重置。所以一旦父容器攔截了該事件,那麼所有的事件都不會傳遞給子View,內部攔截法也就失效了。

推薦閱讀一文解決Android View滑動衝突


二.View工作原理

1.View工作流程:measure測量->layout佈局->draw繪製

  • measure確定View的測量寬高
  • layout確定View的最終寬高四個頂點的位置
  • draw將View 繪製到螢幕
  • 對應onMeasure()、onLayout()、onDraw()三個方法。

具體過程:

  • ViewRoot對應於ViewRootImpl類,它是連線WindowManager和DecorView的紐帶。
  • View的繪製流程是從ViewRootperformTraversals開始。
  • performTraversals()依次呼叫performMeasure()、performLayout()和performDraw()三個方法,分別完成頂級 View的繪製。
  • 其中,performMeasure()會呼叫measure(),measure()中又呼叫onMeasure(),實現對其所有子元素的measure過程,這樣就完成了一次measure過程;接著子元素會重複父容器的measure過程,如此反覆至完成整個View樹的遍歷。layout和draw同理。過程圖如下:

要點提煉|開發藝術之View

補充閱讀瞭解ViewRoot和DecorView

a.measure過程:確定測量寬高

先來理解MeasureSpec

  • 作用:通過寬測量值widthMeasureSpec和高測量值heightMeasureSpec決定View的大小
  • 組成:一個32位int值,高2位代表SpecMode(測量模式),低30位代表SpecSize( 某種測量模式下的規格大小)。
  • 三種模式:
    • UNSPECIFIED:父容器不對View有任何限制,要多大有多大。常用於系統內部。
    • EXACTLY(精確模式):父檢視為子檢視指定一個確切的尺寸SpecSize。對應LyaoutParams中的match_parent具體數值
    • AT_MOST(最大模式):父容器為子檢視指定一個最大尺寸SpecSize,View的大小不能大於這個值。對應LayoutParams中的wrap_content
  • 決定因素:值由子View的佈局引數LayoutParams父容器的MeasureSpec值共同決定。具體規則見下圖:

要點提煉|開發藝術之View

現在,分別討論兩種measure過程:

①View的measure:只有一個原始的View,通過measure()即可完成測量。過程圖見下:

View的measure過程圖

從getDefaultSize()中可以看出,直接繼承View的自定義View需要重寫onMeasure()並設定wrap_content時的自身大小,否則效果相當於macth_parent。解決上述問題的典型程式碼:

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec,heightMeasureSpec);
        
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
        //分析模式,根據不同的模式來設定
        if(widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST){
            setMeasuredDimension(mWidth,mHeight);
        }else if(widthSpecMode == MeasureSpec.AT_MOST){
            setMeasuredDimension(mWidth,heightSpecSize);
        }else if(heightSpecMode == MeasureSpec.AT_MOST){
            setMeasuredDimension(widthSpecSize,mHeight);
        }
    }
複製程式碼

補充閱讀:為什麼你的自定義View wrap_content不起作用

②ViewGroup的measure:除了完成ViewGroup自身的測量外,還會遍歷去呼叫所有子元素的measure方法。

ViewGroup中沒有重寫onMeasure(),而是提供measureChildren()

ViewGroup的measure過程圖

圖片來源自定義View Measure過程

b.layout過程:確定View的最終寬高和四個頂點的位置

  • 大致流程:從頂級View開始依次呼叫layout(),其中子View的layout()會呼叫setFrame()來設定自己的四個頂點(mLeft、mRight、mTop、mBottom),接著呼叫onLayout()來確定其座標,注意該方法是空方法,因為不同的ViewGroup對其子View的佈局是不相同的。

layout過程圖

圖片來源自定義View Layout過程

c.draw過程:繪製到螢幕

繪製順序:

  • 繪製背景:background.draw(canvas)
  • 繪製自己:onDraw(canvas)
  • 繪製children:dispatchDraw(canvas)
  • 繪製裝飾:onDrawScrollBars(canvas)

draw過程圖

注意:Vew有一個特殊的方法setWillNotDraw(),該方法用於設定 WILL_NOT_DRAW 標記位(其作用是當一個View不需要繪製內容時,系統可進行相應優化)。預設情況下View是沒有這個優化標誌的(設為true)。

圖片來源自定義View Draw過程

推薦閱讀對View工作流程的理解(原始碼)


2.自定義View

a.自定義View的型別

要點提煉|開發藝術之View

b.特別提醒

要點提煉|開發藝術之View

最後,因為自定義View內容非常多,這裡不再展開。最重要的是實踐,就是現在帶著理論基礎開始實戰吧~


希望這篇文章對你有幫助~

相關文章