UI视图之事件传递和视图响应探讨
在iOS中只有继承UIResponder的对象才能够接收并处理事件,UIResponder 是所有响应对象的基类,在UIResponder类中定义了处理上述各种事件的接口。我们熟悉的 UIApplication、 UIViewController、 UIWindow 和所有继承自UIView的UIKit类都直接或间接的继承自UIResponder,所以它们的实例都是可以构成响应者链的响应者对象,首先我们通过一张图来简单了解一下事件的传递以及响应.
- UIResponder类,是UIKIT中一个用于处理事件响应的基类。窗又上的所有事件触发,都由该类响应(即事件处理入又)。所以,窗又上的View及控制器都是 派生于该类的,例如UIView、UIViewController等。
- 调用UIResponder类提供的方法或属性,我们就可以捕捉到窗又上的所有响应 事件,并进行处理。
- 响应者链条是由多个响应者对象连接起来的链条,其中响应者对象是能处理事 件的对象,所有的View和ViewController都是响应者对象,利用响应者链条能 让多个控件处理同一个触摸事件.
1.响应者链条
响应者链条就是由多个响应者对象连接起来的链条,它的作用就是让我们能够清楚的看见每个响应者之间的联系,并且可以让一个时间多个对象处理.
2.响应过程
iOS系统检测到手指触摸(Touch)操作时会将其打包成一个UIEvent对象,并放入当前活动Application的事件队列,单例的UIApplication会从事件队列中取出触摸事件并传递给单例的UIWindow来处理,UIWindow对象首先会使用hitTest:withEvent:方法寻找此次Touch操作初始点所在的视图(View),即需要将触摸事件传递给其处理的视图(最合适来处理的控件),这个过程称之为hit-test view。
那么什么是最适合来处理事件的控件?
1.自己能响应触摸事件
2.触摸点在自己身上
3.从后往前递归遍历子控件, 重复上两步
4.如果没有符合条件的子控件, 那么就自己最合适处理
具体点就是:
在确定最合适控件的过程中,遵循以下原则:
(1)判断自己能否接收触摸事件,如果不能,直接返回;如果能,到第(2)步
(2)判断触摸点是否在自己身上,如果不在,直接返回;如果在,到第(3)步
(3)从后往前遍历子控件,重复前两步
(4)如果没有符合条件的子控件,那么自己就是最适合处理该触摸事件的控件。
事件的链有两条:事件的响应链;Hit-Testing 时事件的传递链。
- 响应链:由离 户最近的view向系统传递。 initial view –> super view –> .....–> view controller –> window –> Application –> AppDelegate
- Hit-Testing 链:由系统向离 户最近的view传递。 UIKit –> active app's event queue –> window –> root view –>......–>lowest view
事件传递
事件传递的两个核心方法
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event; // default returns YES if point is in bounds
第一个方法返回的是一个UIView,是用来寻找最终哪一个视图来响应这个事件
第二个方法是用来判断某一个点击的位置是否在视图范围内,如果在就返回YES
1.hit-test view:事件传递给控件的时候, 就会调用该方法,去寻找最合适的view并返回看可以响应的view
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
// 1.如果控件不允许与用用户交互,那么返回nil
if (self.userInteractionEnabled == NO || self.alpha <= 0.01 || self.hidden == YES){
return nil;
// 2. 如果点击的点在不在当前控件中,返回nil
if (![self pointInside:point withEvent:event]){
return nil;
// 3.从后往前遍历每一个子控件
for(int i = (int)self.subviews.count - 1 ; i >= 0 ;i--){
// 3.1获取一个子控件
UIView *childView = self.subviews[i];
// 3.2当前触摸点的坐标转换为相对于子控件触摸点的坐标
CGPoint childP = [self convertPoint:point toView:childView];
// 3.3判断是否在在子控件中找到了更合适的子控件(递归循环)
UIView *fitView = [childView hitTest:childP withEvent:event];
// 3.4如果找到了就返回
if (fitView) {
return fitView;
// 4.没找到,表示没有比自己更合适的view,返回自己
return self;
}
2.pointInside: 该方法判断触摸点是否在控件身上,是则返回YES,否则返回NO,point参数必须是方法调用者的坐标系.
-(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event{
return NO;
}
事件传递的流程
流程描述
- 我们点击屏幕产生触摸事件,系统将这个事件加入到一个由UIApplication管理的事件队列中,UIApplication会从消息队列里取事件分发下去,首先传给UIWindow
- 在UIWindow中就会调用hitTest:withEvent:方法去返回一个最终响应的视图
- 在hitTest:withEvent:方法中就回去调用pointInside: withEvent:去判断当前点击的point是否在UIWindow范围内,如果是的话,就会去遍历它的子视图来查找最终响应的子视图
- 遍历的方式是使用倒序的方式来遍历子视图,也就是说最后添加的子视图会最先遍历,在每一个视图中都回去调用它的hitTest:withEvent:方法,可以理解为是一个递归调用
- 最终会返回一个响应视图,如果返回视图有值,那么这个视图就作为最终响应视图,结束整个事件传递;如果没有值,那么就会将UIWindow作为响应者
hitTest:withEvent:
流程描述
- 首先会判断当前视图的hiden属性、是否可以交互以及透明度是否大于0.01,如果满足条件则进入下一步,否则返回nil
- 调用pointInside: withEvent:方法来判断这个点是否在当前视图范围内,如果满足条件则进入下一步,否则返回nil
- 然后以倒序的方式遍历它的子视图,在每个子视图中去调用hitTest:withEvent:方法,如果有一个子视图返回了一个最终的响应视图,那么就将这个视图返回给调用方;如果全部遍历完成都没有找到一个最终的响应视图,因为点击位置在当前视图范围内,就将当前视图作为最终响应视图返回
实例场景
接下来我们通过一个具体的实例来进一步的理解事件传递,例如:在一个方形按钮中点击中间的圆形区域有效,而点击四角无效
核心思想是在pointInside: withEvent:方法中修改对应的区域
代码如下:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.userInteractionEnabled || [self isHidden] || self.alpha <= 0.01) {
return nil;
//判断当前视图是否在点击范围内
if ([self pointInside:point withEvent:event]) {
//遍历当前对象的子视图(倒序)
__block UIView *hit = nil;
[self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
//坐标转换
CGPoint convertPoint = [self convertPoint:point toView:obj];
//调用子视图的hitTest方法
hit = [obj hitTest:convertPoint withEvent:event];
//如果找到了就停止遍历
if (hit) *stop = YES;
//返回当前的视图对象
return hit?hit:self;
}else {
return nil;
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
CGFloat x1 = point.x;
CGFloat y1 = point.y;
CGFloat x2 = self.frame.size.width / 2;
CGFloat y2 = self.frame.size.height / 2;
//判断是否在圆形区域内
double dis = sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
if (dis <= self.frame.size.width / 2) {
return YES;
else{
return NO;
视图的响应者链
首先我们要知道事件传递和响应过程是相反的
如果hitTest:withEvent:找到了第一响应者initial view,但是该响应者没有处理该事件,那么事件会沿着响应者链向上传递:第一响应者 -> 父视图 -> 视图控制器,如果传递到最顶级视图还没处理事件,那么就传递给UIWindow去处理,若window对象也不处理那么就交给UIApplication处理,如果UIApplication对象还不处理,就丢弃该事件( 但是并不会引起崩溃 )
并且在iOS中,能够响应事件的对象都是UIResponder的子类对象,UIResponder提供了四个用户点击的回调方法,分别对应用户点击开始、移动、点击结束以及取消点击,其中只有在程序强制退出或者来电时,取消点击事件才会调用。
系统回调方法
// UIView是UIResponder的子类,可以覆盖下列4个方法处理不同的触摸事件
// 一根或者多根手指开始触摸view,系统会自动调用view的下面方法
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
// 一根或者多根手指在view上移动,系统会自动调用view的下面方法(随着手指的移动,会持续调用该方法)
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
// 一根或者多根手指离开view,系统会自动调用view的下面方法
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
// 触摸结束前,某个系统事件(例如电话呼入)会打断触摸过程,系统会自动调用view的下面方法
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
// 提示:touches中存放的都是UITouch对象
响应者链流程图
响应者链有以下特点:
响应者链通常是由 initial view 开始;
- UIView 的 nextResponder 它的 superview;如果 UIView 已经是其所在的 UIViewController 的 top view,那么 UIView 的 nextResponder 就是 UIViewController;
- UIViewController 如果有 Super ViewController,那么它的 nextResponder 为其 Super ViewController 最表层的 View;如果没有,那么它的 nextResponder 就是 UIWindow;
- UIWindow 的 contentView 指向 UIApplication,将其作为 nextResponder;
-
UIApplication 是 个响应者链的终点,它的 nextResponder 指向nil,整个
responder chain 结束。
//核心代码如下:
PJBtn.h
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface PJBtn : UIButton
PJBtn.m
#import "PJBtn.h"
@implementation PJBtn
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.userInteractionEnabled || [self isHidden] || self.alpha <= 0.01) {
return nil;
//判断当前视图是否在点击范围内
if ([self pointInside:point withEvent:event]) {
//遍历当前对象的子视图(倒序)
__block UIView *hit = nil;
[self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
//坐标转换
CGPoint convertPoint = [self convertPoint:point toView:obj];
//调用子视图的hitTest方法
hit = [obj hitTest:convertPoint withEvent:event];
//如果找到了就停止遍历
if (hit) *stop = YES;
//返回当前的视图对象
return hit?hit:self;
}else {
return nil;
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
CGFloat x1 = point.x;
CGFloat y1 = point.y;
CGFloat x2 = self.frame.size.width / 2;
CGFloat y2 = self.frame.size.height / 2;
//判断是否在圆形区域内
double dis = sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
if (dis <= self.frame.size.width / 2) {
return YES;
else{
return NO;
ViewController.m
#import "ViewController.h"
#import "PJBtn.h"
@interface ViewController () {
PJBtn *fangxingBtn;
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
fangxingBtn = [[PJBtn alloc] initWithFrame:CGRectMake(200, 200, 100, 100)];
fangxingBtn.backgroundColor = [UIColor blueColor];
[fangxingBtn addTarget:self action:@selector(pjBtnClick) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:cornerBtn];
- (void)pjBtnClick {
NSLog(@"你点到了圆形区域");
}
相关实战技术
:
1.事件的传递方向: 事件传递是从上自下传递,响应是从下到上,所谓的上就是父视图而已,也就是离窗口最近的.
2.穿透控件:
2.1 如果我们不想让某个视图响应事件,只需要重载 PointInside:withEvent:方法,让此方法返回NO就行了.
2.2 若是view上有view1,view1上有view2,点击view2,view2自己响应,点击view1,view1不响应,只有view响应,也就是隔层传递
/*
重载view1的此方法,如果点在自己身上,且子控件中有最合适的响应者,就返回对应子控件,否则就不响应,并将该事件随着响应者链条往回传递,交给上一个响应者来处理. (即调用super的touches方法)
谁是上一个响应者?
1. 如果view的控制器存在,就传递给控制器;如果控制器不存在,则将其传递给它的父视图
2. 在视图层次结构的最顶级视图,如果也不能处理收到的事件或消息,则其将事件传递给window对象进行处理
3. 如果window对象也不处理,则其将事件或消息传递给UIApplication对象
4. 如果UIApplication也不能处理该事件或消息,则将其丢弃
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event{
CGRect frame = CGRectMake(0, 0, self.frame.size.width, self.frame.size.height);
BOOL value = (CGRectContainsPoint(frame, point));
NSArray *views = [self subviews];
for (UIView *subview in views) {
value = (CGRectContainsPoint(subview.frame, point));