当我们讨论 “iOS 事件处理与响应者链” 的时候,其实我们想要了解的是:触摸事件是如何被正确的视图识别并且被正确的处理。当然我们都知道,iOS 设备上事件有三种(为了便于说明问题,全文都使用触摸事件来举例):
1: 触摸事件 2: 摇一摇 3: 远程控制事件
从软件层面开始,当手指触摸到屏幕后,触摸事件会被 SpringBoard 这个系统进程拿到并处理:
1: 如果没有 App 在前台,则触摸事件交由桌面系统处理,即主屏幕。 2: 否则,交给 App 处理,即 UIApplication 这个单例对象。
App 处理触摸事件的大致过程
整个处理过程的概括起来看非常简单,我觉得用两句话,就能说透彻:
1、寻找第一响应者(First Responder)视图。 2、把触摸事件封装为 UIEvent 并将其交给第一响应者,然后从第一响应者开始决策处理该 UIEvent。
注意顺序一定是先 1 后2。即先找到最佳响应事件的视图(称为第一响应者),然后 UIApplication 通过 UIWindow 把封装好的 UIEvent 直接交给第一响应者,让其决策如何处理这个 UIEvent。
1、寻找第一响应者
第一响应者(First Responder)的含义很简单,简单说就是 UIWindow 下,第一个接收到 UIEvent 的视图对象(除 UIWindow),往往也是最适合处理当前触摸事件的视图(一般是一个 UIView)。比如点击事件发生在一个按钮上,那么默认情况下,该按钮就是第一响应者。如果点击到输入框,那么输入框就是第一响应者。
![notion image](https://www.notion.so/image/i_first_responder.png?table=block&id=754a55e1-a344-4f91-a4f9-c6e0b7f3b3f8&cache=v2)
first_reponder
当 Application 开始寻找第一响应者时,会首先询问当前的 UIWindow 对象。UIWindow 会遍历它所有的子视图,来找到最佳响应者, UIWindow 的子视图,在数据结构上是一棵树:
![notion image](https://www.notion.so/image/click_button.png?table=block&id=c56a80ff-901e-46c2-b1ad-83a91f58095a&cache=v2)
first_reponder
假设,UIButton 被点击,那么算法需要从 Root View 开始找到 UIButton;简单来讲,就是要找到包含了触摸点坐标的最远的后裔节点。是否满足这个条件,通过下边两个方法来判断:
1:- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; 2: - (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;
这两个方法定义在 UIView 上;hitTest 内部会调用 pointInside 来判断,触摸点是否被当前视图包含(被子视图包含也算):
1、如果不包含触摸点,放弃当前树杈分支,回到最近的包含了触摸点的根视图(hitTest 方法会返回该视图对象),从另一个分叉继续寻找。 2、如果包含触摸点,递归遍历当前视图子视图,直到某个满足条件的视图没有子视图(hitTest 方法 return nil)的时候,那么该视图就是第一响应者。
这个递归函数其实也很好写,递归的过程就是数据结构中树的递归(这里可以采用深度优先的策略进行后序遍历),另外递归的结束条件很明确:
1、pointInside 返回 True。 2、当前视图没有子视图,hitTest 返回 nil。
同时满足 1、2,递归结束,第一响应者被找到。另外,在寻找的过程中,一些属性会影响视图是否能成为第一响应者:
1、视图 hidden ,即使满足递归条件,也不能被做为第一响应者。 2、视图的 userInteractionEnabled 为 false,视图放弃响应用户交互,所以也没有资格成为第一响应者。
最后,开发中,经常会通过重写 hitTest 方法,来扩大按钮的点击区域。
2、开始响应 UIEvent 事件
经过上边的步骤,Application 已经找到了第一响应者对象,接下来 UIWindow 会将封装好的 UIEvent 对象,直接交给第一响应者,让其处理。
![notion image](https://www.notion.so/image/event_process.png?table=block&id=b5365ae7-abe3-42a1-9007-f19b8ae9195c&cache=v2)
event process
所以,理论上,我们可以通过拦截
-[UIWindow sendEvent:]
拿到 App 内所有的事件 (包括手势识别)。2.1、Responder 对象
视图之所以能处理 UIEvent,是因为它们都是 Responder 对象;iOS API 设计了 UIResponder 对象(Mac 上是 NSReponder),UIApplication、UIView、UIViewcontroller 都继承了它。这样屏幕上显示的视图对象同时也都是 Reponder 对象。
![notion image](https://www.notion.so/image/ios_responder.jpg?table=block&id=b7590189-0200-4003-ab2f-6db3a31c7b9c&cache=v2)
first_reponder
UIResponder 中定义了 4 个处理事件的重要方法,用来处理 UIEvent 对象:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event; - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event; - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event; - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
如果视图响应处理 UIEvent 事件,就需要实现上述方法;默认情况下UIView 视图(除 UIControl、UIScrollView 及其子类,下同) 并没有实现该方法,此时会走 UIResponder 的默认实现,默认实现具体做的是:传递响应者链。
2.2、响应者链 Responder Chain
当第一响应者不能处理 UIEvent 的时候,事件会被转发给下一个响应者(next Responder),下一个响应者不能处理,则继续传递给 next Responder ,以此类推下去,便形成了响应者链。UIResponder 的属性 nextResponder 是建立响应者链的桥梁:
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
响应者链的链路是: subview –> (viewController) –> parentView –> … –> UIWindow –> UIApplication (下图的右半部分是 macOS 上的链路) :
![notion image](https://www.notion.so/image/responder_chain.png?table=block&id=7a4413bb-4df7-4570-abc4-3ce8a6de2ae3&cache=v2)
responder_chain
当执行
addSubView:
或addChildViewController:
方法的时候,parentView 和 parentViewController 会自动被设置为 nextResponder。当然,可以通过重写
-touchesBegan
等方法,来切断响应者链条。具体做法是自己的实现中不调用-[super touchesBegan ]
即可。UIControl 的 Target-Action 设计模式
在 UIControl 及其子类(UIButton …)的设计上,iOS Api 采用了 Target-Action 的设计模式。宏观上来看,这并不属于响应者链的一部分,它只是事件处理的一个末端机制。
-[button addTarget:self action:@selector(buttonClicked:) forControlEvents:UIControlEventTouchUpInside];
上边的方法调用,几乎是开发者天天写的代码,也是典型的 Target-Action 设计模式,从方法名就能看出来:
第一参数就是Target 第二参数就是Action 第三参数是对 UIEvent 的抽象映射
Target 就是 UIEvent 的新的作用对象(响应者),Action 是对该 UIEvent 做出响应的具体动作(响应)。UIControl 通过这种 Target-Action 的方式对 UIEvent 进行了转发,从而可以把 UIEvent 事件转发给任意对象处理(原本只有 UIReponder 对象和手势识别器对象才能处理)。
UIControl 实现的具体做法是,重写
-touchesBegan
相关方法,改变响应者链来实现:1、首先在
-touchesBegan
方法中调用-sendAction:to:forEvent:
把消息先转发给 UIApplication,让其统一处理; 2、然后 UIApplication 调用sendAction:to:from:forEvent:
把消息交给具体的 Target 处理。- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self sendAction:@selector(buttonClicked:) to:target forEvent:event]; } - (void)sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event { [[UIApplication sharedApplication] sendAction:action to:target from:self forEvent:event]; }
![notion image](https://www.notion.so/image/control_target_action.png?table=block&id=5250dcc9-5f4e-4e76-a6f0-459f258a8e08&cache=v2)
Target Action
关于手势识别器
为了更方便的处理一些常用的事件,UIKit 提供了手势识别器。手势识别更像一个顶层设计,通过 Target-Action 设计模式,快速实现常用事件的响应,同时又不会参与到响应者链条这种繁杂的事件传递中。
1、手势识别属于事件处理阶段,工作在找到了第一响应者之后,但不参与响应者链条的传递。 2、如果成功 hitTest 到第一响应者,且第一响应者绑定了手势识别,那么事件会优先传给手势识别器。 3、手势识别需要一定的过程,比如 pan 手势的识别,需要先经历几个 touches began 与 touches move 事件,随后 pan 手势识别成功,默认会拦截后续的所有 touches 事件。此后,view 自身的 touches 相关方法不再回调。不过可以通过修改 cancelsTouchesInView = false,让 view 和 手势识别同时响应事件。4、手势识别和UIControl
类似,通过 UIApplication 中转实现Target-Action
模式。
手势识别本身还有很多强大的功能待挖掘,这里不再展开。 (全文完,可以点赞,转发,转载请注明出处,谢谢)
参考文档
- https://developer.apple.com/library/archive/documentation/General/Conceptual/Devpedia-CocoaApp/Responder.html#//apple_ref/doc/uid/TP40009071-CH1-SW1
- https://developer.apple.com/library/archive/documentation/General/Conceptual/Devpedia-CocoaApp/EventHandlingiPhone.html#//apple_ref/doc/uid/TP40009071-CH13-SW1
- https://developer.apple.com/library/archive/documentation/General/Conceptual/CocoaEncyclopedia/Target-Action/Target-Action.html#//apple_ref/doc/uid/TP40010810-CH12
- https://developer.apple.com/documentation/uikit/uigesturerecognizer