Android——View的事件體系

View的事件體系

1.1 View簡述

  • View是Android中所有控件的基類。View是一種界面層的控件的一種抽象,代表一個控件。
  • ViewGroup:控件組。ViewGroup內部包含多個控件(View)。ViewGroup也繼承View,意味着View本身就可以是單個控件也可以是多個控件組成的一組控件。

1.1.1 View的位置參數

  • View的位置由四個頂點確定,對應View的四個屬性:top(左上縱座標)、left(左上橫座標)、right(右下橫座標)、bottom(右下縱座標)。

    View位置參數

  • View的寬高和座標關係

    • width = right - left
    • height = bottom - top
  • 位置參數的獲取方法

    • Left=getLeft()
    • Right=getRight()
    • Top=getTop()
    • Bottom=getBottom()
  • View的其他參數:

    • x:View的左上角橫座標

    • y:View的左上角縱座標

    • translationX:View的左上角橫座標相對於父容器的偏移量。(默認0)

    • translationY:View的左上角縱座標相對於父容器的偏移量。(默認0)

    • x=left+translationX

    • y=top+translationY

      注意:top和left表示的是原始左上角的位置信息,不會改變;x、y、translationX、translationY這四個參數會發生改變。

1.1.2 MotionEvent和TouchSlop

  • MotionEvent

    • ACTION_DOWN:手指剛接觸屏幕。
    • ACTION_MOVE:手指在屏幕上移動。
    • ACTION_UP:手指從屏幕上鬆開的一瞬間。
    • 通過MotionEvent對象可以獲取點擊事件發生的x和y座標。getX/getY返回相對於當前View左上角的x和y座標;getRawX/getRawY返回相對於手機屏幕左上角的x和y座標。
  • TouchSlop

    • 系統所能識別出的被認爲是滑動的最小距離。TouchSlop是一個和設備相關的常量。通過ViewConfiguration.get(getContext()).getScaledTouchSlop()方法獲取。

1.1.3 VelocityTracker、GestureDetector、Scroller

  • VelocityTracker

    • 速度追蹤。

      /**
      * 在View的onTouchEvent方法中追蹤當前點擊事件的速度
      */
      VelocityTracker velocityTracker = VelocityTracker.obtain();
      velocityTracker.addMovement(event);
      
      /**
      * 獲取當前速度
      * 要在獲取速度前調用computeCurrentVelocity()計算速度。單位ms。
      * 速度是指一段時間內手指所劃過的像素數。帶方向,可以爲負值。
      * 速度 = (終點位置 - 起點位置)/時間段
      */
      velocityTracker.computeCurrentVelocity(1000);
      int xVelocity=(int)velocityTracker.getXvelocity();
      int yVelocity=(int)velocityTracker.getYvelocity();
      
      /**
      * 不需要時,需要重置並回收內存
      */
      velocityTracker.clear()
      velocityTracker.recycle()
      
  • GestureDetector

    • 手勢檢測。

      /**
      * 創建GestureDetector對象,並實現OnGestureListener接口
      */
      GestureDetector mGestureDetector = new GestureDetector(this);
      //解決長按屏幕後無法無法拖動現象
      mGestureDetector.setIsLongpressEnabled(false);
      
      /**
      * 在View的onTouchEvent方法中實現
      */
      boolean consume = mGestureDetector.onTouchEvent(event);
      return consume;
      

      完成上述步驟後,可選擇性地實現OnGestureListener和OnDoubleTapListener中的方法:

      方法名 描述 所屬接口
      onDown 手指輕輕觸摸屏幕的一瞬間,由1個ACTION_DOWN觸發 OnGestureListener
      onShowPress 手指輕輕觸摸屏幕,尚未鬆開或拖動,由1個ACTION_DOWN觸發。和onDown()的區別:它強調的是沒有鬆開或者拖動的狀態。 OnGestureListener
      onSingleTapUp 手指(輕觸摸屏幕後)鬆開,伴隨着1個 ACTION_UP觸發,是單擊行爲。 OnGestureListener
      onScroll 手指按下屏幕並拖動,由1個ACTION_DOWN,多個ACTION_MOVE觸發,是拖動行爲。 OnGestureListener
      onLongPress 用戶長久地按着屏幕不放,即長按。 OnGestureListener
      onFling 用戶按下觸摸屏、快速滑動後鬆開,由1個ACTION_DOWN、多個ACTION_MOVE和1個ACTION_UP觸發,是快速滑動行爲。 OnGestureListener
      onDoubleTap 雙擊,由2次連續的單擊組成。不可能和onSingleTapConirned共存。 OnDoubleTapListener
      onSingleTapConfirmed 嚴格的單擊行爲。和onSingleTapUp的區別:如果觸發了onSingleTapConfirmed,那麼後面不可能再緊跟着另一個單擊行爲,即這隻可能是單擊,而不可能是雙擊中的一次單擊。 OnDoubleTapListener
      onDoubleTapEvent 雙擊行爲,在雙擊的期間,ACTION DOWN、ACTION_ MOVE和ACTION_ UP都會觸發此回調。 OnDoubleTapListener

      常用:onSingleTapUp、onFling、onScroll、onLongPress、onDoubleTap。

      注意:實際開發中,可以不使用GestureDetector,可以完全在View的ontouchEvent方法中實現所需監聽。

  • Scroller

    • 彈性滑動。

    • View的scollTo/scrollBy方法進行滑動時,過程是瞬間完成的,沒有過渡效果。Scroller可以實現有過渡效果的滑動,Scroller本身無法讓View彈性滑動,需要和View的computeScroll方法配合實現。

    • 典型代碼:

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

