在iOS中不是任何对象都能处理事件,只有继承了UIResponder
的对象才能接收并处理事件,我们称之为响应者对象。
一、UIResponder
UIApplication
,UIViewController
、UIView
都继承自UIResponder
,因此他们都是响应者对象,都能接收并处理事件。
UIResponder
内部提供了以下方法来处理事件:
1.1. 触摸事件
1 | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event; |
touches
是一个集合对象,集合存放的对象都是UITouch
类型。为什么要放到集合里面呢?因为每一根手指对应一个UITouch
对象,多个手指就会对应多个UITouch
对象,所以要放到一个集合里面。
UITouch
作用:
- 保存手指触摸相关的信息,比如触摸的位置、时间、阶段等;
- 当手指移动时,系统会更新同一个
UITouch
对象,使之能够一直保存该手指所在的触摸位置; - 当手指离开屏幕时,系统会销毁对应的
UITouch
对象。
UITouch常用属性
触摸产生时所处的窗口
1
@property(nullable,nonatomic,readonly,strong) UIWindow *window;
触摸产生时所处的视图
1
@property(nullable,nonatomic,readonly,strong) UIView *view;
短时间内点按屏幕的次数,可以根据tapCount判断单击、双击或多次点击
1
@property(nonatomic,readonly) NSUInteger tapCount;
记录了触摸事件产生或变化时的时间,单位是秒
1
@property(nonatomic,readonly) NSTimeInterval timestamp;
当前触摸事件所处的状态
1
@property(nonatomic,readonly) UITouchPhase phase;
UITouch常用方法
获取触摸点的坐标
1
- (CGPoint)locationInView:(nullable UIView *)view;
- 返回值表示触摸在view上的位置;
- 这里返回的位置是针对触摸view的坐标系的(以view的左上角为原点
{0, 0}
); - 调用时传入的view参数为
nil
的话,返回的是触摸点在UIWindow
的位置。
获取触摸view上一个触摸点坐标(上一个触摸位置的历史记录点)
1
- (CGPoint)previousLocationInView:(nullable UIView *)view;
UIEvent
UIEvent
称为事件对象,记录事件产生的时刻和类型。每产生一个事件,就会产生一个UIEvent
对象。
UIEvent常用属性
事件类型
1
2
3@property(nonatomic,readonly) UIEventType type;
@property(nonatomic,readonly) UIEventSubtype subtype;事件产生的时间
1
@property(nonatomic,readonly) NSTimeInterval timestamp;
1.2. 加速计事件
1 | - (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event API_AVAILABLE(ios(3.0)); |
1.3. 远程控制事件
1 | - (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event API_AVAILABLE(ios(4.0)); |
1.4. 3DTouch事件
3DTouch是iOS9新增的事件,只有iPhone6s以上机型才支持。
1 | - (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0)); |
二、事件的产生和传递
发生触摸事件后,系统会将该事件加入到一个由UIApplication
管理的事件队列中。
UIApplication
会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常,先发送事件给应用程序的主窗口(就是UIApplication
的keyWindow
)。
主窗口会在视图层次结构中找到一个最合适的视图来处理触摸事件,这也是整个事件处理过程的第一步。
找到合适的视图控件后,就会调用视图控件的touches
系列方法来作具体的事件处理。
触摸事件的传递是从父控件传递到子控件,如果父控件不能接收触摸事件,那么子控件就不可能接收到触摸事件。
什么情况下UIView不接收事件呢?
- 不接收用户交互
userInteractionEnabled = NO;
- 视图隐藏
hidden = YES;
- 透明
alpha <= 0.01
注意:UIImageView
的userInteractionEnabled
默认值是NO
,所以UIImageView
及其子控件默认是不能接收事件的。
我们通过一个示例程序,看下事件传递过程:
示例中视图的层级关系:
点击红色view时事件传递:
1 | -> UIApplication -> UIWindow(keyWindow)-> 白色view(或控制器view)-> 红色view |
点击紫色view时事件传递:
1 | -> UIApplication -> UIWindow(keyWindow)-> 白色view(或控制器view)-> 黄色view -> 蓝色view -> 紫色view |
2.1. hitTest
这个方法是UIView
提供的,当事件传递给当前view时,会调用当前view的该方法看自己及其子类是否能够接收事件,最终返回能够接收事件的view对象。
1 | - (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event |
- point:触摸点
- event:事件信息
- 返回值:处理事件的view
我们通过上面案例看下该方法的响应过程:
ViewController.m
1 | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { |
DBRedView
1 | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { |
DBGreenView
1 | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { |
DBYellowView
1 | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { |
DBBlueView
1 | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { |
DBPurpleView
1 | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { |
DBTealView
1 | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { |
点击控制器白色view时,输出:
1 | 2016-09-02 15:44:06.321289+0800 ResponderDemo[26021:1537173] RedView |
点击红色view时,输出:
1 | 2016-09-02 16:02:10.479834+0800 ResponderDemo[26986:1550250] RedView |
点击紫色view时,输出:
1 | 2016-09-02 16:02:35.484942+0800 ResponderDemo[26986:1550250] RedView |
- 分析点击紫色view事件传递过程:
- 查看
UIWindow
上的视图,把事件传递给控制器view; - 倒序遍历控制器view子视图
[Yellow, RedView]
,把事件传递给RedView
; - 遍历
RedView
的子视图,发现是空的,且不是最适合接收事件的对象,返回nil
,把事件传递给YellowView
; - 遍历
YellowView
的子视图[BlueView]
,把事件传递给BlueView
; - 遍历
BlueView
的子视图[PurpleView, TealView]
,把事件传递给TealView
; - 遍历
TealView
的子视图,发现是空的,且不是最适合接收事件的对象,返回nil
,把事件传递给PurpleView
; - 遍历
PurpleView
的子视图,发现是空的,但是自己是最适合接收事件的对象,返回自己,并把这个返回值依次往上传递给自己的父视图(BlueView
,YellowView
),最后执行Touch
方法;
- 查看
通过上面案例我们可以总结以下结论:
系统触摸事件到App进程后,由UIApplication
的事件队列进行管理,之后会按照数组倒序形式匹配适合的视图来接收事件,并把事件传递给子控件,调用子控件的hitTest:withEvent:
方法,如果子控件没有找到最适合的view,那么自己就是最适合接收事件的view,最终执行Touch
方法。
问题来了,怎样判断一个视图就是最适合接收事件的视图呢?
继续往下看……
2.2. pointInside
这个方法也是UIView
提供的,当事件传递给当前视图时,会判断触摸点是否在当前视图范围内,以此来判断是否可以有效接收事件。
1 | - (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event; |
- point:触摸点
- event:事件信息
- 返回值:YES代表点在view的范围内,NO代表触摸点不在范围内
继续研究上面的案例:DBYellowView
新增pointInside:
方法
1 | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { |
点击紫色view时,输出:
1 | 2016-09-02 16:53:56.407022+0800 ResponderDemo[29351:1579409] RedView |
pointInside
方法返回NO
时,事件没有继续往下传递,touches
相关方法也就不再执行了。而且事件往下传递前调用了pointInside:withEvent:
方法,最后在hitTest:withEvent:
方法中返回了nil
。
把黄色view中的pointInside
返回YES
,点击控制器view时,输出:
1 | 2016-09-02 17:23:23.508077+0800 ResponderDemo[30341:1594278] RedView |
为什么点击控制器view时,YellowView
会执行Touch
方法?因为事件传递给黄色view时,黄色view把该触摸点认为是在自身范围内的,所以最终在hitTest
方法中返回自己。
这在正常逻辑中是错误的行为,因为pointInside
方法必须是和方法调用者在同一个坐标系。默认情况下,由系统判断一个点是否在view的范围内时,是根据触摸点的坐标是否超出view的范围来判断的,如果超出view的范围,就不应该触发touch
方法。
2.3. hitTest方法的内部实现
上面了解事件传递的规则后,我们尝试手动实现其内部逻辑代码。
内部逻辑:
- 判断自己能否接收事件;
- 点在不在自己身上;
- 倒序遍历子控件,把事件传递给子控件,调用子控件的
hitTest:withEvent
方法; - 如果子控件没有找到最适合的view,那么自己就是最适合的view。
内部实现代码:
1 | - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { |
三、案例分析
场景:一个view覆盖到按钮上面,按钮绑定的事件和view的点击事件互不影响。
代码实现:DBTealView.h
1 | @interface DBTealView : UIView |
DBTealView.m
1 |
|
DBTouchButtonViewController.m
1 |
|
点击蓝色view输出:TealView-Touch
点击红色按钮输出:-[DBTouchButtonViewController redBtnTouch:]
点击红色按钮和蓝色view的交集(覆盖面)输出:TealView-Touch
很显然不是我们想要的结果,利用上面介绍的知识修改下代码就可以。DBTouchButtonViewController.m
1 | - (void)viewDidLoad { |
DBTealView.h
1 | @interface DBTealView : UIView |
DBTealView.m
1 | - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { |
点击蓝色view输出:TealView-Touch
点击红色按钮输出:-[DBTouchButtonViewController redBtnTouch:]
点击红色按钮和蓝色view的交集(覆盖面)输出:-[DBTouchButtonViewController redBtnTouch:]
结果符合预期。
结论:当遇到事件冲突或者需要事件转移的时候,应首先考虑让哪个视图在什么样的条件下成为事件最终处理者。在平时开发中,通过该方式可以增加视图的热区(触摸)范围。
四、事件的响应链
响应链条:由多个响应者对象连接起来的链条。
作用:能够很清楚的看见每个响应者之间的关系,并且可以让一个事件多个对象处理。
示例
UI构建还是以上面的示例程序为参考
自定义Application、Window、然后重写他们以及界面上view的touchesBegan:withEvent:
方法
main.m
1 | int main(int argc, char * argv[]) { |
DBApplication.m
1 | @implementation DBApplication |
DBWindow
1 | @implementation DBWindow |
AppDelegate.m
1 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { |
其他view都参照DBWindow
,重写touchesBegan:withEvent:
方法,并调用父类的方法。
点击紫色view输出:
1 | 2016-09-02 19:14:16.940994+0800 ResponderDemo[59378:1948636] -[DBPurpleView touchesBegan:withEvent:] |
事件处理的流程
- 先将事件对象由上往下传递(由父控件传递给子控件),找到合适的控件来处理这个事件;
- 调用最合适控件的
touches..
系列方法; - 如果调用了
[super touches...]
就会将事件顺着响应者链条往上传递,传递给上一个响应者; - 接着就会调用上一个响应者的
touches..
系列方法; - 如果没有响应者或响应者对象是
UIApplication
,则事件终止。
如何判断上一个响应者?
- 如果当前view是控制器的view,那么控制器就是上一个响应者;
- 如果当前view不是控制器的view,那么父控件就是上一个响应者。
后记
如果想监听一个view上面的触摸事件,需要自定义view,实现view的touches方法,在方法内部实现具体处理代码。但这种方式实现起来有明显的缺点:必须自定义view,由于在view内部touches方法中监听事件,因此默认情况下,无法让其他外界对象监听view的触摸事件,而且实现用户的具体手势行为非常繁琐。因此苹果给我们提供了手势识别功能(Gesture Recognizer),简单易用,开发效率大大提升。
注意:hitTest
和pointInside
方法系统会执行两次,文中的相关输出都是经过过滤的。至于为什么会执行两次,苹果工程师给出了以下答复(查看原文回复)
Yes, it’s normal. The system may tweak the point being hit tested between the calls. Since hitTest should be a pure function with no side-effects, this should be fine.