面试攻克-事件分发机制全解析

2,978 阅读8分钟

目录介绍

  • 1.事件分发流程图(究极重点)
  • 2.在遍历子View时如何从内层的子View开始遍历?
  • 3.滑动冲突有哪些场景?滑动冲突处理原则是什么?
  • 4.ACTION_POINTER_DOWN ,event.getX(int index)什么时候发生?
  • 5.View的滑动方式有哪些?
  • 6.ScrollView里面有一个button,然后按住button向上滑,讲述事件传递过程?
  • 7.按住一个button,然后手指移到别处,click事件还能不能响应?

事件分发机制虽然大家都知道是什么东西,但有可能其中的一些细节重点要点还是不清晰,本文将结合实例带你攻克事件分发。

1. 事件分发流程图(究极重点)

  1. 一般来说,一组事件序列为ACTION_DOWN(一个,手指点下)->ACTION_MOVE(N个,手指移动)->ACTION_UP(一个,手指抬起),必须以DOWN事件开始,UP事件结束
  2. 当一个View消费事件后,后续的事件都直接交由它去处理但有两种情形需要注意:
    1. ViewGroup进行了拦截,后续事件将交由ViewGroup的onTouchEvent去处理。
    2. 可以在处理事件的View的onTouchEvent()中手动的去调用其他View的onTouchEvent()将事件强行传递给其他View处理,但这样违背了事件分发的本质。
  • (这里指的消费事件其实并不是onTouchEvent返回true而是ACTION_DOWN事件时是否返回true,当一个View消费事件后后续的MOVE和UP事件都交由当前这个消费了事件的View去处理。)
  1. onInterceptTouchEvent在DOWN事件和MOVE事件返回true进行拦截其实是非常没有必要的,如果在DOWN拦截那么后续事件都不会交由子View去判断,在UP事件拦截那么消费了事件的子View的UP事件将无法进行响应。
  2. 如果onInterceptTouchEvent在MOVE事件返回true的话那么首先会发送一个ACTION_CANCEL事件给原先处理事件的View,之后后续的MOVE和UP事件将直接发送给其自身的onTouchEvent去处理且其自身的onInterceptTouchEvent将不会被调用。(也就是说onInterceptTouchEvent一旦返回true那么之后的事件将不会在触发其自身的onInterceptTouchEvent方法,onInterceptTouchEvent在返回true以后将不再调用)。
  3. View的onTouchEvent默认消耗事件(ACTION_DOWN返回true),除非它是不可点击的(clickable和longClickable同时为false),如果View设置了onClickListener则clickable为true,设置了onLongClickListener则longClickable为true。View默认longClickable都为false,clickable非情况(如Button为true,TextView为false)。
  • View是否消耗事件顺序:onTouch(setOnTouchListener)->onTouchEvent->setOnClickListener->setOnLongClickListener。
  1. View的enable不影响onTouchEvent的返回值,View当enable为false时只要clickable为true照样可以消费事件只不过ACTION_UP时不会有任何响应。

2.在遍历子View时如何从内层的子View开始遍历?

可以通过重写getChildDrawingOrder方法去改变遍历规则。

3.滑动冲突有哪些场景?滑动冲突处理原则是什么?

滑动冲突的本质其实是一个策略问题,在开发中我们通常都是通过在子View中去调用requestDisallowInterceptTouchEvent方法配合父View中的onInterceptTouchEvent方法去使用。

下边给出一个例子:

public class MyLayout extends LinearLayout{
    public MyLayout(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_MOVE:   //表示父类需要拦截
                    return true;
            default:
                break;
        }
        return false;    //如果设置拦截,除了down,其他都是父类处理
    }
}
public class MyButton extends Button {
    public MyButton(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                getParent().requestDisallowInterceptTouchEvent(true);//禁用父View的拦截方法。
                break;
            case MotionEvent.ACTION_MOVE:
                if(满足条件){
                getParent().requestDisallowInterceptTouchEvent(false);//解除父View的拦截的禁用。
                }
        }
        return true;
    }
}
  • 可以看到当 ACTION_DOWN 事件时我们在View自身的onTouchEvent中调用了 getParent().requestDisallowInterceptTouchEvent(true)这个方法,当此方法调用后在方法内部中会改变FLAG_DISALLOW_INTERCEPT标志位为true,这时在ViewGroup中的dispatchTouchEvent中如果检测到FLAG_DISALLOW_INTERCEPT为true的话将跳过onInterceptTouchEvent的调用而直接返回false,也就是父View直接不进行拦截,这时我们的事件都将由子View去处理,同时也不用担心父View的拦截方法会对事件进行拦截,当我们在移动时满足事件可被父View进行拦截时则需要调用getParent().requestDisallowInterceptTouchEvent(false)将父View的拦截方法的禁用解除掉,这时父View的onInterceptTouchEvent将可继续去判断是否需要进行事件的拦截。

在ViewGroup的dispatchTouchEvent中我们可以看到如下代码:

// 发生ACTION_DOWN事件或者已经发生过ACTION_DOWN,并且将mFirstTouchTarget赋值,才进入此区域,主要功能是拦截器
final boolean intercepted;
//onInterceptTouchEvent返回true后之后将不再执行onInterceptTouchEvent方法,因为其将mFirstTouchTarget字段置为了null。
if (actionMasked == MotionEvent.ACTION_DOWN|| mFirstTouchTarget != null) {
    //disallowIntercept:是否禁用事件拦截的功能(默认是false),即不禁用
    //可以在子View通过调用requestDisallowInterceptTouchEvent方法对这个值进行修改,不让该View拦截事件
    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
    //默认情况下会进入该方法
    if (!disallowIntercept) {
        //调用拦截方法
        intercepted = onInterceptTouchEvent(ev);
        ev.setAction(action);
    } else {
        intercepted = false;
    }
} else {
    // 当没有触摸targets,且不是down事件时,开始持续拦截触摸。
    intercepted = true;
}

