View體系詳解

2020-09-28 11:00:32

一、學習腦圖

二、View基礎

2.1 什麼是View

Q1:怎麼理解View

  • View是介面層的控制元件的一種抽象,代表了一個控制元件。
  • android在視覺上的呈現。
  • 是所有控制元件是基礎類別,可以是單個控制元件View可以是一組控制元件ViewGroup

Q2:View的重要性?

ViewAndroid中是一個十分重要的概念,雖然說View不屬於四大元件,但是它的作用堪比四大元件,在開發中,Activity承擔了視覺化的功能,Android提供了很多基礎的控制元件,當我們不滿足於這些基礎控制元件的功能時,可以用自定義控制元件,而控制元件的自定義就需要對View體系有深入的瞭解。

2.2 View的位置引數

Android系統中,有兩種座標系,分別是Android座標系和View座標系。

2.2.1 Android座標系
  • 將螢幕左上角作為座標原點
  • 原點向右是X軸正方向
  • 原點向下是Y軸正方向

注意:使用getRawX()getRawY()方法獲得的座標是Android座標系的座標

2.2.2 View座標系

Q1:View的位置由什麼來決定?

四個頂點:top(左上角縱座標)、left(左上角橫座標)、right(右下角橫座標)、bottom(右下角縱座標)

注意:這些座標都是相對於父容器來說的,是一種相對座標

Top = getTop()Left = getLeft(),Right = getRight(),Bottom=getBottom()

自Anroid3.0後,增加了xytranslationXtranslationY這幾個引數。

  • xyView左上角的座標
  • translationXtranslationY:左上角相對於父容器的偏移量

注意:View在平移過程中,topleft表示原始左上角的位置資訊,發生改變的值是xytranslationXtranslationY這四個引數。

Q2:getX()getY()getRawX()getRawY()有什麼區別?

getXgetY是檢視座標,是相對於控制元件的距離

getRawXgetRawY是絕對座標,是與整個螢幕的距離

Q3:View怎麼獲取自身的寬和高?

width = getRight()-getLeft() = getWidth()

height = getBottom()-getTop() = getHeight()

2.2.3 View的觸控
2.2.3.1 MotionEvent

手指接觸控螢幕幕後所產生的一系列事件。

  • ACTION_DOWN —— 手指剛接觸控螢幕幕
  • ACTION_MOVE —— 手指在螢幕上移動
  • ACTION_UP —— 手指從螢幕上鬆開的一瞬間

正常情況下,觸控式螢幕幕會出現以下兩種情況

  • 點選螢幕後鬆開,DOWN -> UP
  • 點選螢幕滑動後再鬆開,DOWN->MOVE->…->MOVE->UP
2.2.3.2 TouchSlop

TouchSlop是系統所能識別出的被認為是滑動的最小距離,是一個常數。

Q1:怎麼獲取這個常數?

ViewConfiguration.get(getContext()).getScaledTouchSlop()

Q2:這個常數有什麼意義?

在處理滑動時,可以利用這個常數來進行過濾,當兩次滑動事件的滑動距離小於這個常數時,可以認為它們不是滑動。

2.2.3.3 VelocityTracker

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

Q:怎麼使用VelocityTracker

1.在ViewonTouchEvent方法中追蹤當前點選事件的速度

 VelocityTracker velocityTracker = VelocityTracker.obtain(); 
 velocityTracker.addMovement(event);

2.獲取當前速度

  velocityTracker.computeCurrentVelocity(1000);
        int xVelocity = (int)velocityTracker.getXVelocity();
        int yVelocity = (int)velocityTracker.getYVelocity();

注意:

  • 獲取速度之前需要先計算速度,即getXVelocity()getYVelocity()方法前必須先呼叫velocityTracker.computeCurrentVelocity(1000);

  • 這裡的速度指的事一段時間內手滑動的畫素數。速度可以為正也可以為負,因為在Android座標系中,手指逆著座標正方向滑動,速度結果就是負的,這裡的正負指的是方向。

    速度 = (終點位置 - 起點位置)/時間段

3.使用clear重置並回收記憶體

當不需要使用velocityTracker時,需要使用clear去回收它。

   velocityTracker.clear();
        velocityTracker.recycle();
2.2.3.4 GestureDetector

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

Q:怎麼使用GestureDetector

1.建立一個GestureDetector物件並實現OnGestureDetector介面

 GestureDetector mGestureDetector = new GestureDetector(this,this);
        //解決長按螢幕後無法拖動的現象
  mGestureDetector.setIsLongpressEnabled(false);

