艺术探索笔记:View 的事件体系

View 的事件体系

View 是一种界面层的控件的一种抽象,它代表着一个控件。ViewGroup 继承自 View,内部包含了许多个控件,即一组 View。这意味着 View 本身可以是单个控件也可以是多个控件组成的一组控件,即 View 树的结构。
View Hierarchy
所以 View 的事件体系即控件的事件体系。

View的基础知识

View的位置参数

View 的位置主要由它的四个顶点来决定,分别对应于 View 的四个属性:top、left、right、bottom。这些坐标都是相对于 View 的父容器来说的,它是一种相对坐标。

left = getLeft();
right = getRight();
top = getTop();
bottom = getBottom();

此外,View 还有几个参数:x、y、translateX、translateY,其中 x 和 y 是 View 左上角的坐标,translateX 和 translateY 是 View 左上角相对于父容器的偏移量。

x = left + translateX
y = right + translateY

View 在平移的过程中,top 和 left 表示的是原始左上角的位置参数,其值不会发生改变,此时发生改变的是x、y、translationX和translationY这四个参数。

MotionEvent、TouchSlop

MotionEvent

在手指接触屏幕后所产生的一系列事件中,典型的事件类型有如下几种:

  • ACTION_DOWN——手指刚接触屏幕;

  • ACTION_MOVE——手指在屏幕上移动;

  • ACTION_UP——手机从屏幕上松开的一瞬间。

正常情况下,一次手指触摸屏幕的行为会触发一系列点击事件,考虑如下几种情况:

  • 点击屏幕后离开松开,事件序列为DOWN -> UP;
  • 点击屏幕滑动一会再松开,事件序列为DOWN -> MOVE -> … > MOVE -> UP。

过 MotionEvent 对象我们可以得到点击事件发生的 x 和 y 坐标。

  • getX/getY 相对于当前 View 左上角的 x 和 y 坐标

  • getRawX/getRawY 相对于手机屏幕左上角的 x 和 y 坐标

TouchSlop

TouchSlop 是系统所能识别出的被认为是滑动的最小距离。这是一个常量,和设备有关,在不同设备上这个值可能是不同的,可以通过系统方法获取。

ViewConfiguration.get(context).getScaledTouchSlop();

VelocityTracker、GestureDetector、Scroller

VelocityTracker

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

if (tracker == null) {
	//使用 obtain()方法得到这个类的实例
	tracker = VelocityTracker.obtain();
}
//addMovement(MotionEvent)函数将你接受到的 motionEvent 加入到 tracker 类实例中
tracker.addMovement(event);
// 计算 1s 内手指的滑动速度
tracker.computeCurrentVelocity(1000);
int xVelocity = tracker.getXVelocity();
int yVelocity = tracker.getYVelocity();
// 结束时,重置并回收内存
tracker.clear();
tracker.recycle();
tracker = null;

在上面的例子中,我们通过 computeCurrentVelocity(int) 来初始化速率的单位,通过 velocity 的正负值来判断滑动的方向。

GestureDetector

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

// 初始化 GestureDetector 对象
gestureDetector = new GestureDetector(context, this);
gestureDetector.setIsLongpressEnabled(false);
// onTouchEvent
return gestureDetector.onTouchEvent(event);

常用方法:onSingleTapUp(单击)、 onFling(快速滑动)、onScroll(拖动)、onLongPress(长按)和onDoubleTap(双击)。

Scroller

用于实现 View 的弹性滑动。使用 View 的 scrollTo、scrollBy 方法和 View 的 computeScroll 方法配合使用实现弹性滑动。

scroller = new Scroller(context);

scroller.fling();
postInvalidate();

@Override
public void computeScroll() {
    super.computeScroll();
    if (scroller.computeScrollOffset()) {
        scrollTo(scroller.getCurrX(), scroller.getCurrY());
        postInvalidate();
    }
}

VIew的滑动

View 的滑动主要可以通过三种方式来实现:

  • 通过 View 本身提供的 scrollTo/scrollBy 方法来实现滑动。

  • 通过动画给 View 施加平移效果来实现滑动。

  • 通过改变 View 的 LayoutParams 使得 View 重新布局从而实现滑动。

scrollTo、scrollBy

使用 scrollTo/scrollBy 进行滑动的本质是对 View 的 mScrollX/mScrollY 属性进行修改并重绘界面。 mScrollX 的值总是等于 View 左边缘和 View 内容左边缘在水平方向的距离,而 mScrollY 的值总是等于 View 上边缘和 View 内容上边缘在竖直方向的距离。

scrollTo 和 scrollBy 只能改变 View 内容的位置而不能改变 View 在布局中的位置。从左向右滑动,那么 mScrollX为负值,反之为正值;如果从上往下滑动,那么 mScrollY 为负值,反之为正值。

Animation

Android 中动画主要分为以下几类:

  • 逐帧动画

  • 补间动画

  • 属性动画

逐帧动画

逐帧动画的原理就是让一系列的静态图片依次播放,利用人眼“视觉暂留”的原理,实现动画。

