Android 事件小结 背景 谈我对 Android 事件机制的理解 滑动事件冲突 复杂场景的自定义分发事件 结尾

这篇文章不适合小白直接来阅读
原创文章,转载需要本人同意

我之前一直从事 Android App 开发,现在跑去做 APM 了,在公司清闲了好一段时间,后来发现自己对 Android 原本很了解的一些东西都遗忘掉了,意识到还是之前没有写博客导致的,所以现在想把自己回忆的一些东西记录整理下来。 写的时候参考了一些书籍和一些开源框架(主要是 ObservableScrollView),同时也动手写了一个封装了事件处理细节的框架来方便工作和学习。

谈我对 Android 事件机制的理解

  1. 事件从 Activity 开始进入,通过* View DecorView 依次递归向子 View 分发
  2. 如果 down 事件被 View 消耗,那么后续事件会一直传递到该 View, 如果该 View 不处理后续事件,那么后续事件依然会传递进来,只是事件会往父容器抛 ( 这里的 View 如果是 ViewGroup,ViewGroup 的子 View 消耗了 down,可以等价的认为事件被该 ViewGroup 消耗了,也就是后续的事件会传递给该 ViewGroup, 由该 ViewGroup 的事件分发逻辑处理 ), 反过来,如果 View 不消耗 down 事件,那么后续事件压根不会传进来。 ( 所以自定义View 的时候,如果需要处理事件,down 事件要消耗,同时自定义的 ViewGroup 如果想将后续的事件分发给子 View 处理,那么 down 事件不能拦截)
  3. ViewGroup 如果决定拦截事件,那么后续事件不会再经过 onInterceptTouchEvent 方法,只要事件经过子 View 并且子 View 没有调用 requestDisallowInterceptTouchEvent(true) 方法, 那么每次都会调用 onInterceptTouchEvent 方法,换句话说,如果有事件冲突,我们的子 View 不想让父容器处理事件,那么可以在 子 View 中调用这个方法,阻止父容器拦截事件
  4. 子容器不处理事件,那么事件会继续交给父容器的 onTouchEvent 处理(2 补充)
  5. View、ViewGroup 虽然机制有所不同,但是事件分发上逻辑等价,整个分发过程逻辑递归 (ViewGroup 子 View 消耗了事件,逻辑上等价于该 ViewGroup 消耗了事件,只是调用谁的 onTouchEvent 问题)

滑动事件冲突

这里的事件冲突表示为整个完整的事件本应该交给父容器处理确被容器里面的 View 消费了,反过来一样。注意完整的事件说明不纯在整个事件流不纯在一会是父容器处理一会是子 view 处理的情况,这种情况后面细说,也就是说确定是谁消耗事件了,那么后续的事件就只由他处理(如果他不处理不了,那么事件会往上抛,但是事件流还是会经过他)

套路

父容器处理事件

public boolean onInterceptTouchEvent(MotionEvent ev){
   switch (ev.getActionMasked()){

        case MotionEvent.ACTION_DOWN:
            return false; // 读者想想这里如果返回 true 会怎么样,想不起来再往上看
        case MotionEvent.ACTION_MOVE:
            if (事件交给我处理){
                return true;
            }
             break;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP: // 想想为啥不对这两个事件进行拦截
             break; 

   }

   return super.onInterceptTouchEvent(ev);
}

子 View 处理事件

public boolean dispatchTouchEvent(MotionEvent ev){ //建议在这里处理,如果该 View 是个 ViewGroup,可能不会调用到 onTouchEvent() 方法。该方法保证能在事件传递进来后都能接收到。
    switch (ev.getActionMasked()){
        case MotionEvent.ACTION_DOWN:
               parent.requestDisallowInterceptTouchEvent(true);
           break;
        case MotionEvent.ACTION_MOVE:
            if (处理事件){
                parent.requestDisallowInterceptTouchEvent(false);
            }
            break;

    }
    return super.dispatchTouchEvent(ev);
}

复杂场景的自定义分发事件

问题分析

考虑这种情况,ViewGroup 嵌套 View, 根据手势的滑动,整个事件流,在不同情况下回交给 ViewGroup 或者 View 处理,系统的 Android 事件分发机制表名,如果 View 的事件被拦截,那么就不会再调用 onInterceptTouchEvent 方法,后续所有事件会交给 ViewGroup 处理,这时候问题就来了,如果后续的 move 事件满足某个条件,需要交给 view 处理,那这样就没法实现了。