2.在ViewonTouchEvent方法新增

 boolean consume = mGestureDetector.onTouchEvent(event);
        return consume;

3.有選擇的實現OnGestureListenerOnDoubleTapListener中的方法

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

2.2.3.5 Scroller

彈性滑動物件,用於實現View的彈性滑動

Q:如何使用Scroller?典型程式碼固定,如下。

Scroller scroller = new Scroller(mContext);

//緩慢捲動到指定位置
    private void smoothScrollTo(int destX,int destY){
        int scrollX = getScrollX();
        int delta = destX -scrollX;
        //1000ms內滑向destX,效果就是慢慢滑動
        scroller.startScroll(scrollX,0,delta,0,1000);
        invalidate;
    }
    
    @Override
    Public void computeScroll(){
    	if(mScroller.computeScrollOffset()){
    		ScrollTo(mScroll.getCurrX(),mScroller.getCurrY());
    		postInvalidate();
    	}
    }

三、View的滑動

滑動在Android開發中具有重要的作用,掌握滑動的方法是實現自定義控制元件的基礎。

View滑動的基本思想:

當觸控事件傳到View時,系統記下觸控點的座標,手指移動時系統記下移動後的觸控的座標並算出偏移量,並通過偏移量來修改View的座標。

3.1 View滑動的7種方法

3.2.1 layout()

思路:view進行繪製的時候會呼叫onLayout()方法來設定顯示的位置,因此可以通過修改Viewlefttoprightbottom這四種屬性來控制View的座標。

  • 使用:
 public boolean onTouchEvent(MotionEvent event) {
        //獲取到手指處的橫座標和縱座標
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                //計算移動的距離
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                //呼叫layout方法來重新放置它的位置
                layout(getLeft()+offsetX, getTop()+offsetY,
                        getRight()+offsetX , getBottom()+offsetY); //layout()方法
                break;
        }
        return true;
    }

3.2.2 offsetLeftAndRight()offsetTopAndBottom()

思路:與layout()方法思路一樣,不同的是offsetLeftAndRight()offsetTopAndBottom()方法設定的是左右和上下的偏離值。

使用:

public boolean onTouchEvent(MotionEvent event) {
        //獲取到手指處的橫座標和縱座標
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                //計算移動的距離
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                //對left和right進行偏移
                offsetLeftAndRight(offsetX);  
                //對top和bottom進行偏移
                offsetTopAndBottom(offsetY);
                break;
        }
        return true;
    }
3.2.3 LayoutParams(改變佈局引數)

思路:LayoutParams主要儲存了一個View的佈局引數,可以通過LayoutParams來改變View的佈局的引數從而達到了改變View的位置的效果。

  • 使用:
public boolean onTouchEvent(MotionEvent event) {
        //獲取到手指處的橫座標和縱座標
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                //計算移動的距離
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                LinearLayout.LayoutParams layoutParams= (LinearLayout.LayoutParams) getLayoutParams();
                layoutParams.leftMargin = getLeft() + offsetX;
                layoutParams.topMargin = getTop() + offsetY;
                setLayoutParams(layoutParams);
                break;
        }
        return true;
    }

​ 父控制元件若是LinearLayout則按程式碼上所示,父控制元件若是RelativeLayout,則要使用RelativeLayout.LayoutParams,除了使用佈局的LayoutParams外,也可以用ViewGroup.MarginLayoutParams來實現

ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) getLayoutParams();

3.2.4 動畫

思路:通過動畫可以讓一個View進行平移,而平移也就是一種滑動,主要操作的是ViewtranslationXtranslationY屬性。

Android 內有兩種動畫可以使用:View動畫和屬性動畫。

  • View動畫使用:

    1.在res目錄新建anim資料夾並建立translate.xml:

  LinearLayout.LayoutParams layoutParams= (LinearLayout.LayoutParams) getLayoutParams();
                layoutParams.leftMargin = getLeft() + offsetX;
                layoutParams.topMargin = getTop() + offsetY;
                setLayoutParams(layoutParams);

​ 2.在Java程式碼中參照:

 mCustomView.setAnimation(AnimationUtils.loadAnimation(this, R.anim.translate));

注意:View動畫並不能改變View的位置引數。

如果對一個Button進行如上的平移動畫操作,當Button平移300畫素停留在當前位置時,我們點選這個Button並不會觸發點選事件,但在我們點選這個Button的原始位置時卻觸發了點選事件。這就是補間動畫和屬性動畫的區別

  • 屬性動畫使用:

    CustomView在1000毫秒內沿著X軸像右平移300畫素:

