[Android]《Android开发艺术探索》学习笔记-View的事件体系

一、View的基础知识

1、什么是View

View是Android中所有控件的基类,View是一种界面层控件的一种抽象。ViewGroup也继承了View,说明了View本身就可以是单个控件也可以是多个控件组成的一组控件,其内部可以有子View,这样就形成了View树的结构。

2、View的位置参数

View的位置主要由它的四个顶点来决定,分别对应于View的四个属性:top、left、right和bottom,其中top是左上角纵坐标,left是左上角横坐标,right是右下角横坐标,bottom是右下角纵坐标(这些坐标都是相对View的父容器来说的,因此它是一种相对坐标),下图是View的位置坐标和父容器的关系。Android中的x轴y轴的正方向是右和下。

我们可以轻松得到View的宽高和坐标的关系:

width = right - left
height = bottom - top

这四个参数可以由以下方式获取:

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

Android3.0后,View增加了x、y、translationX和translationY这几个参数。其中x和y是View左上角的坐标,而translationX和translationY是View左上角相对于容器的偏移量。他们之间的换算关系如下:

x = left + translationX;
y = top + translationY;

View在平移过程中,top和left表示的是原始左上角的位置信息,其值并不会发生改变,改变的是x、y、translationX和translationY。

3、MotionEvent和TouchSlop

(1)MotionEvent:在手指接触屏幕后产生的一系列事件中,典型的事件类型有:

-ACTION_DOWN:手指刚接触屏幕

-ACTION_MOVE:手指在屏幕上移动

-ACTION_UP:手指从屏幕上松开的一瞬间

点击屏幕后离开松开:DOWN->UP

点击屏幕滑动一会再松开:DOWN->MOVE->…->MOVE->UP

通过MotionEven对象我们可以得到事件发生的x和y坐标,我们可以通getX/getY方法,返回的是相对于当前View左上角的x和y坐标;getRawX/getRawY方法,返回的是相对于手机屏幕左上角的x和y坐标。

(2)TouchSlop:TouchSloup是系统所能识别出的被认为是滑动的最小距离,这是一个常量,与设备有关,可以通过ViewConfiguration.get(getContext()).getScaledTouchSloup()获得。这个常量可以用来给我们过滤一些滑动事件,如果没达到这个值,即可认为这个不是滑动来提高用户体验。

4、VelocityTracker、GestureDetector和Scroller

(1)VelocityTracker:速度追踪,用于追踪手指在滑动过程中的速度,包括水平放向速度和竖直方向速度。

使用过程很简单,首先在View的onTouchEvent方法中追踪当前单击事件的速度:

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

我们知道当前滑动速度时,可以通过以下方式来获取滑动速度:

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

获取速度之前必须先计算速度,即调用computeCurrentVelocity方法,这里指的速度是指一段时间内手指滑过的像素数,1000指的是1000毫秒,得到的是1000毫秒内滑过的像素数。速度可正可负,可以用以下公式来表示:

速度 = (终点位置 - 起点位置) / 时间段

最后我们不使用它的时候用clear方法来重置并回收内存:

velocityTracker.clear();
velocityTracker.recycle();

(2)GestureDetector: 手势检测,用于辅助检测用户的单击、滑动、长按、双击等行为。

使用很简单,首先创建一个GestureDetector对象并实现OnGestureListener接口,根据需要,也可实现OnDoubleTapListener接口从而监听双击行为:

GestureDetector mGestureDetector = new GestureDetector(this);
//解决长按屏幕后无法拖动的现象
mGestureDetector.setIsLongpressEnabled(false);

接着接管目标View的onTouchEvent,在待监听View的onToucheEvent方法中添加如下实现:

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

实现OnGestureListener和OnDoubleTapListener接口中的方法,其中常用的方法有:onSingleTapUp(单击)、onFling(快速滑动)、onScroll(拖动)、onLongPress(长按)和onDoubleTap(双击)。 建议:如果只是监听滑动相关的,可以自己在onTouchEvent中实现,如果要监听双击这种行为,那么就使用GestureDetector。

(3)Scroller:弹性滑动对象,用于实现View的弹性滑动。其本身无法让View他行滑动,需要和View的computeScroll方法配合使用才能完成这个功能。

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

 

二、View的滑动

1、使用scrollTo/scrollBy

scrollBy实际调用了scrollTo,它实现了基于当前位置的相对滑动,而scrollTo则实现了绝对滑动,scrollTo和scrollBy只能改变View的内容位置而不能改变View在布局中的位置,滑动偏移量mScrollX和mScrollY的正负与实际滑动方向相反,即从左向右滑动,mScrollX为负值,从上往下滑动mScrollY为负值。

2、使用动画

