16、View的触摸事件与绘制:事件分发机制与点击区域的计算

各位同学,今天我们来聊一个非常核心的话题——触摸事件与绘制的关系。说实话,很多Android开发做了两三年,对事件分发还是模棱两可。我当年刚入行时也是这样,直到有一次线上出了个点击穿透的bug,排查了整整两天……嗯,从那以后我就把这块啃得透透的。

你想想看,用户手指点下去,屏幕上那个View是怎么知道自己被点中的?这背后其实是一套精密的数学计算和事件传递机制。今天我们就把它彻底讲清楚。

16.1 事件分发三兄弟:dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent

这三个方法,说白了就是事件分发的"三驾马车"。每个ViewGroup都有它们,每个View也有其中两个(View没有onInterceptTouchEvent)。

核心口诀:

  • dispatchTouchEvent:事件分发入口,决定事件给谁
  • onInterceptTouchEvent:拦截判断,决定是否自己处理
  • onTouchEvent:事件消费,真正处理事件的地方

它们的调用顺序是这样的:

// 伪代码展示事件分发流程
public boolean dispatchTouchEvent(MotionEvent ev) {
    boolean consume = false;
    if (onInterceptTouchEvent(ev)) {
        // 拦截了,自己处理
        consume = onTouchEvent(ev);
    } else {
        // 不拦截,传给子View
        consume = child.dispatchTouchEvent(ev);
        if (!consume) {
            // 子View没消费,自己处理
            consume = onTouchEvent(ev);
        }
    }
    return consume;
}

我在项目中遇到过一个问题:一个RecyclerView嵌套了横向滑动的卡片,结果纵向滑动时经常误触。排查下来就是onInterceptTouchEvent没处理好,横向滑动时没有拦截纵向事件。后来加了个滑动方向判断就解决了。

16.2 事件分发与绘制的关系

很多人觉得事件和绘制是两套独立的体系。其实不然。它们之间有一个关键的桥梁——点击区域(hitRect)

绘制的时候,View会把自己的位置信息画到屏幕上。事件分发时,系统需要判断手指的位置落在了哪个View的区域内。这个区域,就是hitRect。

我的经验:绘制和事件分发的坐标系是统一的。View的left、top、right、bottom决定了它在屏幕上的位置,也决定了它的点击区域。所以如果你自定义View时改了绘制位置,但没改事件区域,就会出现"画在A处,点中B处"的诡异现象。

16.3 点击区域(hitRect)的计算

点击区域的计算,核心就是判断一个点是否在View的矩形范围内。系统用的是ViewGroup.isPointInView()方法:

// ViewGroup中的判断逻辑
private boolean isPointInView(View view, float x, float y) {
    // 获取View的点击区域
    final Rect hitRect = mAttachInfo.mTmpInvalRect;
    view.getHitRect(hitRect);
    // 判断点是否在区域内
    return hitRect.contains((int) x, (int) y);
}

getHitRect()是怎么算的呢?我们来看源码:

// View.java
public void getHitRect(Rect outRect) {
    if (mTransformationInfo != null && mTransformationInfo.mMatrix != null) {
        // 如果有变换矩阵(比如旋转、缩放),需要矩阵映射
        Matrix matrix = mTransformationInfo.mMatrix;
        RectF tmpRect = mAttachInfo.mTmpTransformRect;
        tmpRect.set(0, 0, getWidth(), getHeight());
        matrix.mapRect(tmpRect);
        outRect.set((int) tmpRect.left, (int) tmpRect.top,
                    (int) tmpRect.right, (int) tmpRect.bottom);
    } else {
        // 没有变换,直接用位置信息
        outRect.set(mLeft, mTop, mRight, mBottom);
    }
    // 加上滚动偏移
    outRect.offset((int) getScrollX(), (int) getScrollY());
}

看到了吗?它考虑了变换矩阵滚动偏移。这就是为什么你给View做了旋转动画后,点击区域会跟着转——因为hitRect也变了。

注意:如果你用setTranslationX/Y移动了View,getHitRect返回的是移动后的位置。但如果你用setLeft/setRight直接修改布局参数,那就要小心了——这会影响父View的测量和布局,不推荐这样做。

16.4 事件分发的完整流程

我们来画一张图,把整个流程串起来:

View事件分发完整流程 Activity.dispatchTouchEvent DecorView.dispatchTouchEvent ViewGroup.dispatchTouchEvent 拦截? onTouchEvent处理 子View.dispatchTouchEvent 子View未消费 → 回溯到父View的onTouchEvent 分发路径 拦截路径

这张图展示了事件从Activity一路传递到具体View的过程。注意看那个菱形判断——拦截还是不拦截,这是整个分发的关键决策点。

16.5 点击区域计算的实战案例

我记得有一次,产品经理要求做一个"点击头像弹出菜单"的功能。头像是个圆形,但默认的点击区域是矩形。结果用户点矩形角落时也会触发,体验很差。

解决方案很简单——重写onTouchEvent,手动判断点击位置是否在圆形区域内:

@Override
public boolean onTouchEvent(MotionEvent event) {
    if (event.getAction() == MotionEvent.ACTION_DOWN) {
        float x = event.getX();
        float y = event.getY();
        float cx = getWidth() / 2f;
        float cy = getHeight() / 2f;
        float radius = Math.min(cx, cy);
        // 判断点是否在圆内
        float dx = x - cx;
        float dy = y - cy;
        if (dx * dx + dy * dy > radius * radius) {
            // 点在圆外,不消费事件
            return false;
        }
    }
    return super.onTouchEvent(event);
}

小技巧:如果你只是想让点击区域变小一点,可以用TouchDelegate。它允许你给View设置一个比实际区域更大或更小的点击范围。我在做"展开更多"按钮时经常用这个,让用户更容易点到。

16.6 事件分发与绘制的联动

最后说一个容易被忽略的点:invalidate和事件分发的关系

当你调用invalidate()时,系统会重新绘制View。但事件分发不会自动重新计算点击区域——除非View的位置变了。所以如果你在动画过程中改变了View的位置,记得在onAnimationUpdate里调用invalidate(),这样才能保证点击区域和绘制位置同步。

我曾经遇到一个bug:一个View做了平移动画,动画结束后点击区域还在原来的位置。排查了半天,发现是动画结束后没有调用layout()更新位置。后来用ViewPropertyAnimator.withEndAction()做了修正才解决。

避坑指南:如果你用setTranslationX/Y做动画,事件分发会自动处理偏移。但如果你用setLeft/setRight直接改位置,一定要记得重新requestLayout(),否则点击区域和绘制位置会不同步。

好了,这一章的内容就到这里。事件分发和绘制的关系,说白了就是"画在哪,点在哪"的问题。理解了hitRect的计算逻辑,你就掌握了事件分发的核心。


公众号:蓝海资料掘金营,微信deep3321