1.2 View的滑動

1.2.1 使用scrollTo/scrollBy

  • scrollTo實現了基於所傳遞參數的絕對滑動;scrollBy實現了基於當前位置的相對滑動。scrollBy實質上調用的也是scrollTo方法。
  • View內部存在兩個屬性mScrollXmScrollY(單位是像素);這兩個屬性可以通過getScrollXgetScrollY獲得。mScrollX的值等於View左邊緣View內容左邊緣在水平方向上的距離;mScrollY的值等於View上邊緣View內容上邊緣在垂直方向上的距離。
  • scrollTo和scrollBy只能改變View內容的位置而不能改變View在佈局中的位置。
  • 從左向右滑動mScrollX爲負值,反之爲正值;從上往下滑動mScrollY爲負值,反之爲正值。

1.2.2 使用動畫

  • 通過動畫讓一個View進行平移(平移是一種滑動),主要通過操作View的translationXtranslationY屬性實現。可以採用View動畫或者屬性動畫

  • View動畫:(100ms內將一個View從原始位置向右下角移動100個像素)

    <?xml version="1.0" encoding="utf-8"?>
    <set xmlns:android="http://schemas.android.com/apk/res/android"
        android:fillAfter="true"
        android:zAdjustment="normal">
        
        <translate
            android:duration="100"
            android:fromXDelta="0"
            android:fromYDelta="0"
            android:interpolator="@android:anim/linear_interpolator"
            android:toXDelta="100"
            android:toYDelta="100"/>
    </set>
    

    View動畫是對View的影像做操作,不能真正的改變View的位置參數;如果希望動畫後的狀態得以保留需要將fillAfter屬性設爲true,否則動畫完成後回到初始狀態。

  • 屬性動畫:(100ms內將一個View從原始位置向右移動100個像素)

    ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
    
  • 注意:在很久的版本上(現在的版本不存在這個問題),因爲動畫移動的是影像,而真身還在原先位置,從而導致,經過動畫移動後的控件的觸控事件在新位置上無法觸發,在原先位置上可以觸發。

1.2.3 改變佈局參數

  • 改變佈局參數,即改變LayoutParams

    ViewGroup.MarginLayoutParams params =(ViewGroup.MarginLayoutParams)mButton.getLayoutParams();
    params.width +=100;
    params.leftMargin +=100;
    mButton.requestLayout();
    //或者mButton.setLayoutParams(params);
    

1.2.4 滑動方式的對比

  • scrollTo/scrollBy:操作簡單,適合對View內容的滑動。
  • 動畫:操作簡單,適合用於沒有交互的View和實現複雜的動畫效果。
  • 改變佈局參數:操作稍微複雜,適用於有交互的View。

1.3 彈性滑動