通常是采用 XML 资源进行定义的,需要在 <animation-list …/> 标签下使用 <item …/> 子元素标签定义动画的全部帧,并指定各帧的持续时间。亦可通过 AnimationDrawable 类来实现。

补间动画

补间动画就是指开发者指定动画的开始、动画的结束的"关键帧",而动画变化的"中间帧"由系统计算,并补齐。补间动画有四种:

  • 淡入淡出:alpha
  • 位移:translate
  • 缩放:scale
  • 旋转:rotate

补间动画一般也是通过 xml 来实现,对于 xml 形式补间动画的定义,也是需要在 res/anim/ 文件夹下定义动画资源 。亦可通过 Animation 类来实现,或 AnimationSet 来实现组合动画。

属性动画

属性动画可以看作是增强版的补间动画。主要通过使用 ValueAnimator 或者 ObjectAnimator 的静态工厂方法创建动画。亦可使用资源文件来定义动画。

PS:自定义补间动画、 interpolator、Transformation 等内容见第七章。

改变布局参数

通过修改控件 LayoutParams 对象的属性来进行动画。

弹性滑动

上述的滑动方式直接使用都会过于生硬,所以要实现弹性滑动。所谓弹性活动,就是将一次比较大的滑动分割成若干个小的滑动,并在一定的时间内完成。常见的方式:

  • Scroller
  • Handler#postDelayed
  • Animation

Scroller

实现代码见上方示例。Scroller 中定义了 startScroll 方法进行平滑滚动,filing 方法进行惯性滑动。

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

startX和startY表示的是滑动的起点,dx和dy表示的是要滑动的距离,而duration表示的是滑动时间,即整个滑动过程完成所需要的时间。

在调用 scroller.startScroll 方法后,需要调用 invalidate 方法进行重绘。View 的 draw 方法中会调用 computeScroll 方法,computeScroll 又会去向 Scroller 获取当前的 scrollX 和 scrollY;然后通过 scrollTo 方法实现滑动;接着又调用 postInvalidate 方法来进行第二次重绘,这一次重绘的过程和第一次重绘一样,还是会导致 computeScroll 方法被调用;然后继续向 Scroller 获取当前的 scrollX 和 scrollY,并通过 scrollTo 方法滑动到新的位置,如此反复,直到整个滑动过程结束。

scroller.computeScrollOffset 方法用来判断动画是否已经结束,返回 true 表示滑动还未结束,false 则表示滑动已经结束。computeScrollOffset 方法通过插值器 ViscousFluidInterpolator 来根据时间流逝的百分比计算出 scrollX 和 scrollY 改变的百分比并计算出当前值。

点击事件分发机制

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

  • dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent ev)

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

  • onInterceptTouchEvent

    public boolean onInterceptTouchEvent(MotionEvent event)
    

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

  • onTouchEvent

    public boolean onTouchEvent(MotionEvent event)
    

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

    上述三个方法之间的关系可以用如下伪代码来表示:

    public boolean dispatchTouchEvent(MotionEvent event) {
      boolean consume = false;
      if(onInterceptTouchEvent(event)) {
          consume = onTouchEvent(event);
      } else {
          consume = child.dispatchTouchEvent(event);
      }
      return consume;
    }
    

通过上面的伪代码,我们也可以大致了解点击事件的传递规则:对于一个根 ViewGroup 来说,点击事件产生后,首先会传递给它,这时它的 dispatchTouchEvent 就会被调用,如果这个 ViewGroup 的 onInterceptTouchEvent 方法返回 true 就表示它要拦截当前事件,接着事件就会交给这个 ViewGroup 处理,即它的 onTouchEvent 方法就会被调用;如果这个 ViewGroup 的 onInterceptTouchEvent 方法返回 false 就表示它不拦截当前事件,这时当前事件就会继续传递给它的子元素,接着子元素的 dispatchTouchEvent 方法就会被调用,如此反复直到事件被最终处理。

当一个点击事件产生后,它的传递过程遵循如下顺序:Activity -> Window -> View,即事件总是先传递给 Activity, Activity 再传递给 Window,最后 Window 再传递给顶级 View。顶级 View 接收到事件后,就会按照事件分发机制去分发事件。考虑一种情况,如果一个 View 的 onTouchEvent 返回 false,那么它的父容器的 onTouchEvent 将会被调用,依此类推。如果所有的元素都不处理这个事件,那么这个事件将会最终传递给 Activity 处理,即 Activity 的 onTouchEvent 方法会被调用。

当一个 View 需要处理事件时,如果它设置了 OnTouchListener,那么 OnTouchListener 中的 onTouch 方法会被回调。这时事件如何处理还要看 onTouch 的返回值,如果返回 false,则当前 View的 onTouchEvent 方法会被调用;如果返回 true,那么 onTouchEvent 方法将不会被调用。由此可见,给 View 设置的 OnTouchListener,其优先级比 onTouchEvent 要高。在 onTouchEvent 方法中,如果当前设置的有 OnClickListener,那么它的 onClick 方法会被调用。可以看出,平时我们常用的 OnClickListener,其优先级最低,即处于事件传递的尾端。

结论
  1. 同一个事件序列是指从手指接触屏幕的那一刻起,到手指离开屏幕的那一刻结束,在这个过程中所产生的一系列事件,这个事件序列以 down 事件开始,中间含有数量不定的 move 事件,最终以 up 事件结束。
  2. 正常情况下,一个事件序列只能被一个 View 拦截且消耗。这一条的原因可以参考(3),因为一旦一个元素拦截了某此事件,那么同一个事件序列内的所有事件都会直接交给它处理,因此同一个事件序列中的事件不能分别由两个 View 同时处理,但是通过特殊手段可以做到,比如一个 View 将本该自己处理的事件通过 onTouchEvent 强行传递给其他 View 处理。
  3. 某个 View 一旦决定拦截,那么这一个事件序列都只能由它来处理(如果事件序列能够传递给它的话),并且它的 onInterceptTouchEvent 不会再被调用。这条也很好理解,就是说当一个 View 决定拦截一个事件后,那么系统会把同一个事件序列内的其他方法都直接交给它来处理,因此就不用再调用这个 View 的 onInterceptTouchEvent 去询问它是否要拦截了。
  4. 某个 View 一旦开始处理事件,如果它不消耗 ACTION_DOWN 事件(onTouchEvent 返回了false),那么同一事件序列中的其他事件都不会再交给它来处理,并且事件将重新交由它的父元素去处理,即父元素的 onTouchEvent 会被调用。意思就是事件一旦交给一个 View 处理,那么它就必须消耗掉,否则同一事件序列中剩下的事件就不再交给它来处理了。
  5. 如果 View 不消耗除 ACTION_DOWN 以外的其他事件,那么这个点击事件会消失,此时父元素的 onTouchEvent 并不会被调用,并且当前 View 可以持续收到后续的事件,最终这些消失的点击事件会传递给 Activity 处理。
  6. ViewGroup 默认不拦截任何事件。Android 源码中 ViewGroup 的 onInterceptTouchEvent 方法默认返回 false。
  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 事件除外。

View的滑动冲突

常见的滑动冲突场景可以简单分为如下三种:

  1. 外部滑动方向和内部滑动方向不一致

​ 针对这种场景的滑动,可以根据滑动的距离差来进行判断

  • 根据滑动路径和水平方向所形成的的夹角
  • 根据水平方向和竖直方向的距离差
  • 根据水平和竖直方向的速度差
  1. 外部滑动方向和内部滑动方向一致

    针对这种场景,只能从业务上来进行规定。

  2. 上面两种情况的嵌套

    和场景 2 一样,它的滑动规则更为复杂,也只能从业务上来规定。

滑动冲突的解决方式

针对滑动冲突,这里给出两种解决滑动冲突的方式:外部拦截法和内部拦截法。

外部拦截法

所谓外部拦截法是指点击事情都先经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要此事件就不拦截,这样就可以解决滑动冲突的问题。外部拦截法需要重写父容器的 onInterceptTouchEvent 方法,在内部做相应的拦截即可。

public boolean onTouchEvent(MotionEvent event) {
    boolean intercepted = false;
	switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                // 必须返回 false,外部容器一旦拦截 down 事件,后续事件都会交由外部容器处理
                intercepted = false;
            	break;
            }
            case MotionEvent.ACTION_MOVE: {
            	if (外部容器需要拦截) {
                    intercepted = true;
                } else {
                    intercepted = false;
                }
            	break;
            }
            case MotionEvent.ACTION_UP: {
                // 必须返回 false,如果返回 true,子元素处理事件后无法收到 up 事件,就无法触发 onClick 事件
            	intercepted = false;
            	break;
            }
            return intercepted;
    }
}
内部拦截法

内部拦截法是指外部容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交由父容器进行处理,这种方法和Android中的事件分发机制不一致,需要配合 requestDisallowInterceptTouchEvent 方法才能正常工作,使用起来较外部拦截法稍显复杂,我们需要重写子元素的 dispatchTouchEvent 方法。

public boolean dispatchTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            // 拦截 down 事件,后续所有事件都会交由子元素处理
            parent.requestDisallowInterceptTouchEvent(true);
            break;
        case MotionEvent.ACTION_MOVE:
            if(外部容器需要处理此类点击事件) {
                // 在需要的时候,将 move 事件交由外部容器处理
                parent.requestDisallowInterceptTouchEvent(false);
            }
            break;
        case MotionEvent.ACTION_UP:

            break;
    }
    return super.dispatchTouchEvent(event);
}

外部容器需要重写 onInterceptTouchEvent。

public boolean onTouchEvent(MotionEvent event) {
    // 除 down 事件外,其他事件外部容器都需要拦截。
    // 只有这样,当子元素调用 parent.requestDisallowInterceptTouchEvent(false) 时,外部容器才能拦截并处理所需要的事件。
    return event.getAction() != MotionEvent.ACTION_DOWN;
}

针对场景 2 和场景 3 这些复杂的情况,需要更复杂的拦截规则。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值