0%

iOS 事件分发与手势识别

iOS 事件分发与手势识别

在讲事件的分发之前,有三个概念不得不提:响应者对象, nextResponder, 响应者链.

响应者对象

在iOS系统中,能够响应并处理事件的对象称之为responder object, UIResponder是所有responder对象的基类。
UIApplication,UIViewController,UIView和所有继承自UIView的UIKit类(包括UIWindow,继承自UIView)都直接或间接的继承自UIResponder,所以它们的实例都是responder object对象。

nextResponder

有UIResponder的文档如下:

The UIResponder class does not store or set the next responder automatically, so this method returns nil by default. Subclasses must override this method and return an appropriate next responder.

UIResponder自身默认返回的是nil.但子类必须重写这个方法并且返回一个合适的nextResponder.UIView的默认实现:通常情况下是它的父视图,但是如果view是作为ViewController的rootView,那么它的nextResponder就是ViewController. ViewController的默认实现:返回它管理的view的父视图.

响应者链

app的视图结构是一个N叉树(一个视图可以有多个子视图,一个子视图同一时刻只有一个父视图),而每一个继承UIResponder的对象都可以在这个N叉树中扮演一个节点。当叶节点成为第一响应者的时候,从这个叶节点开始往其父节点开始追朔出一条链,这一条链就是当前活跃的响应者链。

响应者链的链头是叶节点,链尾是UIApplication(如果AppDelegate也继承自UIResponsder,那么UIApplication会将事件代理给它).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)didTapped:(UIGestureRecognizer *)sender {
//拿到第一响应者,根据nextResponder就可以拿到整个响应者链。
UIResponder *view = sender.view;
int i = 0;
while (view != nil) {
NSLog(@"第%d个:%@", i, view);

i += 1;
view = view.nextResponder;
}
}

链头
第一响应者--->xxx--->xxx---...-->UIWindow--->UIApplication--->AppDelegate(可能)

介绍完这个三个概念后,开始正文.

第一响应者的查找

简单点讲就是当前window(这里指的是keyWindow)会对其上的视图进行hitTest检测. hitTest方法会返回第一响应者.

复杂点讲就是发生触摸事件后,系统会将该事件加入到一个由UIApplication管理的事件队列中.UIApplication会从事件队列中取出最前面的事件,并将事件分发下去以便处理.通常,先发送事件给应用程序的主窗口(keyWindow).主窗口会在视图层次结构中进行hitTest检测来找到一个最合适的视图处理触摸事件,这也是整个事件处理过程的第一步。找到合适的视图控件后,就会调用视图控件的touches方法来作具体的事件处理。

hitTest的一种可能实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
//首先判断是否可以接收事件
if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
//然后判断触摸点是否在当前视图上
if ([self pointInside:point withEvent:event] == NO) return nil;
//循环遍历所有子视图,查找是否有最合适的视图
for (NSInteger i = self.subviews.count - 1; i >= 0; i--) {
UIView *childView = self.subviews[i];
//转换点到子视图坐标系上
CGPoint childPoint = [self convertPoint:point toView:childView];
//递归查找是否存在最合适的view
UIView *fitView = [childView hitTest:childPoint withEvent:event];
//如果返回非空,说明子视图中找到了最合适的view,那么返回它
if (fitView) {
return fitView;
}
}
//循环结束,仍旧没有合适的子视图可以处理事件,那么就认为自己是最合适的view
return self;
}

note:重写hitTest方法要注意开始的三个条件判断,否则可能会出一些bug:比如重写button的hitTest方法但没有加上述判断,就会导致明明已经隐藏了button,但如果摸到了该button,那么该button的target-action方法将会被调用,这应该是你不希望的.

触摸事件处理的整体过程

  1. 用户点击屏幕后产生一个触摸事件,经过一系列的hitTest过程后,会找到最合适的视图控件来处理这个事件

  2. 找到最合适的视图控件后,就会调用控件的touches方法来做具体的事件处理touchesBegan…touchesMoved…touchedEnded…

  3. UIResponder的这些touches方法的默认实现是将事件顺着响应者链条向上传递,将事件交给nextResponder进行处理。如果最终都没有响应者处理,该事件就被抛弃。(如果你不想将事件交给nextResponder处理,那么在处理事件的时候不调用super就可以了,事件的处理到这个节点也就结束了)

note:在定制UIView子类的上述事件处理方法时,如果需要将事件传递给next responder,可以直接调用super的对应事件处理方法,这样事件将会传递给next responder,即使用
[super touchesBegan:touches withEvent:event];
不建议直接向nextResponder发送消息,这样可能会漏掉父类对这一事件的其他处理。
[self.nextResponder touchesBegan:touches withEvent:event];

为什么调用super,nextResponder会收到touch消息?super的意思是调用父类的实现,网上的一种说法是UIView的touch方法实现会将touch事件转发给nextResponder。

父视图UIView上有一个按钮UIButton,点击btn,父视图上的touches系列方法是不会调用的,说明UIButton处理事件后没有调用super。