1.3.1 使用Scroller

  • 典型代碼:

    Scroller mScroller = new Scroller(mContext);
    
    //緩慢滾動到指定位置
    private void smoothScrollTo(int destX,int destY){
        int scrollX = getScrollX();
        int delta = destX - scrollX;
        // 1000ms 內滑向 destX,效果就是慢慢滑動
        mScroller.startScroll(scrollX,0,delta,1000);
        invalidate();
    }
    
    @Override
    public void computeScroll(){
        if(mScroller.computeScrollOffset()){
            scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
            postInvalidate();
        }
    }
    
  • Scroller實現讓View彈性滑動原理:

    • startScroll方法:僅是保存傳遞的參數。
    • invalidate方法:該方法會導致View重繪,在View的draw方法中會調用computeScroll方法,computeScroll在View中是一個空實現,需要我們實現。
    • computeScroll方法:示例代碼實現該方法,完成彈性滑動。當View重繪後會在draw方法中調用computeScroll,computeScroll會向Scroller獲取當前的ScrollX和ScrollY,然後通過scrollTo方法滑動到當前位置,之後postInvalidate進行二次重繪。與第一次重繪相似,還是會調用computeScroll方法,然後獲取當前的ScrollX和ScrollY,再通過scrollTo方法滑動到當前位置,如此反覆,直至滑動過程結束。
    • computeScrollOffset方法:該方法通過時間的流逝計算出當前的ScrollX和ScrollY的值,類似於差值器的概念。該方法的返回爲true表示滑動還未結束,false表示滑動結束。
  • Scroller的工作原理

    Scroller 本身並不能實現View的滑動,需要配合View的computeScroll方法才能完成彈性滑動的效果。通過不斷地重繪View,每一次重繪距離滑動起始時間會有一個時間間隔,通過這個時間間隔Scroller計算出View當前的滑動位置,知道位置後就可以通過scrollTo方法完成View的滑動。View的每一次重繪都會導致View進行小幅度的滑動,多次的小幅度滑動組成了彈性滑動。

1.3.2 使用動畫

  • 動畫本身就是一種漸近過程。

    //100ms內將一個View從原始位置向右移動100個像素
    ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
    
  • 模仿Scroller實現彈性滑動:

    final int startX = 0;
    final int deltaX = 100;
    final ValueAnimator animator = ValueAnimator.ofInt(0, 1).setDuration(1000);
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            float fraction=animator.getAnimatedFraction();
            mButton.scrollTo(startX+(int)(deltaX*fraction),0);
        }
    });
    

1.3.3 使用延時策略

  • 核心思想

    通過發送一系列延時消息從而達到一種漸進式的效果。可以使用Handler或View的postDelayed方法,也可以使用線程的sleep方法。對於postDelayed方法,可以通過其延時發送一個消息,在消息中進行View滑動,連續地發送這種延時消息,就可以實現彈性滑動效果。對於sleep方法,通過在while循環中不斷地滑動View和sleep,也可以實現彈性滑動效果。

  • 示例代碼:

    private static final int MESSAGE_SCROLL_TO = 1;
    private static final int FRAME_COUNT = 30;
    private static final int DELAYED_TIME = 30;
    private int mCount = 0;
    
    @SuppressLint("HandlerLeak")
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what){
                case MESSAGE_SCROLL_TO:{
                    mCount++;
                    if (mCount<=FRAME_COUNT){
                        float fraction = mCount/(float)FRAME_COUNT;
                        int scrollX = (int)(fraction*100);
                        mButton.scrollTo(scrollX,0);
                        mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO,DELAYED_TIME);
                    }
                    break;
                }
                default:
                    break;
            }
        }
    };
    

1.4 View的事件分發機制