主要是操作View的translationX和translationY属性,既可以用传统的View动画,也可以用属性动画(如果用属性动画为了能够兼容3.0以下的版本,需要采用开源动画库nineolddandroids)。

如果用View动画代码如下:

<?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>

如果用属性动画代码如下:

ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();

(3)改变布局参数

就是改变LayoutParams。

(4)各种滑动方式的对比

scrollTo/scrollBy:操作简单,适合对View内容的滑动;

动画:操作简单,主要用于没有交互的View和实现复杂的动画效果;

改变布局参数:操作稍微复杂,适用于有交互的View。

 

三、弹性滑动

1、使用Scroller

  Scroller scroller = new Scroller(mContext);
  //缓慢移动到指定位置
  private void smoothScrollTo(int destX,int dextY){
      int scrollX = getScrollX();
      int deltaX = destX - scrollX;
      //1000ms内滑向destX,效果就是缓慢滑动
      mScroller.startSscroll(scrollX,0,deltaX,0,1000);
      invalidate();
  }

  @override
  public void computeScroll(){
      if(mScroller.computeScrollOffset()){
          scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
          postInvalidate();
      }
  }

从上面代码可以知道,我们首先会构造一个Scroller对象,并调用他的startScroll方法,其实该方法并没有让View实现滑动,只是把参数保存下来,我们来看看startScroll方法的实现:

  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;
  }

startX和startY表示滑动的起点,dx和dy表示的是滑动的距离,而duration表示的是滑动时间。这里的滑动指的是View内容的滑动,在startScroll方法被调用后,马上调用invalidate方法,这是滑动的开始,invalidate方法会导致View的重绘,在View的draw方法中调用computeScroll方法,computeScroll又会去向Scroller获取当前的scrollX和scrollY;然后通过scrollTo方法实现滑动,接着又调用postInvalidate方法进行第二次重绘,一直循环,知道computeScrollOffset()方法返回值为false才结束整个滑动过程。我们再来看看computeScrollOffset()的关键代码:

  public boolean computeScrollOffset(){
      ...
      int timePassed = (int)(AnimationUtils.currentAnimationTimeMills() - 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(y * mDeltaY);
              break;
          ...
          }
      }
      return true;
  }

可以看出,computeScroll向Scroller获取当前的scrollX和scrollY其实是通过计算时间流逝的百分比来获得的,每一次重绘距滑动起始时间会有一个时间间距,通过这个时间间距Scroller就可以得到View当前的滑动位置,然后就可以通过scrollTo方法来完成View的滑动了。

Scroller本身不能实现View的滑动,它需要配合View的computeScroll方法才能完成弹性滑动的效果,它不断让View重绘,而每一次重绘距滑动起始时间会有一个时间间隔,通过这个时间间隔Scroller就可以得出View当前的滑动位置,知道了滑动位置就可以通过scrollTo方法来完成View的滑动,就这样每一次重绘都会导致View进行小幅度的滑动,而多次小幅度的滑动就组成了弹性滑动,这就是Scroller的工作机制。

2、通过动画

动画本身就是一种渐近的过程,因此通过它来实现的滑动天然就具有弹性效果。

ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();

以上代码让一个View在100ms内向右移动100像素。当然,我们也可以利用动画来模仿Scroller实现View弹性滑动的过程:

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

上面的动画本质上是没有作用于任何对象上的,他只是在1000ms内完成了整个动画过程,利用这个特性,我们就可以在动画的每一帧到来时获取动画完成的比例,根据比例计算出View所滑动的距离。

3、使用延时策略

延时策略的核心思想是通过发送一系列延时信息从而达到一种渐近式的效果。可以采用Handler或View的postDelayed方法或线程的sleep方法。以下是Handler的一个示例代码:

  private static final int MESSAGE_SCROLL_TO = 1;
  private static final int FRAME_COUNT = 30;
  private static final int DELATED_TIME = 33;

  private int mCount = 0;

  @suppressLint("HandlerLeak")
  private Handler handler = new handler(){
      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);
                      mButton1.scrollTo(scrollX,0);
                      mHandelr.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO , DELAYED_TIME);
                  }
                  break;
          default : break;
          }
      }
  }

 

四、View的事件分发机制

1、点击事件的传递规则

所谓点击事件的事件分发,其实就是对MotionEvent事件的分发过程,即当一个MotionEvent产生了以后,系统需要把这个时间传递给一个具体的View,而这个传递过程就是分发过程。点击事件的分发过程由三个很重要的方法来完成:dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent。

public boolean dispatchTouchEvent(MotionEvent ev):用来进行事件的分发。如果事件能够传递给当前View,那么此方法一定会被调用,返回结果受当前View的onTouchEvent和下级View的dispatchTouchEvent影响,表示能否消耗事件。

