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开发艺术探索
发布了64 篇原创文章 · 获赞 65 · 访问量 8814

猜你喜欢

转载自blog.csdn.net/qq_33334951/article/details/103188433