iOS事件机制

前言

  按照时间顺序,事件的生命周期包括事件的产生和传递(事件从父控件传递到子控件并找到合适的view)和事件的处理(对于事件的响应).其中的重点是:

  • 1、触摸事件由触屏生成后如何传递到当前应用.
  • 2、应用接收触摸事件后如何寻找最佳响应者.
  • 3、响应事件如何沿着响应链流动.
  • 4、响应链(UIResponder),手势识别器(UIGesture),UIControl之间的关系.

  当用户触碰了屏幕后,整个事件的传递和响应的流程基本如下:
1、点击到屏幕上的某一点被封装成为触摸事件添加到UIApplication对象的事件队列中.这个队列按照fifo的顺序执行其中的触摸事件.事件出队列时,UIApplication开始寻找一个最佳响应者,过程称为这个过程称为hit-testing.
2、当找到一个最佳响应者后,接下来是事件的传递和响应.事件除了被最佳响应者消耗,还能被手势识别器或者targte-action模式捕捉或者消耗.
3、触摸事件要么会被某个响应对象捕获后释放,要么没有找到能够响应的对象,最终被释放.

触摸,事件,响应者

触摸–UITouch

  当手指触摸屏幕时,会生成一个UItouch对象,如果多个手指同时触摸,会生成多个对象.如果两个手指一前一后触摸同一位置(双击),那么第一次触摸生成UITouch对象,第二次触摸更新这个对象,UItouch对象的tapCount从1变为2. 如果两个手指一前一后触摸位置不同,将生成两个UITouch对象,两者之间没有联系.每一个UITouch对象记录了触摸的一些信息,包括触摸事件,位置,阶段,所处的视图,窗口等信息.

UIEvent

  一个触摸事件对应一个UIEvent对象,其中的type属性标识了事件的类型(事件类型不只有触摸类型).UIEvent对象中包含了触发该事件的触摸对象集合,因为一个触摸事件可能包含多个UITouch对象.

UIResponder

  每一个响应者都是一个UIResponder对象或者是其子类.本身都具有响应事件的能力,
1、UIView
2、UIViewcontroller
3、UIApplication
4、Appdelegate
响应者之所以能响应事件,是因为UIResponder提供了如下四个方法

1
2
3
4
5
6
7
8
//手指触碰屏幕,触摸开始
- (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;

这些方法会在响应者对象接收到事件的时候调用,用于对事件做出响应.

寻找事件的最佳响应者

  当触摸事件产生时,因为触摸位置所在的视图不止一个,需要找到一个最适合响应的视图,这个过程叫做hit-testing,命中的最佳响应者称为hit-tested view.
1、应用程序收到事件后,如何寻找最佳响应者.2、寻找到最佳响应者过程中事件的拦截

寻找最佳响应者-自上而下

  1、队列中的事件出队列后,application将事件传递给当前展示的window,2、如果window能响应事件,则传递给子视图.3、子视图如果能响应,则继续往下寻找,不能则传递给同级视图往下寻找.4、如果当前视图没有能响应事件的子视图了,则该视图就是最佳响应者.

1
UIApplication->window->view->...->view

需要注意的一点是,当寻找当前视图的子视图时,是从后(后添加)往前(先添加)寻找,判断是否可以响应.因为后添加的视图总是位于更上层.

如何判断视图是否响应事件

  1、不允许交互:userInteractionEnabled = NO;2、视图隐藏:如果父视图hidden = yes,那么子视图也会隐藏,隐藏的视图无法接受事件.3、透明度:如果视图的alpha<0.01,那么会认为视图透明,无法响应事件.

hitTest:withEvent:

  hitTest:withEvent是每个view都可以响应的方法,如果当前视图无法响应事件,那么返回nil.如果当前视图可以响应事件,但是无子视图可以响应事件,则返回自身作为当前视图层级中事件响应者.如果当前视图可以响应,并且子视图也可以响应,则返回子视图中的响应者.
  UIApplication响应hitTest:withEvent方法,传递UIWindow判断是否可以响应事件,如果可以,在调用UIWindow的子视图的hitTest:withEvent方法.最终返回一个结果给UIApplication.下面是hitTest:withEvent方法的大致实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
//3种状态无法响应事件
if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
//触摸点若不在当前视图上则无法响应事件
if ([self pointInside:point withEvent:event] == NO) return nil;
//从后往前遍历子视图数组
int count = (int)self.subviews.count;
for (int i = count - 1; i >= 0; i--)
{
// 获取子视图
UIView *childView = self.subviews[i];
// 坐标系的转换,把触摸点在当前视图上坐标转换为在子视图上的坐标
CGPoint childP = [self convertPoint:point toView:childView];
//询问子视图层级中的最佳响应视图
UIView *fitView = [childView hitTest:childP withEvent:event];
if (fitView)
{
//如果子视图中有更合适的就返回
return fitView;
}
}
//没有在子视图中找到更合适的响应视图,那么自身就是最合适的
return self;
}

其中pointInside:withEvent方法,判断如果坐标在自身坐标范围内则返回true,否则返回false.
  对于如下视图,点击图中的view查看效果

views

