ArturoYi
iOS

浅谈 iOS 事件的传递和响应过程

hitTest响应链
传递和响应分开记。传递用图看 hitTest 怎么从窗口找到一个视图,响应用图看消息怎么沿 next 向上。

我把一次点击拆开记。传递和响应各讲一遍,图下面写我看到的东西。最后回到这几个问题。

问题

  1. 视图的事件怎么传递、怎么响应?
  2. 父视图关掉交互,子视图为什么也点不到?
  3. 怎么扩大按钮的点击范围?
  4. 怎么让父视图和子视图同时响应同一次触摸?
  5. 子视图关掉交互、父视图开着,点在子视图上,为什么父视图还能响应?
  6. 普通 UIView 没有手势,点击会交到父视图。UIControl 同样没有手势,父视图却收不到。为什么?

试验页面只有三层:窗口、父视图、子视图。每次只改一个开关。

分析

事件我分成三类。今天只拆第一类。

类型我遇到它的时候
触摸手指点在屏幕上
运动摇一摇、重力感应
远程耳机上的按键

运动和远程不经过 hitTest。它们从当前第一响应者进入响应链。

触摸分两段。传递找出一个视图。响应再看它处理不处理。

传递结束时,手里只有一个命中视图。它接住了这个点,不等于它会处理这次触摸。

传递

传递在问:这个点落在哪个视图上。从窗口往子视图走,问到一个就停。

手指碰到屏幕,系统把事件交给当前 App。UIApplication 收到之后交给窗口,窗口调用 hitTest(_:with:)。

手指
  │
  ▼
UIApplication.sendEvent
  │
  ▼
UIWindow.hitTest
  │
  ▼
父视图.hitTest
  │
  ▼
子视图.hitTest
  │
  ▼
返回子视图 ──► 父视图原样向上交 ──► 窗口原样向上交

这张图是调用顺序,不是视图嵌套。箭头向下是在找人。最底下向右的箭头不再往下问,只把已经找到的子视图交回窗口。

同一层里,后加入的视图排在 subviews 后面,画在更上面,也先被问到。点在按钮上时,树是这样:

UIWindow
├─ 背景     先加入,画在下面,最后才问
└─ 卡片     后加入,先被问
   └─ 按钮  ● 点在这里

背景画在卡片下面,所以窗口先问卡片。卡片再问按钮。按钮没有更深的子视图,点又在按钮里,按钮把自己返回。卡片和窗口都把这个结果原样交上去,背景不会被问到。

问子视图之前先过四关:没有隐藏,允许交互,alpha 不小于 0.01,点在自己的 bounds 里。有一关不过,当前视图返回 nil,它下面的子视图都不会被问到。

HitTestWalkthrough.swift
import UIKit

// 传递时我对照的过程。真正调用的是系统的 hitTest
func illustratedHitTest(_ point: CGPoint, on view: UIView, event: UIEvent?) -> UIView? {
    // 不过这一关,子视图不会被问到
    if view.isHidden || !view.isUserInteractionEnabled || view.alpha < 0.01 {
        return nil
    }
    if !view.point(inside: point, with: event) {
        return nil
    }
    // 数组末尾先问,也就是画在更上面的那个
    for subview in view.subviews.reversed() {
        let local = subview.convert(point, from: view)
        if let hit = illustratedHitTest(local, on: subview, event: event) {
            return hit
        }
    }
    return view
}

我点在子视图上,命中的是子视图。点在父视图空白处,子视图说点不在自己里面,父视图返回自己。

选出的视图记在这次 UITouch 上。手指接着移动、抬起,都还发给它,不会每动一次就重新找。

一次传递只选出一个视图。

响应

响应在问:这个视图处理不处理。从命中视图沿 next 向上,直到有一层把触摸留下,或者链走完。

我这次的父视图就是控制器的根视图:

子视图          touchesBegan 从这里开始
  ↑
父视图
  ↑
控制器          插在自己的根视图后面
  ↑
窗口
  ↑
UIWindowScene
  ↑
UIApplication

箭头向上。子视图的下一位是父视图。控制器出现在自己的根视图后面。

普通 UIView 没写触摸方法时,默认实现把消息交给 next,所以上面父视图能收到。某一层写了方法又不调用 super,链停在这一层。

不调用 super                 调用 super

子视图  打印 child           子视图  打印 child
   ✕ 停在这里                    │
                                 ▼
                             父视图  打印 parent

左边只有子视图的打印。右边子视图先处理自己的事,super 再把同一次触摸交给父视图,两行都会出现。

四个方法要一起这样转发:touchesBegan、touchesMoved、touchesEnded、touchesCancelled。我只转发了开始,抬起那一下父视图收不到。

UIButton 是另一条结果。它是 UIControl,自己实现了这四个方法,用来跟踪手指、发送 action。

普通 UIView                      UIButton
没有写触摸方法                   自己实现了四个触摸方法
默认交给 next                    不把 touchesBegan 交给父视图
父视图能收到                     父视图没有打印
手势列表可以是空的               手势列表也是空的

两列的手势都可以是空的。父视图收不收得到,差在有没有把触摸方法留给自己。

传递向下找人。响应向上找愿意处理的人。

解答

参考资料

Copyright © 2026