ObjectAnimator.ofFloat(mCustomView,"translationX",0,300).setDuration(1000).start();

3.2.5 scrollTo/scrollBy

思路:scollTo(x,y)表示移動到一個具體的座標點,而scollBy(dx,dy)則表示移動的增量為dx、dy。其中scollBy最終也是要呼叫scollTo的。scollToscollBy移動的是View的內容,如果在ViewGroup中使用則是移動他所有的子View

  • 使用:
 public boolean onTouchEvent(MotionEvent event) {
        //獲取到手指處的橫座標和縱座標
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                //計算移動的距離
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                ((View)getParent()).scrollBy(-offsetX,-offsetY);
                break;
        }
        return true;
    }

3.2.6 Scroller

scollTo/scollBy方法來進行滑動時,這個過程是瞬間完成的,使用Scroller來實現有過度效果的滑動,這個過程不是瞬間完成的,而是在一定的時間間隔完成的。Scroller本身是不能實現View的滑動的,它需要配合ViewcomputeScroll()方法才能彈性滑動的效果。

  • 使用:
@Override
    public void computeScroll() {
        super.computeScroll();
        if(mScroller.computeScrollOffset()){
            ((View)getParent()).scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
            //不斷的重繪,重複呼叫computeScroll方法
            PostInvalidate();
        }
    }
    //緩慢捲動到指定位置
    public void smoothScrollTo(int destX,int destY){
        int scrollX=getScrollX();
        int delta=destX-scrollX;
        //1000秒內滑向destX
        mScroller.startScroll(scrollX,0,delta,0,2000);
        invalidate();
    }