当点击view4的时候,可以看到其中的hitview的传递过程和响应链的传递过程

consequens
  因为是从最上层的view开始遍历,因此直接找到了view4,找到之后,开始事件的响应链传递,一层层传递到了application中.

hit—testing过程中的事件拦截

  实际开发时会遇到一些特殊的交互需求,例如对tabbar中建的按钮,对于超过tabbar的部分点击是没有响应的,
tabbar

  分析途中的事件触摸事件传递过程 window-rootview-tabbar-circlbutton,如果点击位置在tabbar内部,事件是可以传递到circlebutton的,但是点击在tabbar外部时,事件传递到tabbar后,因为点击区域在其之外,所以hit-testing返回了nil,认为其不可以响应事件,传递到tabbar的之外的视图上.
  这个问题的解决方案是,当点击圆圈上部时,当事件传递到tabbar,pointInside:方法返回了no,因此,可以重写tabbar的pointInside:方法,如果触摸点在圆圈内部则返回true,否则才返回no,这样才可以让事件传递到源泉内部.

1
2
3
4
5
6
7
8
9
10
11
12
//TabBar
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
//将触摸点坐标转换到在CircleButton上的坐标
CGPoint pointTemp = [self convertPoint:point toView:_CircleButton];
//若触摸点在CricleButton上则返回YES
if ([_CircleButton pointInside:pointTemp withEvent:event]) {
return YES;
}
//否则返回默认的操作
return [super pointInside:point withEvent:event];
}

事件的响应及在响应链中传递

  确定了最佳响应者之后,同时事件(UIevent)也会从UIwindow一起传递到视图上.通过重写UIResponder的触摸方法,可以实现截获对事件的处理,实现对视图的拖动和画图等功能.

  响应链,响应者对于触摸事件的拦截和传递通过touchesBegan:方法控制,该方法,默认是将响应链向上进行传递.如果进行重写,可以做如下处理:

  • 不拦截,继续往下传递
  • 拦截,自行处理事件,但是不在继续往下传递.
  • 拦截,自行处理事件,同时继续将事件往下传递.

  事件向下继续传递通过super调用方法实现.向下传递是将事件传递给nextResponder, 对于UIView,nextResponder是其父视图,对于UIViewcontroller,nextResponder是presenting view controller或者UIWindow.UIWindow的nextResponder为UIApplication对象。
在touchBegan方法中调用如下方法可以查看nextResponder

1
2
3
4
5
6
7
8
9
- (void)printResponderChain
{
UIResponder *responder = self;
printf("%s",[NSStringFromClass([responder class]) UTF8String]);
while (responder.nextResponder) {
responder = responder.nextResponder;
printf(" --> %s",[NSStringFromClass([responder class]) UTF8String]);
}
}

UIResponder,UIgestureRecognier, UIControl

  除了responder,手势和UIControl都可以处理事件,当他们同时出现时,会有什么结果呢?

几个结论

  1、手势响应优先级最高(UIgestureRecognier),点击事件响应链优先级较低.很多的手势冲突都是事件的优先级没有捋清.
2、单击事件优先传递给手势响应,如果手势识别成功,那么就取消事件响应链的传递.
3、如果手势识别失败,那么触摸事件就会继续在响应链中传递下去.

事件的几个概念

1、UITouch,当一根手指触摸屏幕时,会创建一个与之关联的UITouch对象.手指离开屏幕时对象销毁.
2、UIEvent,一个UIEvent对象对应多个UITouch对象,从第一个手指触摸屏幕到最后一个手指离开屏幕.
3、UIResponder,所有继承了UIResponder的对象都可以接受并处理事件.

手势识别和事件响应混用

对于如下界面
conflict
backView添加手势,同时增加事件传递的touchBegan方法,btn增加增加target-action,testView增加事件传递的Touch方法.

1、点击backView:
backclick

2、点击testView
testclick

3、点击按钮
btnclick

  对于1,2手势和响应链都触发了,但是最后响应链cancel了,说明触摸对象被取消,只有手势(gesture)继续执行了.因此,在手势和事件响应链同时存在时,识别出了手势就会将触摸对象取消.

  对于场景3,默认控制操作可以防止重叠的手势识别器,对于按钮的默认操作是点击,因此会忽略掉手势动作.

  综上所述,当一种交互操作识别成功时,另外一种交互操作就会被cancel,不会再往下传递.

实际开发中遇到的问题

  实际开发中,为了不让父视图手势识别干扰子视图的点击事件响应和响应链的传递,一般重写UIGestureRecognizerDelegate中的gestureRecognizer:方法

1
2
3
4
5
6
7
8
9
10
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch  
{
// 若为UITableViewCellContentView(即点击了tableViewCell),
if ([NSStringFromClass([touch.view class]) isEqualToString:@"UITableViewCellContentView"]) {
// cell 不需要响应 父视图的手势,保证didselect 可以正常
return NO;
}
//默认都需要响应
return YES;
}

  参考demo,和多线程demo放在一起了
iOS事件处理demo

  参考文章

iOS触摸事件全家桶

iOS点击事件和手势冲突

史上最详细的iOS事件传递和响应