public boolean onInterceptTouchEvent(MotionEvent ev):用来判断是否拦截某个事件,如果当前View拦截了某个事件,那么在同一个事件序列中,此方法不会被再次调用,返回结果表示是否拦截当前事件。

public boolean onTouchEvent(MotionEvent ev):在dispatchTouchEvent中调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前View无法再次接收到事件。

以下伪代码表示上述三者的关系:

  public boolean dispatchTouchEvent (MotionEvent ev){
      boolean consume = false;
      if (onInterceptTouchEvnet(ev){
          consume = onTouchEvent(ev);
      } else {
          consume = child.dispatchTouchEnvet(ev);
      }

      return consume;
  }

对于一个根ViewGroup来说,点击事件产生后,首先会传递给它,这时它的dispatchTouchEvent就会被调用,如果这个ViewGroup的onInterceptTouchEvent返回true,那么表示它要拦截这个事件,接着事件就会交给这个ViewGroup处理,它的onTouchEvent就会被调用;否则当前事件就会继续传递给它的子元素,调用子元素的dispatchTouchEvent,如此反复处理。

当一个View需要处理事件时,如果它设置了OnTouchListener,那么onTouch方法会被调用,如果onTouch返回false,则当前View的onTouchEvent方法会被调用,返回true则不会被调用,由此可见onTouchListener的优先级比onTouchEvent高,在onTouchEvent方法中如果设置了OnClickListener,那么他的onClick方法会被调用。由此可见处理事件时的优先级关系: onTouchListener > onTouchEvent > onClickListener

当一个点击事件产生后,它的传递过程遵循如下顺序:Activity->Window->View,即事件总是先传递给Activity,Activity再传递给Window,Window传给View才执行上述事件分发机制。

总结:

(1)一个事件系列以down事件开始,中间包含数量不定的move事件,最终以up事件结束。

(2)正常情况下,一个事件序列只能由一个View拦截并消耗。

(3)某个View拦截了事件后,该事件序列只能由它去处理,并且它的onInterceptTouchEvent不会再被调用。

(4)某个View一旦开始处理事件,如果它不消耗ACTION_DOWN事件(onTouchEvnet返回false),那么同一事件序列中的其他事件都不会交给他处理,并且事件将重新交由他的父元素去处理,即父元素的onTouchEvent被调用。

(5)如果View不消耗ACTION_DOWN以外的其他事件,那么这个事件将会消失,此时父元素的onTouchEvent并不会被调用,并且当前View可以持续收到后续的事件,最终消失的点击事件会传递给Activity去处理。

(6)ViewGroup默认不拦截任何事件。

(7)View没有onInterceptTouchEvent方法,一旦事件传递给它,它的onTouchEvent方法会被调用。

(8)View的onTouchEvent默认消耗事件,除非他是不可点击的(clickable和longClickable同时为false。

(9)View的enable不影响onTouchEvent的默认返回值。

(10)onClick会发生的前提是当前View是可点击的,并且收到了down和up事件。

(11)事件传递过程总是由外向内的,即事件总是先传递给父元素,然后由父元素分发给子View,通过requestDisallowInterceptTouchEvent方法可以在子元素中干预父元素的分发过程,但是ACTION_DOWN事件除外。

2、事件分发的源码解析(见书P144-P154)

 

五、View的滑动冲突

界面中只要内外两层同时可以滑动,这个时候就会产生滑动冲突。

1、常见的滑动冲突场景

(1)外部滑动方向和内部滑动方向一致

(2)外部滑动方向和内部滑动方向一致

(3)上面两种情况的嵌套

2、滑动冲突的处理规则

对于(1)的处理规则是当用户左右滑动时,需要让外部的View来拦截点击事件,当用户上下滑动时,需要让内部View拦截点击事件。我们可以通过滑动的角度、距离差和速度差来做判断。

对于(2)能在业务上寻找突破点,因为其不能通过滑动的角度、距离差和速度差来做判断,根据业务需求,规定什么时候让外部View拦截事件,什么时候由内部View拦截事件。

对于(3)就更复杂了,同样根据需求在业务上找到突破点。

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:{
             intercepted = false;
             break;
     }
     case MotionEvent.ACTION_MOVE:{
             if (父容器需要当前事件) {
                 intercepted = true;
             } else {
                 intercepted = flase;
             }
             break;
     }
     case MotionEvent.ACTION_UP:{
             intercepted = false;
             break;
     }
     default : break;
 }
 mLastXIntercept = x;
 mLastYIntercept = y;
 return intercepted;
}

(2)内部拦截法

内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗,否则就交由父容器进行处理。这种方法与Android事件分发机制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作。需要重写子元素的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);
             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)方法时,父元素才能继续拦截所需的事件。因此,父元素要做以下修改:

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

 

发表评论

电子邮件地址不会被公开。 必填项已用*标注