手势识别

有文档曰:

Gesture recognizers receive touch and press events before their view does. If a view’s gesture recognizers fail to recognize a sequence of touches, UIKit sends the touches to the view. If the view does not handle the touches, UIKit passes them up the responder chain. For more information about using gesture recognizer’s to handle events, see Handling UIKit Gestures.

又有UIGestureRecognizer文档曰:

A window delivers touch events to a gesture recognizer before it delivers them to the hit-tested view attached to the gesture recognizer. Generally, if a gesture recognizer analyzes the stream of touches in a multi-touch sequence and doesn’t recognize its gesture, the view receives the full complement of touches. If a gesture recognizer recognizes its gesture, the remaining touches for the view are cancelled.

字里行间透露着一句话:手势识别器拥有优先处理事件的权利。

手势识别的过程基本上是这样子的:当找到第一响应者view后,window会先让view上的手势识别器处理事件,当view上的手势识别器识别失败后,view才会接收到全部的touch。否则如果手势识别成功,view将收到touchCancel回调。与此同时(是否同时不太确定)系统也会沿着响应者链让所有链上的responder上的手势识别器处理事件,如果其中某个手势识别器识别成功,那么第一响应者将收到touchCancel回调.不同responder上的手势识别器可能会同时识别成功.而shouldRecognizeSimultaneouslyWithGestureRecognizer代理方法默认返回false.因此默认情况下最终只会有一个识别成功,即响应者链上最前面的responder上的手势的action方法被调用.

举个例子,如下图:XQView0_1上添加tap手势,XQView0_1_0的touch系列方法不调用super,点击XQView0_1_0。

打印:

1
2
3
2023-03-31 12:56:06.719888+0800 ResponderChainDemo[26161:2476848] touchesBegan:<XQView0_1_0: 0x129b1d340; frame = (20 19.6667; 35 35); autoresize = RM+BM; backgroundColor = <UIDynamicModifiedColor: 0x60000274de30; contrast = normal, baseColor = <UIDynamicCatalogSystemColor: 0x600003c71100; name = systemTealColor>>; layer = <CALayer: 0x6000029aba00>>
2023-03-31 12:56:06.720824+0800 ResponderChainDemo[26161:2476848] didTapped
2023-03-31 12:56:06.721124+0800 ResponderChainDemo[26161:2476848] touchesCancelled:<XQView0_1_0: 0x129b1d340; frame = (20 19.6667; 35 35); autoresize = RM+BM; backgroundColor = <UIDynamicModifiedColor: 0x60000274de30; contrast = normal, baseColor = <UIDynamicCatalogSystemColor: 0x600003c71100; name = systemTealColor>>; layer = <CALayer: 0x6000029aba00>>

可以看到,即使XQView0_1_0不调用super,XQView0_1上的手势也能识别成功,并且识别成功后XQView0_1_0收到touchesCancelled消息。

猜想实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
UIResponder *firstResponderView = a;
NSArray *responders = @[a, b, c, ..., UIWindow, UIApplication, AppDelegate];

firstResponderView.touchBegin

//进行手势识别
BOOL hasRec = NO;
for item in responders {
item.gestureRecognizers 开始识别手势
if 识别成功 {
hasRec = YES;
调用ges的target-action方法。
}
}

if hasRec {
firstResponderView.touchCancelled
} else {
//将touch事件全部交给firstResponderView处理。
firstResponderView.touchMoved
firstResponderView.touchEnd
}

UIWindow的派发事件方法:

1
- (void)sendEvent:(UIEvent *)event;                    // called by UIApplication to dispatch events to views inside the window

如果第一响应者是UIControl,情况会有所不同.

UIControl的文档如下:

The target object can be any object, but it is typically the view controller whose root view contains the control. If you specify nil for the target object, the control searches the responder chain for an object that defines the specified action method.

当你指定target为nil的时候,系统会沿着响应者链寻找一个实现了action方法的响应者对象并调用action方法.上面说到手势识别器识别成功后,响应者链上的view将收到touchCancel回调.这里就有冲突了.最终的结果就是nextResponder上的手势识别器(cancelsTouchesInView = true,就是那种自己处理完后不让view处理的识别器)将不处理, 所有touch由UIControl处理,处理完后调用action方法.

比如:父view上添加tap手势,父view上添加一个UIButton.点击button,响应的是button的target-action,而不是tap的target-action.如果父view上的tap cancelsTouchesInView = false,则两者都会被调用.

参考

深入理解 iOS 事件机制 TODO:跟着作者的demo做一遍,加深理解

iOS事件分发机制与实践

iOS响应者链、事件的传递

详解iOS触摸事件与手势识别

iOS事件响应者链之被忽视的手势识别器工作原理 作者的思考挺有意思的

官方文档:

UIGestureRecognizer

Using Responders and the Responder Chain to Handle Events

nextResponder

About the Gesture Recognizer State Machine 讲解手势识别器状态机的。如果要实现自定义的手势识别器建议看看。

觉得文章有帮助可以打赏一下哦!