关于iOS Responder Chain 的一些理解

3,079 阅读5分钟

基本概念

  • 响应者:它是 UIResponder/UIView/UIViewController/UIApplication 的实例。它会接受事件,并且它必须处理事件或将事件传递给下一个响应者。UIKit 会自动决定哪个对象为最合适的响应者,即第一响应者。
  • 响应链:响应者传递事件的过程。

响应链的传递流程

以官方文档的图举例:

如果 text field 没有处理事件, UIKit 会将事件传递给它的父视图 UIView 对象,依次传递到 UIViewController 的根视图,若还不能处理,则传递给 UIWindow 。如果 window 不能处理事件,则将该事件再传递给 UIApplication。

如何决定哪一个响应者包含 touch 事件

UIKit 使用基于 view 的 hit-testing 去决定 touch 事件发生的位置。在发生 touch 事件后,UIKit 会比较 touch 的位置所在的视图是否在视图层级中。hitTest(_:with:)会将包含 touch 事件的最上层的视图变为第一响应者。

如果 touch 的位置超出视图的边界,那 hitTest(_:with:) 方法会忽略该视图的所有子视图。因此,当一个视图的 clipsToBounds 为 false 时,即使 touch 的位置未超出子视图的边界,子视图也不会响应该事件。

下面解释一下如果 touch 的位置超出视图的边界,那 hitTest(_:with:) 方法会忽略该视图的所有子视图:

如果我们有上图层级结构,blueView 是 greenView 的子视图,grayView 是 blueView 的子视图,三个视图都添加 UITapGestureRecognizer 手势,如果你点击 grayView 中超出 blueView 的部分它是不会响应 grayView 的 action,因为该部分已经超出了 blueView 部分,所以 blueView 及其所有子视图都会被响应链忽略。所以,你点击 grayView 中超出 blueView 的部分,响应者是 greenView,会响应 greenView 的 action 。

当发生一个 touch 事件时,UIKit 会创建一个 UITouch 的对象,并将它与一个视图联系起来。需要注意的是,当 touch 的位置或者其他参数改变时, UIKit 会将 UITouch 对象的信息更新。但是 UITouch 对象的 view 属性并不会变。即使 UITouch 对象的位置已经超出原始视图的边界,UITouch 对象的 view 属性值也不会改变。当 touch 结束,UIKit 会释放 UITouch 对象。

hitTest(_:with:)

该方法主要用来返回接受 touch 事件的视图层中最上层的且包含当前 point 的子视图。

它通过 point(inside:with:) 函数来寻找包含 point 的子视图,如果 point(inside:with:) 返回 true,则再往上一直找到最上层且包含 point 的子视图。

你可以通过重写它来对某些子视图隐藏响应事件。当视图的 hidden 为 true 或 isUserInteractionEnabled 为 false 或 alpha 的值小于 0.01 的时候,这些视图将被该方法忽略。

如何修改响应链

你可以通过重写响应者的 next 属性来修改响应链。当你修改之后,下一个响应者即你返回的那个对象。

下面是 UIKit 各类默认下一个响应者:

  • UIView:若 view 是 Controller 的根视图,下一个响应者为 Controller;若不是根视图,则下一个响应者为它的父视图。
  • UIViewController:如果 Controller 是 window 的根视图,下一个响应者是 window 对象;如果 Controller1 是被 Controller2 present 的,那下一个响应者即Controller2。
  • UIWindow:下一个响应者为 UIApplication 对象、
  • UIApplication:当 app delegate 是 UIResponder 一个实例,并且不是一个 view 、 view controller 或者 app 它本身时。UIApplication 的下一个响应者为 app delegate。

关于 UIControl

如果我们在添加手势的视图上面添加一个带有 action 的 button ,如果我们点击 button 会触发 视图的手势事件 还是 button 的 action 呢?

答案是 button 的 action 。看官方文档我们会看见这么一句话手势处理器不会影响 UIKit controls 处理事件的能力。

不仅仅是 UIButton ,其他下面的对象也不会受手势识别器的影响:

  • UISwitch, UIStepper, UISegmentedControl, UIPageControl, UISlider UISwitch

以上这些皆为 UIControl 的子类。

实际应用

扩大UIButton的点击范围

  • 创建一个 UIButton 的子类,重写 point(inside:with:) 方法
// 将按钮的点击范围上下左右扩大10pt的范围,注意范围不要超出父视图的边界,超出范围依旧无效。
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    let newFrame = CGRect(x: bounds.origin.x - 10, y: bounds.origin.y - 10, width: bounds.size.width + 20, height: bounds.size.height + 20)
    return newFrame.contains(point)
}
  • 设置 button 的 contentEdgeInsets 属性
button.contentEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)

解决 scrollView及子类TableView/CollectionView 右滑手势和系统的右滑返回手势的冲突

// 通过修改响应者依赖 来使 scrollView及子类 的手势响应失效
if let rec = navigationController?.interactivePopGestureRecognizer {
    collectionView.panGestureRecognizer.require(toFail: rec)
}

总结

  • 如何查找第一响应者:
1.UIWindow 接受到一个事件,执行 hit-test 去寻找应该接受该事件的对象
2、hitTest:withEvent 将会通过调用 pointInside :withEvent: 方法来判断
视图是否包含当前事件的位置。
3、一直递归调用 hitTest:withEvent 直到找到最上层且包含 point 的对象,即为第一响应者。
  • 事件不能处理的时响应链的传递过程
subview -> superview ... -> root view -> view controller -> root view controller -> window -> UIAPPlication - app delegate
  • UIControl 的子类处理事件的能力不收手势识别器的影响

参考