本文章将记录有关iOS事件的传递机制,如有错误欢迎指出~
iOS的事件分为3大类型
Touch Events(触摸事件)
Motion Events(运动事件,比如重力感应和摇一摇等)
Remote Events(远程事件,比如用耳机上得按键来控制手机)
在开发中,最常用到的就是Touch Events(触摸事件),基本贯穿于每个App中,也是本文的猪脚~ 因此文中所说事件均特指触摸事件。
接下来,记录、涉及的问题大致包括:
事件是怎么找它的妈妈的?(寻找事件的最佳响应者)
事件又是如何去到妈妈的身边的?妈妈又将如何对待它?(事件的响应及在响应链中的传递)
寻找事件的最佳响应者(Hit-Testing)
当我们触摸屏幕的某个可响应的功能点后,最终都会由UIView或者继承UIView的控件来响应
那我们先来看下UIView的两个方法:
// recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
//返回寻找到的最终响应这个事件的视图
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;
// default returns YES if point is in bounds
//判断某一个点击的位置是否在视图范围内
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;
每个UIView对象都有一个 hitTest: withEvent:
方法,这个方法是Hit-Testing
过程中最核心的存在,其作用是询问事件在当前视图中的响应者,同时又是作为事件传递的桥梁。
看看它是什么时候被调用的
当手指接触屏幕,UIApplication接收到手指的触摸事件之后,就会去调用UIWindow的
hitTest: withEvent:
方法在
hitTest: withEvent:
方法中会调用pointInside: withEvent:
去判断当前点击的point是否属于UIWindow范围内,如果是,就会以倒序的方式遍历它的子视图,即越后添加的视图,越先遍历子视图也调用自身的
hitTest: withEvent:
方法,来查找最终响应的视图
再来看个示例:
视图层级如下(同一层级的视图越在下面,表示越后添加):
A
├── B
│ └── D
└── C
├── E
└── F
现在假设在E视图所处的屏幕位置触发一个触摸,App接收到这个触摸事件事件后,先将事件传递给UIWindow,然后自下而上开始在子视图中寻找最佳响应者。事件传递的顺序如下所示:
UIWindow将事件传递给其子视图A
A判断自身能响应该事件,继续将事件传递给C(因为视图C比视图B后添加,因此优先传给C)。
C判断自身能响应事件,继续将事件传递给F(同理F比E后添加)。
F判断自身不能响应事件,C又将事件传递给E。
E判断自身能响应事件,同时E已经没有子视图,因此最终E就是最佳响应者。
以上,就是寻找最佳响应者的整个过程。
接下来,来看下hitTest: withEvent:
方法里,都做些了什么?
我们已经知道事件在响应者之间的传递,是视图通过判断自身能否响应事件来决定是否继续向子视图传递,那么判断响应的条件是什么呢?
视图响应事件的条件:
允许交互:
userInteractionEnabled = YES
禁止隐藏:
hidden = NO
透明度:
alpha > 0.01
触摸点的位置:通过
pointInside: withEvent:
方法判断触摸点是否在视图的坐标范围内
代码的表现大概如下:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
//3种状态无法响应事件
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
//触摸点若不在当前视图上则无法响应事件
if ([self pointInside:point withEvent:event]) {
//从后往前遍历子视图数组
for (UIView *subView in [self.subviews reverseObjectEnumerator]) {
// 坐标系的转换,把触摸点在当前视图上坐标转换为在子视图上的坐标
CGPoint convertedPoint = [subView convertPoint:point fromView:self];
//询问子视图层级中的最佳响应视图
UIView *hitTestView = [subView hitTest:convertedPoint withEvent:event];
if (hitTestView) {
//如果子视图中有更合适的就返回
return hitTestView;
}
}
//没有在子视图中找到更合适的响应视图,那么自身就是最合适的
return self;
}
return nil;
}
说了这么多,那我们可以运用hitTest: withEvent:
来搞些什么事情呢
使超出父视图坐标范围的子视图也能响应事件
视图层级如下:
A
├── B
如上图所示,视图B有一部分是不在父视图A的坐标范围内的,当我们触摸视图B的上半部分,是不会响应事件的。当然,我们可以通过重写视图A的 hitTest: withEvent:
方法来解决这个需求。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *view = [super hitTest:point withEvent:event];
//如果找不到合适的响应者
if (view == nil) {
//视图B坐标系的转换
CGPoint newPoint = [self.deleteButton convertPoint:point fromView:self];
if (CGRectContainsPoint(self.deleteButton.bounds, newPoint)) {
// 满足条件,返回视图B
view = self.deleteButton;
}
}
return view;
}
在视图A的hitTest: withEvent:
方法中判断触摸点,是否位于视图B的视图范围内,如果属于,则返回视图B。这样一来,当我们点击视图B的任何位置都可以响应事件了。
注:文章底部有简单的Demo(仅供参考)
事件的响应及在响应链中的传递
经历Hit-Testing后,UIApplication已经知道事件的最佳响应者是谁了,接下来要做的事情就是:
将事件传递给最佳响应者响应
事件沿着响应链传递
事件传递给最佳响应者
最佳响应者具有最高的事件响应优先级,因此UIApplication会先将事件传递给它供其响应。
UIApplication中有个sendEvent:
的方法,在UIWindow中同样也可以发现一个同样的方法。UIApplication是通过这个方法把事件发送给UIWindow,然后UIWindow通过同样的接口,把事件发送给最佳响应者。
以寻找事件的最佳响应者一节中点击视图E为例,在EView的 touchesBegan:withEvent:
上打个断点查看调用栈就能看清这一过程:
当事件传递给最佳响应者后,响应者响应这个事件,则这个事件到此就结束了,它会被释放。假设响应者没有响应这个事件,那么它将何去何从?事件将会沿着响应链自上而下传递。
注意:
寻找最佳响应者一节中也说到了事件的传递,与此处所说的事件的传递有本质区别。上面所说的事件传递的目的是为了寻找事件的最佳响应者,是自下而上(父视图到子视图)的传递;而这里的事件传递目的是响应者做出对事件的响应,这个过程是自上而下(子视图到父视图)的。前者为“寻找”,后者为“响应”。
事件沿着响应链传递
在UIKit中有一个类:UIResponder,它是所有可以响应事件的类的基类。来看下它的头文件的几个属性和方法
NS_CLASS_AVAILABLE_IOS(2_0) @interface UIResponder : NSObject
#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
#else
- (nullable UIResponder*)nextResponder;
#endif
--------------省略部分代码------------
// Generally, all responders which do custom touch handling should override all four of these methods.
// Your responder will receive either touchesEnded:withEvent: or touchesCancelled:withEvent: for each
// touch it is handling (those touches it received in touchesBegan:withEvent:).
// *** You must handle cancelled touches to ensure correct behavior in your application. Failure to
// do so is very likely to lead to incorrect behavior or crashes.
- (void)touchesBegan:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet *)touches NS_AVAILABLE_IOS(9_1);
UIApplication,UIViewController和UIView都是继承自它,都有一个 nextResponder
方法,用于获取响应链中当前对象的下一个响应者,也通过nextResponder
来串成响应链。
在App中,所有的视图都是根据树状层次结构组织起来的,因此,每个View都有自己的SuperView。当一个View被add到SuperView上的时候,它的nextResponder
属性就会被指向它的SuperView,各个不同响应者的指向如下:
UIView 若视图是控制器的根视图,则其
nextResponder
为控制器对象;否则,其nextResponder
为父视图。UIViewController 若控制器的视图是window的根视图,则其
nextResponder
为窗口对象;若控制器是从别的控制器present出来的,则其nextResponder
为presenting view controller。UIWindow
nextResponder
为UIApplication对象。UIApplication 若当前应用的app delegate是一个UIResponder对象,且不是UIView、UIViewController或app本身,则UIApplication的
nextResponder
为app delegate。
这样,整个App就通过nextResponder
串成了一条链,也就是我们所说的响应链,子视图指向父视图构成的响应链。
看一下官网对于响应链的示例展示
若触摸发生在UITextField上,则事件的传递顺序是:
- UITextField ——> UIView ——> UIView ——> UIViewController ——> UIWindow ——> UIApplication ——> UIApplicationDelegte
图中虚线箭头是指若该UIView是作为UIViewController根视图存在的,则其nextResponder
为UIViewController对象;若是直接add在UIWindow上的,则其nextResponder
为UIWindow对象。
响应者对于事件的拦截以及传递都是通过 touchesBegan:withEvent:
方法控制的,该方法的默认实现是将事件沿着默认的响应链往下传递。
响应者对于接收到的事件有3种操作:
不拦截,默认操作 事件会自动沿着默认的响应链往下传递
拦截,不再往下分发事件 重写
touchesBegan:withEvent:
进行事件处理,不调用父类的touchesBegan:withEvent:
拦截,继续往下分发事件 重写
touchesBegan:withEvent:
进行事件处理,同时调用父类的touchesBegan:withEvent:
将事件往下传递
因此,你也可以通过 touchesBegan:withEvent:
方法搞点事情~
总结
触摸事件先通过自下而上(父视图-->子视图)的传递方式寻找最佳响应者,
然后以自上而下(子视图-->父视图)的方式在响应链中传递。
Github :TouchEventDemo(仅供参考)
参考资料
深入浅出iOS事件机制
iOS触摸事件全家桶