View類中呼叫

    //使用Scroll來進行平滑移動
          mCustomView.smoothScrollTo(-400,0);
  • 原始碼分析

    當我們構造一個Scroller物件並呼叫它的startScroll方法時,startScroll儲存了傳遞的幾個引數

       /**
         * @param startX 起點的橫座標
         * @param startY 起點的縱座標
         * @param dx 水平滑動的距離
         * @param dy 豎直滑動的距離
         * @param duration 滑動時間
         */
      public void startScroll(int startX, int startY, int dx, int dy, int duration) {
            mMode = SCROLL_MODE;
            mFinished = false;
            mDuration = duration;
            mStartTime = AnimationUtils.currentAnimationTimeMillis();
            mStartX = startX;
            mStartY = startY;
            mFinalX = startX + dx;
            mFinalY = startY + dy;
            mDeltaX = dx;
            mDeltaY = dy;
            mDurationReciprocal = 1.0f / (float) mDuration;
        }
    

    Q1:在startScroll方法中,內部並沒有做滑動相關的事,那麼startScroll是如何讓View滑動的?

    答:使用invalidate方法。invalidate方法會導致View重繪,重繪過程中Viewdraw方法中又會去呼叫computeScroll方法,本來computeScroll方法在View中是一個空實現,在上面的程式碼中我們已經新增程式碼,通過scrollTo方法實現滑動,接著使用PostInvalidat方法第二次重繪,如此反覆,知道整個滑動過程結束。

    computeScroll方法中有使用到computeScrollOffset()方法,下面看看這個方法的原始碼

    public boolean computeScrollOffset() {
            if (mFinished) {
                return false;
            }
    
            int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
        
            if (timePassed < mDuration) {
                switch (mMode) {
                case SCROLL_MODE:
                    final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
                    mCurrX = mStartX + Math.round(x * mDeltaX);
                    mCurrY = mStartY + Math.round(x * mDeltaY);
                    break;
      .......              
    

    這個方法會根據時間的流逝計算當前的scrollXscrollY的值。返回ture時表示滑動未結束,返回false則表示滑動已結束。

3.2.7 延時策略

核心思想:通過傳送一系列延時訊息從而達到一種漸進性效果。

使用:Handle/ViewpostDelayed/執行緒的sleep

注意:無法精確定時,因為系統訊息排程也需要時間。

四、事件分發機制

事件分發機制是View體系裡學習的核心點,它是解決滑動衝突的理論基礎,因此,學習好事件分發機制是非常重要的。

這一部分主要是我對知識的總結概括,看了之後還是對事件分發機制感到模糊的讀者,推薦一篇詳細的事件分發文章學習 View 事件分發,就像外地人上了黑車!

Q1:什麼是點選事件的事件分發?

當一個點選事件MotionEvent產生以後,系統把這個事件傳遞給具體的View的過程,就是事件分發過程。

4.1 主要方法

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

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

  • onTouchEvent:進行事件處理。

三者關係的虛擬碼:

public boolean dispatchTouchEvent(MotionEvent event) {
        boolean consume  = false; //boolean型別的值表示是否消費事件
        if(onInterceptTouchEvent(event)){ //當前View攔截了這個事件
            consume = onTouchEvent(event); //執行當前View的onTouchEvent()方法,是否消費由該返回值決定
        }else {//當前View沒有攔截這個事件
            consume  = child.dispatchTouchEvent(event);  //事件傳遞給下一層View的dispatchTouchEvent()方法,是否消費由下一層ViewdispatchTouchEvent()方法返回值決定
        }
        return consume;
    }

4.2 事件分發的全流程

Q2: View事件分發的本質是什麼?

View事件分發的本質是遞迴,點選事件自上而下分發的過程是「遞」,消耗事件自下而上的過程是「歸」。

Q3: 「遞」和「歸」的兩個過程分別是什麼?

當一個點選事件產生後,它的傳遞過程會遵循如下順序:Activity->Window->ViewGroup->…->View。這個自上而下傳遞的過程就是「遞「的過程。

當傳遞到具體的一個View後,這個View的onTouchEvent返回false,即不消耗這個事件,那麼這個事件則會向上傳遞,假若所有的元素都不處理這事件,那麼這個事件最終會傳遞給Activity處理,這個自下而上傳遞的過程就是「歸」的過程。

注意:

在「遞」的過程中,ViewGroup 可以在當前層級,通過設定 onInterceptTouchEvent 方法返回 true,來攔截事件的下發,而直接步入「歸」流程。

ViewGroup 可以攔截事件下發的同時,child 也可以通過 getParent.requestDisallowInterceptTouchEvent 方法,來阻止上一級的下發攔截。

圖取自學習 View 事件分發,就像外地人上了黑車!

總結:同樣參考上面連結

4.3 原始碼分析

事件分發的三個過程:

  1. Activity對事件的分發
  2. 頂級View對事件的分發
  3. View對事件的處理

這裡不列出原始碼,僅畫出流程圖,需要檢視原始碼的讀者可檢視《Android開發藝術探索》相應章節或者在編譯器中檢視。

五、滑動衝突

在使用滑動的過程中,假設一種情況,一個介面內外兩層可以滑動,這個時候你滑動它,這個介面怎麼判定你滑動的是內層還是外層?這個時候就會產生滑動衝突,所以在這個部分,我們一起來解決這個滑動衝突。

5.1 場景的滑動衝突場景

  • 場景A:外部滑動與內部滑動不一致的滑動衝突,常見於常見於ScrollViewFragmentLisetView的使用。
  • 場景B:外部滑動與內部滑動一致的滑動衝突,可能出現在自定義ViewListView中,外部可以上下滑動,內部也可以上下滑動。
  • 場景C:場景AB的巢狀。

5.2 處理規則

  • 場景A的處理規則:當使用者左右滑動時,讓外部的View攔截點選事件,當使用者上下滑動時,讓內部的View攔截點選事件。

    Q1:如何判斷使用者是左右滑動還是上下滑動

    利用水平偏移量和豎直方向的偏移量進行相減,用是否大於0來判斷哪個偏移量大。若偏移量offsetX-offsetY>0,可判斷為水平滑動,這時可以由外部攔截,讓它來處理點選事件。

  • 場景B的處理規則:需要根據業務邏輯來處理,,規定何時讓外部View攔截事件何時由內部View攔截事件。

  • 場景C的處理規則:同樣需要從業務上找突破點

5.3 解決方法

  • 外部攔截法

  • 內部攔截法

5.3.1 外部攔截法

點選事件都先經過父容器的攔截處理,如果父容器需要就攔截,不需要此事件就不攔截。

方法:需要重寫父容器的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;
   }

5.3.2 內部攔截法

父容器不攔截任何事件,所有事件都傳遞個給子元素,如果子元素需要此事件就直接消耗,否則交由父容器處理。

方法:需要配合requestDisallowInterceptTouchEvent方法。重寫子ViewdispatchTouchEvent()

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);//為fasle表示允許父容器攔截
         }
         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;
 }
}

Q1:為什麼父容器不能攔截ACTION_DOWN事件?

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


參考自: