Android View的事件体系

作者: 一个有故事的程序员 | 来源:发表于2018-08-07 17:21 被阅读21次

    导语

    本章主要介绍View的事件分发和滑动冲突问题的解决方案,可以和Android事件拦截机制分析对比着看。

    主要内容

    • view的基础知识
    • View的滑动
    • 弹性滑动
    • View的事件分发机制
    • 滑动冲突

    具体内容

    view的基础知识

    View的位置参数、MotionEvent和TouchSlop对象、VelocityTracker、GestureDetector和Scroller对象。

    什么是view

    View是Android中所有控件的基类,View的本身可以是单个空间,也可以是多个控件组成的一组控件,即ViewGroup,ViewGroup继承自View,其内部可以有子View,这样就形成了View树的结构。

    View的位置参数

    View的位置主要由它的四个顶点来决定,即它的四个属性:top、left、right、bottom,分别表示View左上角的坐标点( top,left) 以及右下角的坐标点( right,bottom)。
    同时,我们可以得到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;
    
    MotionEvent和TouchSlop
    MotionEvent

    事件类型:

    • ACTION_DOWN 手指刚接触屏幕
    • ACTION_MOVE 手指在屏幕上移动
    • ACTION_UP 手指从屏幕上松开

    点击事件类型:

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

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

    TouchSlop

    TouchSlop是系统所能识别出的被认为是滑动的最小距离,这是一个常量,与设备有关,可通过以下方法获得:

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

    当我们处理滑动时,比如滑动距离小于这个值,我们就可以过滤这个事件(系统会默认过滤),从而有更好的用户体验。

    VelocityTracker、GestureDetector和Scroller
    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();
    
    GestureDetector

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

    • 创建一个GestureDetector对象并实现OnGestureListener接口,根据需要,也可实现OnDoubleTapListener接口从而监听双击行为:
    GestureDetector mGestureDetector = new GestureDetector(this);
    //解决长按屏幕后无法拖动的现象
    mGestureDetector.setIsLongpressEnabled(false);
    
    • 在目标View的OnTouchEvent方法中添加以下实现:
    boolean consume = mGestureDetector.onTouchEvent(event);
    return consume;
    
    • 实现OnGestureListener和OnDoubleTapListener接口中的方法:
      其中常用的方法有:onSingleTapUp(单击)、onFling(快速滑动)、onScroll(拖动)、onLongPress(长按)和onDoubleTap( 双击)。建议:如果只是监听滑动相关的,可以自己在onTouchEvent中实现,如果要监听双击这种行为,那么就使用GestureDetector。


    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的滑动

    三种方式实现View滑动。

    使用scrollTo/scrollBy

    scrollBy实际调用了scrollTo,它实现了基于当前位置的相对滑动,而scrollTo则实现了绝对滑动。

    scrollTo和scrollBy只能改变View的内容位置而不能改变View在布局中的位置。滑动偏移量mScrollX和mScrollY的正负与实际滑动方向相反,即从左向右滑动,mScrollX为负值,从上往下滑动mScrollY为负值。

    使用动画

    使用动画移动View,主要是操作View的translationX和translationY属性,既可以采用传统的View动画,也可以采用属性动画,如果使用属性动画,为了能够兼容3.0以下的版本,需要采用开源动画库nineolddandroids。 如使用属性动画:(View在100ms内向右移动100像素)。

    ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
    
    改变布局属性

    通过改变布局属性来移动View,即改变LayoutParams。

    各种滑动方式的对比
    • scrollTo/scrollBy:操作简单,适合对View内容的滑动;
    • 动画:操作简单,主要适用于没有交互的View和实现复杂的动画效果;
    • 改变布局参数:操作稍微复杂,适用于有交互的View。

    弹性滑动

    使用Scroller

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

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

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

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

    通过动画

    动画本身就是一种渐近的过程,因此通过动画来实现的滑动本身就具有弹性。实现也很简单:

    ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start()
    ;    
    //当然,我们也可以利用动画来模仿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所滑动的距离。采用这种方法也可以实现其他动画效果,我们可以在onAnimationUpdate方法中加入自定义操作。

    使用延时策略

    延时策略的核心思想是通过发送一系列延时信息从而达到一种渐近式的效果,具体可以通过Hander和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的事件分发机制

    点击事件的传递规则

    点击事件是MotionEvent。首先我们先看看下面一段伪代码,通过它我们可以理解到点击事件的传递规则:

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

    上面代码主要涉及到以下三个方法:

    • public boolean dispatchTouchEvent(MotionEvent ev);
      这个方法用来进行事件的分发。如果事件传递给当前view,则调用此方法。返回结果表示是否消耗此事件,受onTouchEvent和下级View的dispatchTouchEvent方法影响。
    • public boolean onInterceptTouchEvent(MotionEvent ev);
      这个方法用来判断是否拦截事件。在dispatchTouchEvent方法中调用。返回结果表示是否拦截。
    • public boolean onTouchEvent(MotionEvent ev);
      这个方法用来处理点击事件。在dispatchTouchEvent方法中调用,返回结果表示是否消耗事件。如果不消耗,则在同一个事件序列中,当前View无法再次接收到事件。
    事件分发机制

    点击事件的传递规则:对于一个根ViewGroup,点击事件产生后,首先会传递给他,这时候就会调用他的dispatchTouchEvent方法,如果ViewGroup的onInterceptTouchEvent方法返回true表示他要拦截事件,接下来事件就会交给ViewGroup处理,调用ViewGroup的onTouchEvent方法;如果ViewGroup的### onInterceptTouchEvent方法返回值为false,表示ViewGroup不拦截该事件,这时事件就传递给他的子View,接下来子View的dispatchTouchEvent方法,如此反复直到事件被最终处理。
    当一个View需要处理事件时,如果它设置了OnTouchListener,那么onTouch方法会被调用,如果onTouch返回false,则当前View的onTouchEvent方法会被调用,返回true则不会被调用,同时,在onTouchEvent方法中如果设置了OnClickListener,那么他的onClick方法会被调用。

    由此可见处理事件时的优先级关系: onTouchListener > onTouchEvent >onClickListener

    关于事件传递的机制,这里给出一些结论:

    • 一个事件系列以down事件开始,中间包含数量不定的move事件,最终以up事件结束。
    • 正常情况下,一个事件序列只能由一个View拦截并消耗。
    • 某个View拦截了事件后,该事件序列只能由它去处理,并且它的onInterceptTouchEvent
      不会再被调用。
    • 某个View一旦开始处理事件,如果它不消耗ACTION_DOWN事件( onTouchEvnet返回false) ,那么同一事件序列中的其他事件都不会交给他处理,并且事件将重新交由他的父元素去处理,即父元素的onTouchEvent被调用。
    • 如果View不消耗ACTION_DOWN以外的其他事件,那么这个事件将会消失,此时父元素的onTouchEvent并不会被调用,并且当前View可以持续收到后续的事件,最终消失的点击事件会传递给Activity去处理。
    • ViewGroup默认不拦截任何事件。
    • View没有onInterceptTouchEvent方法,一旦事件传递给它,它的onTouchEvent方法会被调用。
    • View的onTouchEvent默认消耗事件,除非他是不可点击的( clickable和longClickable同时为false) 。View的longClickable属性默认false,clickable默认属性分情况(如TextView为false,button为true)。
    • View的enable属性不影响onTouchEvent的默认返回值。
    • onClick会发生的前提是当前View是可点击的,并且收到了down和up事件。
    • 事件传递过程总是由外向内的,即事件总是先传递给父元素,然后由父元素分发给子View,通过requestDisallowInterceptTouchEvent方法可以在子元素中干预父元素的分发过程,但是ACTION_DOWN事件除外。
    事件分发的源码解析

    滑动冲突

    在界面中,只要内外两层同时可以滑动,这个时候就会产生滑动冲突。滑动冲突的解决有固定的方法。

    常见的滑动冲突场景
    1. 外部滑动和内部滑动方向不一致;
      比如viewpager和listview嵌套,但这种情况下viewpager自身已经对滑动冲突进行了处理。
    2. 外部滑动方向和内部滑动方向一致;
    3. 上面两种情况的嵌套,只要解决1和2即可。
    滑动冲突的处理规则

    对于场景一,处理的规则是:当用户左右( 上下) 滑动时,需要让外部的View拦截点击事件,当用户上下( 左右) 滑动的时候,需要让内部的View拦截点击事件。根据滑动的方向判断谁来拦截事件。

    对于场景二,由于滑动方向一致,这时候只能在业务上找到突破点,根据业务需求,规定什么时候让外部View拦截事件,什么时候由内部View拦截事件。

    场景三的情况相对比较复杂,同样根据需求在业务上找到突破点。

    滑动冲突的解决方式
    外部拦截法

    所谓外部拦截法是指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,否则就不拦截。下面是伪代码:

    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;
    

    针对不同冲突,只需修改父容器需要当前事件的条件即可。其他不需修改也不能修改。

    • ACTION_DOWN:必须返回false。因为如果返回true,后续事件都会被拦截,无法传递给子View。
    • ACTION_MOVE:根据需要决定是否拦截
    • ACTION_UP:必须返回false。如果拦截,那么子View无法接受up事件,无法完成click操作。而如果是父容器需要该事件,那么在ACTION_MOVE时已经进行了拦截,根据上一节的结论3,ACTION_UP不会经过onInterceptTouchEvent方法,直接交给父容器处理。
    内部拦截法

    内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗,否则就交由父容器进行处理。这种方法与Android事件分发机制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作。下面是伪代码:

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

    外部拦截法实例:HorizontalScrollViewEx

    更多内容戳这里(整理好的各种文集)

    相关文章

      网友评论

      • maxcion:想问一下?内外拦截都是通过滑动距离判断是否拦截。为什么拦截要通过在事件结束时记录last的值,然后在下一个move事件中相减获取滑动距离呢?谢谢您了。
        一个有故事的程序员:@maxcion 你可以放外面,然后就是只能一个方向滑动。这样写可以按下,有左右,又上下,根据手势。和王者的摇杆差不多。你可以根据情况来用。
        maxcion:@maxcion 那这种判断距离会不会有局限性。无法获取总的滑动距离。因为在n个move事件中。last记录的是上一个move事件的距离。而且当前的move事件和上一个move事件的时间差 特别小。所以获取的滑动距离除非特别快的滑动情况下,获取的滑动距离会比较大。要不然获取的滑动距离的值就会特别小
        一个有故事的程序员:@maxcion 记录之前的值,再和新值比较,然后判断滑动方向。是左右,还是上下。用来解决内外不同方向的滑动。

      本文标题:Android View的事件体系

      本文链接:https://www.haomeiwen.com/subject/pyphvftx.html