iOS 事件的传递链和响应链

为了搞明白从用户点击屏幕开始,到UI根据点击出现反馈。我们可以分成两个部分,1.如何找到哪个View应该响应(或者说 第一响应者)2.沿响应链传递事件。

模拟场景

我们要模拟一个场景,把整个过程走一遍,就可以理解了:
UIWindow -> UIViewController(root) -> UIView -> UIButton,用户点击了UIButton

1.如何找到哪个View来响应

这里我们要说一下事件的传递机制
过程如下:

  1. UIApplication 接收事件:
    • UIApplication 接收到系统传递的触摸事件。
    • UIApplication 将事件传递给应用的主窗口(UIWindow)(func sendEvent(_ event: UIEvent))。
  2. UIWindow 处理事件:
    • UIWindowhitTest(_:with:) 方法会被调用来找到触摸点所在的视图。
    • hitTest(:with:) 方法会递归遍历视图层次结构,从根视图开始,调用每个视图的 pointInside(:with:) 方法。
    • pointInside(_:with:) 方法检查触摸点是否在视图的边界内。如果返回 true,继续递归查找子视图;如果返回 false,继续查找兄弟视图。
    • 最终找到包含触摸点的最深层次的视图,这个视图被称为“事件的第一响应者”(First Responder)。

我们可以看一下具体实现:

open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if !self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01 {
            return nil
        }
        if self.point(inside: point, with: event) {
            for subview in self.subviews.reversed() {
                let convertedPoint = self.convert(point, to: subview)
                if let hitView = subview.hitTest(convertedPoint, with: event) {
                    return hitView
                }
            }
            return self
        }
        return nil
    }

这里有几点要注意

  1. 这是一个递归函数,如果仔细观察,它和数据结构图的遍历算法很像,都是递归中嵌套for循环,但图会发生死循环(不过多赘述),所以需要判断 isVisited,这里的View因为不会有反向的指针,所以不需要。
  2. hitTest(:with:)pointInside(_:with:) 都是 UIView的函数,UIWindow也是继承自UIView,所以可以递归调用,找到最终应该响应的View
  3. for subview in self.subviews.reversed() 这行代码中的 reversed 很关键,因为假设我们同一个位置,add了多个view,一定的上层的view优先级最高。

所以当用户点击了Button的时候,事件先是由 UIApplicationsendEvent,然后 UIWindow 递归调用,hittest等函数,最终确定了UIButton

  1. 传递事件:
    这里我们要说到响应链最重要的类,UIResponderUIViewUIViewControllerUIWindowUIApplication都继承了它。
    • 找到第一响应者视图后,触摸事件会被传递到该视图。
    • 该视图会调用 touchesBegan(_:with:) 方法来响应触摸事件。
    • 在触摸过程中,系统会调用 touchesMoved(:with:)touchesEnded(:with:)touchesCancelled(_:with:) 方法来处理触摸的移动、结束和取消。

不同的控件对UIResponder的实现是不同的,比如 UIView

extension UIResponder {
    @objc func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        // 1. 更新内部状态
        self.updateInternalState(for: touches, with: event)
        
        // 2. 通知手势识别器
        self.notifyGestureRecognizers(for: touches, with: event)
        
        // 3. 将事件传递给下一个响应者
        if let nextResponder = self.next {
            nextResponder.touchesBegan(touches, with: event)
        }
    }
}

这里的关键是通过调用 next 将事件传递给上层。如果没有响应者,那会一直向上传递到UIApplication
UIButton 就不同了,因为它继承自 UIControl

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        
        // 1. 更新按钮的外观状态,例如变为高亮状态
        self.isHighlighted = true
        
        // 2. 检查是否需要触发手势识别器
        self.checkGestureRecognizers(for: touches, with: event)
        
        // 3. 触发目标-动作机制
        if shouldTriggerActions(for: touches, with: event) {
            self.sendActions(for: .touchDown)
        }
    }

因为事件到这里就需要处理了,所以不需要在调用next去传递了

UIResponder 如何获取上一层UIResponder
extension UIView {
    override open var next: UIResponder? {
        return self.superview ?? self.viewController
    }
    
    // 视图控制器的计算属性
    var viewController: UIViewController? {
        var nextResponder: UIResponder? = self
        while let responder = nextResponder {
            if let viewController = responder as? UIViewController {
                return viewController
            }
            nextResponder = responder.next
        }
        return nil
    }
}

这里感兴趣的同学可以去试试,如果你重写了 UIViewtouch函数,在里面print一些数据,你会发现,当你点击一个view的时候,它的父Viewtouch也会调用,因为UIView的函数实现中有调用UIRespondernext函数。但如果你重写UIButtontouch函数做同样的事,会发现父Viewtouch函数并不会被调用。因为UIButton这个控件就是用来处理事件的,不需要向上传递了。

在看看我们最初模拟的场景,UIButton会根据自身实现的UIRespondertouch一系列函数,实现UI上的反馈,点击事件的响应等等。

在日常开发中,我们有时候会遇到一些特殊需求,比如扩大UIButton的点击响应范围,或子View的区域比父View大,这都需要我们去重写UIView中的pointInside(_:with:)去改变它的响应逻辑。

参考资料:
iOS | 事件传递及响应链