4.ACTION_POINTER_DOWN ,event.getX(int index)什么时候发生?

获取事件时需调用MotionEvent.getActionMasked()而不是MotionEvent.getAction(),只有MotionEvent.getActionMasked()可以支持多点触控。

常见值:

  • ACTION_DOWN :第一个手指按下(之前没有任何手指触摸到 View)
  • ACTION_UP :最后一个手指抬起(抬起之后没有任何?指触摸到 View,这个手指未必是 ACTION_DOWN 的那个手指)
  • ACTION_MOVE 有手指发生移动
  • ACTION_POINTER_DOWN 额外手指按下(按下之前已经有别的手指触摸到 View)
  • ACTION_POINTER_UP 有手指抬起,但不是最后一个(抬起之后,仍然还有别的手指在触摸着 View)

默认的event.getX()其实可以理解为 event.getX(0),这是针对于一根手指的情况,再多点触控的情况下我们需要通过调用event.getX(int index)来传入参数以区别当前是第几根手指在进行移动 (这里的index是会变的,但是手指的ID是不会变的,我们需要通过ID找到对应手指的index)

多点触控一般写法实例: github.com/rengwuxian/…

5.View的滑动方式有哪些?

大致可分为下边三个方法(只有layout方法是可以真正改变View坐标位置)

1.layout:

对View进行重新布局定位。在onTouchEvent()方法中获得控件滑动前后的偏移。然后通过layout方法重新设置。

// 视图坐标方式
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // 记录触摸点坐标
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                // 计算偏移量
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                // 在当前left、top、right、bottom的基础上加上偏移量
                layout(getLeft() + offsetX, getTop() + offsetY, getRight() + offsetX, getBottom() + offsetY);
//                        offsetLeftAndRight(offsetX);
//                        offsetTopAndBottom(offsetY);
                break;
        }
        return true;
    }

2.ScrollTo/ScrollBy:

本质是View内容的移动,需要通过父容器的该方法来滑动当前View,Scroller: 平滑滑动,通过重载computeScroll(),使用scrollTo/scrollBy完成滑动效果,Scroller只是一个移动的机制,真正还是需要调用去scrollTo/scrollBy去进行移动。

Scroll中与之相关的各种API中的参数都要跟实际我们认知相反,比如想往自身右边移动100不是去调用scrollerBy(100,0)而是调用scrollerBy(-100,0)。

public class ScrollButton extends android.support.v7.widget.AppCompatButton {
    Scroller scroller;
    int direction = -1;

    public ScrollButton(Context context) {

        this(context,null);
    }

    public ScrollButton(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public ScrollButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        scroller = new Scroller(context);
    }

    @Override
    public void computeScroll() {
        if(scroller!=null){
            if(scroller.computeScrollOffset()){//判断scroll是否完成
                ((View) getParent()).scrollTo(
                        scroller.getCurrX(),scroller.getCurrY()
                );//执行本段位移

                invalidate();//进行下段位移
            }
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                scroller.startScroll(((int) getX()), ((int) getY()), ((int) getX())*direction,
                        ((int) getY())*direction);//开始位移,真正开始是在下面的invalidate
                direction*=-1;//改变方向
                invalidate();//开始执行位移
                break;
        }
        return super.onTouchEvent(event);
    }
}

3.属性动画:

动画对View进行滑动: setTranslationX,setTranslationY。

6.ScrollView里面有一个button,然后按住button向上滑,讲述事件传递过程?

这里的情形可以理解为上图的情景,大家可以自行带入场景。

当手指按下时由于scrollView中onInterceptTouchEvent没对down事件进行拦截同时button的onTouchEvent是默认返回true的(clickable=true)那么button首先会消耗down事件,当我们手指移动时会触发MOVE事件,这时ScrollView的拦截事件将进行拦截(onInterceptTouchEvent在MOVE时返回true)同时会发送 CANCLE事件给button(CANCLE的触发时机是父View进行拦截后会发送给原先处理事件的子View通知它不要处理后续事件了),之后的MOVE和UP事件将直接交由ScrollView的onTouchEvent去处理同时其自身的onInterceptTouchEvent不会再被触发(onInterceptTouchEvent返回true后将不被调用)。

7.按住一个button,然后手指移到别处,click事件还能不能响应?

不能响应。

当手指移动时在View的OnTouchEvent的MOVE事件中会不断检测当前手指是否在View区域内,如果出了View区域的话那么会将mPressed这个标志位置为false,当手指抬起时在UP事件中如果mPressed为false的话将不会触发任何响应(一定要注意的是会触发MOVE和UP事件,因为一个View在DOWN事件返回true后后续的事件序列都会交给其去处理,只不过在这种情况下没有任何响应效果)。

View的onTouchEvent:

public boolean onTouchEvent(MotionEvent event) {
   ....
        switch (event.getAction()) {
            case MotionEvent.ACTION_UP:
                boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
                //如果mPrivateFlags为false则prepressed为false,将不会执行后续UP事件中的任何逻辑
                if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
                ....
                }
                break;
            ...
            case MotionEvent.ACTION_MOVE:
                drawableHotspotChanged(x, y);
                              //判断手指是否在View的区域中
                if (!pointInView(x, y, mTouchSlop)) {
                    removeTapCallback();
                    if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
                        removeLongPressCallback();
                        //如果手指移出View区域将改变mPrivateFlags
                        setPressed(false);
                    }
                }
                break;
        }
        return true;
    }
    return false;
}