1.4.1 點擊事件的傳遞規則

  • 點擊事件,即MotionEvent。點擊事件的事件分發,就是對MotionEvent事件的分發過程,即當MotionEvent產生後,系統需要把這個事件傳遞給一個具體的View,這個過程就是分發過程。

  • 點擊事件的分發過程中三個重要的方法:

    • public boolean dispatchTouchEvent(MotionEvent event):進行事件分發。如果事件能夠傳遞給當前View,此方法一定會被調用,返回結果受當前View的onTouchEvent和下級View的dispatchTouchEvent方法影響,表示是否消耗當前事件。
    • public boolean onInterceptTouchEvent(MotionEvent ev):判斷是否攔截某個事件。如果當前View攔截了某個事件,那麼在同一個事件蓄力中,此方法不會再被調用,返回結果表示是否攔截當前事件。
    • public boolean onTouchEvent(MotionEvent event):處理點擊事件。返回結果表示是否消耗當前事件,如果不消耗,則在同一個事件序列中,當前View不再接收事件。
  • dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent關係:

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
     boolean consume = false;
     if (onInterceptTouchEvent(event)){
         consume = onTouchEvent(event);
     }else {
         consume = getFocusedChild().dispatchTouchEvent(event);
     }
     return consume;
    }
    
    • 對於一個根ViewGroup而言,點擊事件產生後,首先會傳遞給它,這時它的dispatchTouchEvent方法被調用;如果這個ViewGroup的onInterceptTouchEvent方法返回true,表示它要攔截當前事件,接着事件交付給ViewGroup處理(即調用它的onTouchEvent方法),如果這個ViewGroup的onInterceptTouchEvent方法返回false,表示它不攔截當前事件,事件會繼續傳遞給它的子元素,子元素的dispatchTouchEvent方法被調用,如此反覆直到事件被最終處理。
    • 當一個View需要處理事件時,如果設置了OnTouchListener,那麼OnTouchListener中的onTouch方法會被回調。如果onTouch方法返回false,則當前View的onTouchEvent方法會被調用;如果返回true,則當前View的onTouchEvent方法不會被調用。View的OnTouchListener優先級比onTouchEvent高。在onTouchEvent中,如果當前設置的有OnClickListener,那麼其onClick方法會被調用,可見OnClickListener優先級最低,位於事件傳遞的尾端。
    • 當一個點擊事件產生後,其傳遞順序:Activity->Window->View(頂級View),頂級View再按照事件分發機制去分發事件。
    • 如果一個View的onTouchEvent返回false,那麼它的父容器的onTouchEvent將會被調用,以此類推,如果所有元素都不處理這個事件,那麼該事件最終會傳遞給Activity處理,即Activity的onTouchEvent方法會被調用。
  • 事件傳遞機制結論:

    • 1**.事件序列**是指從手指接觸屏幕的那一刻起,到手指離開屏幕的那一刻結束,在這個過程中所產生的一系列事件,該事件序列以down事件開始,中間包含數量不定的move事件,最終以up事件結束。
    • 2.正常情況下,一個事件序列只能被一個View攔截並消耗。一旦一個元素攔截了某次事件,那麼同一個事件序列的所有事件都會直接交給它處理。
    • 3.某個View一旦決定攔截,那麼這一個事件序列都只能由它處理,並且它的onInterceptTouchEvent不會再被調用。
    • 4.某個View一旦開始處理事件,如果它不消耗ACTION_DOWN事件(onTouchEvent返回false),那麼同一事件序列中的其他事件都不會再交給它來處理,並且事件將重新交給它的父元素去處理,即父元素的onTouchEvent會被調用。換個說法:事件一旦決定交給一個View處理,它就必須消耗掉,否則,同一事件序列中的其它事件就不交給它處理了。
    • 5.如果View不消耗ACTION_DOWN以外的其他事件,那麼這個點擊事件會消失,父元素的onTouchEvent也不會被調用,當前View可以持續接受到後續事件,最終消失的點擊事件會傳遞給Activity處理。
    • 6.ViewGroup默認不攔截任何事件
    • 7.View沒有onInterceptTouchEvent方法,一旦有點擊事件傳遞給它,它的onTouchEvent方法會被調用。
    • 8.View的onTouchEvent默認都會消耗事件(返回true),除非它是不可點擊的(clickable和longClickable同時爲false)。View的longClickable屬性默認都爲false,clickable屬性分情況,Button的clickable屬性默認爲true;TextView的clickable屬性默認爲false。
    • 9.View的enable屬性不影響onTouchEvent的默認返回值。即使一個View是disable狀態的,只要它的clickable或者longClickable有一個爲true,那麼它的onTouchEvent就返回true。
    • 10.onClick會發生的前提是當前的View是可點擊的,並且它收到了down和up的事件。
    • 11.事件傳遞過程是有外向內的,即事件總是先傳遞給父元素,然後再由父元素分發給子View,通過requestDisallowInterceptTouchEvent方法可以在子元素中干預父元素的事件分發過程,但是ACTION_DOWN事件除外。