问题解决

上面这种情况的解决,靠 Android 系统默认的分发机制是实现不了的,解决方案就只有自己来给 View 分发事件,当 ViewGroup 拦截事件后,后续的事件会交给 ViewGroup 的 onTouchEvent 处理, 所以我们要在 onTouchEvent 事件里面在特定条件下向 View 手动调用 dispatchTouchEvent 方法分发事件。

一个简单的封装了事件分发的框架

思路
  1. 一个事件流由 down 事件开始,接着若干 move 事件,最后以 cancel 或者 up 事件结束。
  2. 一个事件流由 viewGroup 来进行分发 (onTouchEvent)。
  3. 分发给 ViewGroup 处理和 View 处理的事件是一个完整的事件,也就说由于整个事件流分发给 ViewGroup 或者 View 处理的时候可能只是一个事件流的一部分(没有从 down 事件以 cancel 或者 up 事件结束),所以在分发开始的时候我们要自己创建 down 事件和 cancel 事件来让事件流完整。
  4. 后续事件不再交给 View 处理,我们以 cancel 事件结束( 没用 up)
代码
// ViewGroup 事件处理回调
    public interface LinkageListener {
        public boolean shouldIntercept(MotionEvent event, boolean isDown, float diffx, float diffy);
        public void eventDown(MotionEvent event);
        public void eventMove(MotionEvent event, float diffx, float diffy);
        public void eventCancelOrUp(MotionEvent event);
    }

    //事件分发处理类
    public abstract class LinkageBaseFrameLayout extends FrameLayout{
    public LinkageBaseFrameLayout(Context context) {
        super(context);
    }

    public LinkageBaseFrameLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public LinkageBaseFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    boolean intercepting;
    PointF firstPoint;
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (getLinkageListener() != null){
            switch (ev.getActionMasked()){
                case MotionEvent.ACTION_DOWN://如果拦截,那么后续事件不会传递给 子view,并且不会子啊调用这个方法
                    firstPoint = new PointF(ev.getX(), getY());
                    intercepting = getLinkageListener().shouldIntercept(ev, true, 0, 0);
                    dispatchChildDownEvent = !intercepting;
                    dispatchThisDownEvent = intercepting;
                    cancelChildEvent = false;
                    cancelThisEvent = false;
                    firstPoint = new PointF(ev.getX(), ev.getY());
                    break;
                case MotionEvent.ACTION_MOVE:
                    if (firstPoint == null){// 防御代码, 可能父容器是自定义的 View,比如本框架(QAQ) down 事件别拦截,其他事件被分发过来了
                        firstPoint = new PointF(ev.getX(), ev.getY());
                    }
                    float diffx = ev.getX() - firstPoint.x; //这里 diff 采用上一次和这一次的偏移或者到起始点的偏移, 这里第二种
                    float diffy = ev.getY() - firstPoint.y;
                    intercepting = getLinkageListener().shouldIntercept(ev, false, diffx, diffy);
                    break;
                case MotionEvent.ACTION_UP:
                case MotionEvent.ACTION_CANCEL:
                    break;
            }

            return intercepting;
        }
        return super.onInterceptTouchEvent(ev);
    }


    boolean dispatchChildDownEvent;
    boolean dispatchThisDownEvent;
    boolean cancelChildEvent;
    boolean cancelThisEvent;
    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        if (getLinkageListener() != null){
            switch (ev.getActionMasked()){
                case MotionEvent.ACTION_DOWN:
                    if (intercepting){// 事件只给 viewGroup 处理
                        getLinkageListener().eventDown(ev);
                    }
                    break;
                case MotionEvent.ACTION_MOVE:
                    if (firstPoint == null){// 防御代码
                        firstPoint = new PointF(ev.getX(), ev.getY());
                    }
                    float diffx = ev.getX() - firstPoint.x;// 这里是事件开始,到当前事件坐标的偏移,当然也可以改成上一个事件到当前事件的偏移
                    float diffy = ev.getY() - firstPoint.y;
                    intercepting = getLinkageListener().shouldIntercept(ev, false, diffx, diffy);
                    if (intercepting){ // 拦截,说明接下来的事件给本 viewgrop 处理
                        // 1. 给前面分发给子 View 的事件创建 cancel 事件,使事件完整
                        if (!cancelChildEvent && dispatchChildDownEvent){ // 上一个事件是给子 View 处理,并且没有传递结束事件
                            MotionEvent cancelEvent = obtainMotionEvent(ev, MotionEvent.ACTION_CANCEL);
                            dispathEvent(ev);
                            dispathEvent(cancelEvent);
                            cancelChildEvent = true;
                        }
                        dispatchChildDownEvent = false;
                        cancelThisEvent = false;


                        //2. 当前分配给 ViewGroup 的事件,必须要 down 事件开头(注意偏移是从 down 事件开始)
                        if (!dispatchThisDownEvent){
                            dispatchThisDownEvent = true;
                            MotionEvent downEvent = obtainMotionEvent(ev, MotionEvent.ACTION_DOWN);
                            getLinkageListener().eventDown(downEvent);
                            firstPoint.set(ev.getX(), ev.getY());
                            diffx = 0;
                            diffy = 0;

                        }else {

                            getLinkageListener().eventMove(ev, diffx, diffy);
                        }


                    }else{// 不拦截,事件分发给子 View
                        //1. 取消 ViewGroup 事件, 可以思考不要这段代码怎么样
                        if (!cancelThisEvent && dispatchThisDownEvent){
                            MotionEvent cancelEvent = obtainMotionEvent(ev, MotionEvent.ACTION_CANCEL);
                            getLinkageListener().eventMove(ev, diffx, diffy);
                            getLinkageListener().eventCancelOrUp(cancelEvent);
                            cancelThisEvent = true;
                        }

                        cancelChildEvent = false;
                        dispatchThisDownEvent = false;
                        //2. 子 View 没有分发 down 事件,要补充 down 事件
                        if (!dispatchChildDownEvent){//没有分发 down 事件
                            dispatchChildDownEvent = true;
                            MotionEvent downEvent = obtainMotionEvent(ev, MotionEvent.ACTION_DOWN);
                            dispathEvent(downEvent);
                        }else {

                            dispathEvent(ev);
                        }

                    }

                    break;
                case MotionEvent.ACTION_UP:
                case MotionEvent.ACTION_CANCEL:
                     if (!cancelChildEvent && dispatchChildDownEvent){
                         dispathEvent(ev);
                         cancelChildEvent = true;
                     }

                    if (intercepting){
                        if (!dispatchThisDownEvent){
                            dispatchThisDownEvent = true;
                            MotionEvent downEvent = obtainMotionEvent(ev, MotionEvent.ACTION_DOWN);
                            getLinkageListener().eventDown(downEvent);
                        }
                        getLinkageListener().eventCancelOrUp(ev);
                    }

                    break;
            }
            return true;
        }
        return super.onTouchEvent(ev);
    }

    private MotionEvent obtainMotionEvent(MotionEvent ev, int action) {
        MotionEvent downEvent = MotionEvent.obtain(ev);
        downEvent.setAction(action);
        return downEvent;
    }

    Rect hintRect = new Rect();

    /**
     * 向子 View 分发事件,子 View 不一定能收到完整的事件流,因此最好手指滑动的时候,要在同一个 View 上
     *
     * @param event
     */
    protected void dispathEvent(MotionEvent event){
        View child = null;
        boolean consume = false;
        MotionEvent temp = null;
        for (int i = 0; i < getChildCount(); i++){
            child = getChildAt(i);
            child.getHitRect(hintRect);
            if (hintRect.contains((int)event.getX(), (int)event.getY())){ // 判断点击点的坐标是否在该 view 上
                temp = MotionEvent.obtain(event);
                temp.offsetLocation(-hintRect.left, -hintRect.top); // event 坐标修改为相对 子 view
                consume |= child.dispatchTouchEvent(temp); //分发事件
                if (consume) break;
            };
           }
    };


    /**
     * 本 viewgroup 处理的事件
     *
     * @return
     */
    public abstract LinkageListener getLinkageListener();
}

结尾

通过这种事件分发逻辑,我写了一个下拉放大的组件分享给大家GitHub:EventDispatch

项目效果
Android 事件小结
背景
谈我对 Android 事件机制的理解
滑动事件冲突
复杂场景的自定义分发事件
结尾

Android 事件小结
背景
谈我对 Android 事件机制的理解
滑动事件冲突
复杂场景的自定义分发事件
结尾