1.4.2 事件分發源碼解析

1.5 View的滑動衝突

  • 界面中存在內外兩層可以同時滑動的情況,就會產生滑動衝突。

1.5.1 常見的滑動衝突場景

  • 場景一:外部滑動方向和內部滑動方向不一致。

    • 主要是將ViewPager和Fragment配合使用所組成的頁面滑動效果,可以通過左右滑動來切換界面,每個界面往往是一個ListView。ViewPager內部處理了這種滑動衝突,使用ViewPager時無需關心。如果使用的是ScrollView而非ViewPager,就需要手動處理滑動衝突。
  • 場景二:外部滑動方向和內部滑動方向一致。

    • 系統無法判斷用戶是希望那一層滑動,還是都滑動。
  • 場景三:場景一、場景二嵌套。

1.5.2 滑動衝突的處理規則

  • 場景一:用戶左右滑動時,需要讓外部的View攔截點擊事件;上下滑動時,需要讓內部的View攔截點擊事件。我們可以根據特徵來解決滑動衝突:1.可以根據滑動路徑和水平方向所形成的夾角;2.可以根據水平方向和垂直方向上的距離;3.可以根據水平和豎直方向上的速度。
  • 場景二:根據業務上的規定製定相應的處理規則。
  • 場景三:結合場景一、場景二。

1.5.3 滑動衝突的解決方式

  • 外部攔截法

    • 點擊事件都先經過父容器的攔截處理,如果父容器需要此事件就攔截,如果不需要就不攔截。外部攔截法需要重寫父容器的onInterceptTouchEvent方法,在其中做相應的攔截即可:

      //僞代碼
      @Override
      public boolean onInterceptTouchEvent(MotionEvent ev) {
          boolean intercepted = false;
          int x = (int) ev.getX();
          int y = (int) ev.getY();
          switch (ev.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;
      }
      

      針對不同滑動衝突,只需修改父容器需要當前點擊事件的這個條件,其他不需也不能修改。對於ACTION_DOWN事件,父容器必須返回false(即不攔截),如果父容器攔截了ACTION_DOWN事件,後續的ACTION_MOVE和ACTION_UP事件都會交由父容器處理,無法傳遞給子元素。對於ACTION_MOVE事件,根據需要決定是否攔截,如果需要就返回true,不需要就返回false。對於ACTION_UP事件,需要返回false,該事件本身不存在太多意義。

      補充:假設事件交由子元素處理,如果父容器在ACTION_UP時返回true,就會導致子元素無法接收到ACTION_UP事件,這時,子元素onClick事件無法觸發。由於父容器的特殊性,一旦開始攔截任何一個事件,後續事件都會交由其處理,即使onInterceptTouchEvent方法在ACTION_UP時返回false,ACTION_UP事件也會作爲最後一個事件傳遞給父容器。

  • 內部攔截法

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

    • 這種方法和Android中的事件分發機制不一致, 需要配合requestDisallowInterceptTouchEvent方法使用:

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

      針對不同滑動衝突,只需修改父容器需要當前點擊事件的這個條件,其他不需也不能修改。子元素需要做處理以外,父元素也要默認攔截除了ACTION_DOWN以外的其他事件,這樣當子元素調用parent.requestDisallowInterceptTouchEvenf(false)方法時,父元素才能繼續攔截所需的事件。

    • 父容器不能攔截ACTION_DOWN事件是因爲ACTION_DOWN事件不受FLAG_DISALLOW_INTERCEPT標記位控制;一旦父容器攔截ACTION_DOWN事件,那麼所有的事件都無法傳遞到子元素中去,內部攔截就無法起作用了。父元素如下修改:

      public boolean onInterceptTouchEvent(MotionEvent ev) {
          int action = ev.getAction();
          if (action==MotionEvent.ACTION_DOWN){
              return false;
          }else {
              return true;
          }
      }
      

1.6 參考資料

  • Android開發藝術探索
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章