IOS开发系列——UIView专题之四:事件分发机制篇

4事件分发机制

iOS中的事件大概分为三种,分别是Milti-Touch Events, Motion Events和Remote Control Events(events for controlling multimedia)。

4.1hitTest

iOS事件分发机制(一)hit-Testing

http://suenblog.duapp.com/blog/100031/iOS事件分发机制(一)%20hit-Testing

4.1.1事件检测原理

每当我们点击了一下iOS设备的屏幕,UIKit就会生成一个事件对象UIEvent,然后会把这个Event分发给当前active的app(官方原文说:Then it places the event object in the active app’s event queue.)

告知当前活动的app有事件之后,UIApplication单例就会从事件队列中去取最新的事件,然后分发给能够处理该事件的对象。UIApplication获取到Event之后,Application就纠结于到底要把这个事件传递给谁,这时候就要依靠HitTest来决定了。

iOS中,hit-Testing的作用就是找出这个触摸点下面的View是什么,HitTest会检测这个点击的点是不是发生在这个View上,如果是的话,就会去遍历这个View的subviews,直到找到最小的能够处理事件的view,如果整了一圈没找到能够处理的view,则返回自身。来一个简单的图说明一下

假设我们现在点击到了图中的E,hit-testing将进行如下步骤的检测(不包含重写hit-test并且返回非默认View的情况)

1、触摸点在ViewA内,所以检查ViewA的Subview B、C;

2、触摸点不在ViewB内,触摸点在ViewC内部,所以检查ViewC的Subview D、E;

3、触摸点不在ViewD内,触摸点发生在ViewE内部,并且ViewE没有subview,所以ViewE属于ViewA中包含这个点的最小单位,所以ViewE变成了该次触摸事件的hit-Test View;

4.1.2注意点

1、默认的hit-testing顺序是按照UIView中Subviews的逆顺序;

2、如果View的同级别Subview中有重叠的部分,则优先检查顶部的Subview,如果顶部的Subview返回nil,再检查底部的Subview;

3、Hit-Test也是比较聪明的,检测过程中有这么一点,就是说如果点击没有发生在某View中,那么该事件就不可能发生在View的Subview中,所以检测过程中发现该事件不在ViewB内,也直接就不会检测在不在ViewF内。也就是说,如果你的Subview设置了clipsToBounds=NO,实际显示区域可能超出了superView的frame,你点击超出的部分,是不会处理你的事件的,就是这么任性!

4.1.3事件检测实现

Hit-Test的检查机制如上所示,当确定了Hit-TestView时,如果当前的application没有忽略触摸事件(UIApplication:isIgnoringInteractionEvents),则application就会去分发事件(sendEvent:->keywindow:sendEvent:)。

UIView中提供两个方法用来确定hit-testing View,如下所示

-(UIView*)hitTest:(CGPoint)pointwithEvent:(UIEvent*)event;

// recursively calls-pointInside:withEvent:. point is in the receiver's coordinate system

-(BOOL)pointInside:(CGPoint)pointwithEvent:(UIEvent *)event;

// default returns YES if point is inbounds

当一个View收到hitTest消息时,会调用自己的pointInside:withEvent:方法,如果pointInside返回YES,则表明触摸事件发生在我自己内部,则会遍历自己的所有Subview去寻找最小单位(没有任何子view)的UIView,如果当前View.userInteractionEnabled = NO, enabled=NO(UIControl),或者alpha<=0.01, hidden等情况的时候,hitTest就不会调用自己的pointInside了,直接返回nil,然后系统就回去遍历兄弟节点。简而言之,可以写成这样

[st_hitTest:withEvent:]

- (UIView*)hitTest:(CGPoint)pointwithEvent:(UIEvent*)event{

if(self.alpha<=0.01||!self.userInteractionEnabled||self.hidden) {

returnnil;

}

BOOLinside=[selfpointInside:pointwithEvent:event];

UIView*hitView=nil;

if(inside) {

NSEnumerator*enumerator=[self.subviewsreverseObjectEnumerator];

for(UIView*subviewinenumerator) {

hitView=[subviewhitTest:pointwithEvent:event];

if(hitView) {

break;

}

}

if(!hitView) {

hitView=self;

}

returnhitView;

}else{

returnnil;

}

}

hit-Test是事件分发的第一步,就算你的app忽略了事件,也会发生hit-Test。确定了hit-TestView之后,才会开始进行下一步的事件分发。

我们可以利用hit-Test做一些事情,比如我们点击了ViewA,我们想让ViewB响应,这个时候,我们只需要重写View's

hitTest方法,返回ViewB就可以了,虽然可能用不到,但是偶尔还是会用到的。大概代码如下:

[STPView]

@interfaceSTPView: UIView

@end

@implementationSTPView

- (instancetype)initWithFrame:(CGRect)frame{

self=[superinitWithFrame:frame];

if(self) {

UIButton*button=[UIButtonbuttonWithType:UIButtonTypeCustom];

button.frame=CGRectMake(0,0,CGRectGetWidth(frame),CGRectGetHeight(frame)/2);

button.tag=10001;

button.backgroundColor=[UIColorgrayColor];

[buttonsetTitle:@"Button1"forState:UIControlStateNormal];

[selfaddSubview:button];

[buttonaddTarget:selfaction:@selector(_buttonActionFired:)forControlEvents:UIControlEventTouchDown];

UIButton*button2=[UIButtonbuttonWithType:UIButtonTypeCustom];

button2.frame=CGRectMake(0,CGRectGetHeight(frame)/2,CGRectGetWidth(frame),CGRectGetHeight(frame)/2);

button2.tag=10002;

button2.backgroundColor=[UIColordarkGrayColor];

[button2setTitle:@"Button2"forState:UIControlStateNormal];

[selfaddSubview:button2];

[button2addTarget:selfaction:@selector(_buttonActionFired:)forControlEvents:UIControlEventTouchDown];

}

returnself;

}

- (void)_buttonActionFired:(UIButton*)button{

NSLog(@"=====Button Titled %@ ActionFired

", [buttontitleForState:UIControlStateNormal]);

}

- (UIView*)hitTest:(CGPoint)pointwithEvent:(UIEvent*)event{

UIView*hitView=[superhitTest:pointwithEvent:event];

if(hitView==[selfviewWithTag:10001]) {

return[selfviewWithTag:10002];

}

returnhitView;

}

@end

4.1.4利用catalog实现hitTest

来自STKit,这个category的目的就是方便的编写hitTest方法,由于hitTest方法是override,而不是delegate,所以使用默认的实现方式就比较麻烦。Category如下

[UIView+HitTest.h]

/**

* @abstract hitTestBlock

*

* @param其余参数参考UIView hitTest:withEvent:

* @param returnSuper是否返回Super的值。

*如果*returnSuper=YES,则代表会返回super hitTest:withEvent:,否则则按照block的返回值(即使是nil)

*

* @discussion切记,千万不要在这个block中调用self hitTest:withPoint,否则则会造成递归调用。

*这个方法就是hitTest:withEvent的一个代替。

*/

typedefUIView*(^STHitTestViewBlock)(CGPointpoint, UIEvent*event,BOOL*returnSuper);

typedefBOOL(^STPointInsideBlock)(CGPointpoint, UIEvent*event,BOOL*returnSuper);

@interfaceUIView(STHitTest)

/// althought this is strong ,but i deal it with copy

@property(nonatomic,strong)STHitTestViewBlockhitTestBlock;

@property(nonatomic,strong)STPointInsideBlockpointInsideBlock;

@end

[UIView+HitTest.m]

@implementationUIView(STHitTest)

conststaticNSString*STHitTestViewBlockKey=@"STHitTestViewBlockKey";

conststaticNSString*STPointInsideBlockKey=@"STPointInsideBlockKey";

+ (void)load{

method_exchangeImplementations(class_getInstanceMethod(self,@selector(hitTest:withEvent:)),

class_getInstanceMethod(self,@selector(st_hitTest:withEvent:)));

method_exchangeImplementations(class_getInstanceMethod(self,@selector(pointInside:withEvent:)),

class_getInstanceMethod(self,@selector(st_pointInside:withEvent:)));

}

- (UIView*)st_hitTest:(CGPoint)pointwithEvent:(UIEvent*)event{

NSMutableString*spaces=[NSMutableStringstringWithCapacity:20];

UIView*superView=self.superview;

while(superView) {

[spacesappendString:@"----"];

superView=superView.superview;

}

NSLog(@"%@%@:[hitTest:withEvent:]",spaces,NSStringFromClass(self.class));

UIView*deliveredView=nil;

//如果有hitTestBlock的实现,则调用block

if(self.hitTestBlock) {

BOOLreturnSuper=NO;

deliveredView=self.hitTestBlock(point,event,&returnSuper);

if(returnSuper) {

deliveredView=[selfst_hitTest:pointwithEvent:event];

}

}else{

deliveredView=[selfst_hitTest:pointwithEvent:event];

}

//NSLog(@"%@%@:[hitTest:withEvent:] Result:%@", spaces,  NSStringFromClass(self.class), NSStringFromClass(deliveredView.class));

returndeliveredView;

}

- (BOOL)st_pointInside:(CGPoint)pointwithEvent:(UIEvent*)event{

NSMutableString*spaces=[NSMutableStringstringWithCapacity:20];

UIView*superView=self.superview;

while(superView) {

[spacesappendString:@"----"];

superView=superView.superview;

}

NSLog(@"%@%@:[pointInside:withEvent:]",spaces,NSStringFromClass(self.class));

BOOLpointInside=NO;

if(self.pointInsideBlock) {

BOOLreturnSuper=NO;

pointInside=self.pointInsideBlock(point,event,&returnSuper);

if(returnSuper) {

pointInside=[selfst_pointInside:pointwithEvent:event];

}

}else{

pointInside=[selfst_pointInside:pointwithEvent:event];

}

returnpointInside;

}

- (void)setHitTestBlock:(STHitTestViewBlock)hitTestBlock{

objc_setAssociatedObject(self, (__bridgeconstvoid*)(STHitTestViewBlockKey),

hitTestBlock,OBJC_ASSOCIATION_COPY);

}

- (STHitTestViewBlock)hitTestBlock{

returnobjc_getAssociatedObject(self, (__bridgeconstvoid*)(STHitTestViewBlockKey));

}

- (void)setPointInsideBlock:(STPointInsideBlock)pointInsideBlock{

objc_setAssociatedObject(self, (__bridgeconstvoid*)(STPointInsideBlockKey),

pointInsideBlock,OBJC_ASSOCIATION_COPY);

}

- (STPointInsideBlock)pointInsideBlock{

returnobjc_getAssociatedObject(self, (__bridgeconstvoid*)(STPointInsideBlockKey));

}

@end

代码很简单,就是利用iOS的runtime能力,在hitTest执行之前,插入了一个方法。如果有看不懂的,可以参考我以前的博客iOS面向切面编程

4.2Responder Chain

参考文档:

iOS事件分发机制(二)The ResponderChain

http://suenblog.duapp.com/blog/100032/iOS事件分发机制(二)The%20Responder%20Chain

4.2.1事件传递原理

响应链简单来说,就是一系列的相互关联的对象,从firstResponder开始,到application对象结束,如果firstResponder无法响应事件,则交给nextResponder来处理,直到结束为止。iOS中很多类型的事件分发,都依赖于响应链;在响应链中,所有对象的基类都是UIResponder,也就是说所有能响应事件的类都是UIResponder的子类,UIApplication/ UIView/ UIViewController都是UIResponder的子类,这说明所有的Views,绝大部分Controllers(不用来管理View的Controller除外)都可以响应事件。

PS:CALayer不是UIResponder的子类,这说明CALayer无法响应事件,这也是UIView和CALayer的重要区别之一。

如果找到的hitTestView无法处理这个事件,事件就通过响应链往上传递(hitTestView算是最早的Responder),直到找到一个可以处理的Responder为止。

举个例子,如果触摸通过hitTest确定的是一个View,而这个View没有处理事件,则事件会发送给nextResponder去处理,通常是superView,有关nextResponder的事件传递过程,官方给出了一张很形象的图,如下所示:


PS:View处理事件的方式有手势或者重写touchesEvent方法或者利用系统封装好的组件(UIControls)。

图中所表示的正是nextResponder的查找过程,两种方式分别对应两种app的架构,左边的那种app架构比较简单,只有一个VC,右边的稍微复杂一些,但是寻找路线的原则是一样的,先解释一下,UIResponder本身是不会去存储或者设置nextResponder的,所谓的nextResponder都是子类去实现的(这里说的是UIView,UIViewController,UIApplication),关于nextResponder的值总结如下:

1、UIView的nextResponder是直接管理它的UIViewController(也就是VC.view.nextResponder=VC),如果当前View不是ViewController直接管理的View,则nextResponder是它的superView(view.nextResponder= view.superView)

2、UIViewController的nextResponder是它直接管理的View的superView (VC. nextResponder = VC.view.superView)

3、UIWindow的nextResponder是UIApplication

4、UIApplication的nextResponder是UIApplicationDelegate(官方文档说是nil)

我写了一段代码,打印当前UIResponder的所有nextResponder,大家可以拿去试一下,代码很简单,如下:

[STLogResponderChain]

voidSTLogResponderChain(UIResponder*responder) {

NSLog(@"------------------The

Responder Chain------------------");

NSMutableString*spaces=[NSMutableStringstringWithCapacity:4];

while(responder) {

NSLog(@"%@%@",spaces,responder.class);

responder=responder.nextResponder;

[spacesappendString:@"----"];

}

}

然后我测试了一下,打印的日志如下图所示:

[Log]

UIButton

----STPView

--------UIView

------------STPFeedViewController

----------------UIView

--------------------UIView

------------------------_STWrapperViewController

----------------------------UIView

--------------------------------UIView

------------------------------------STNavigationController

----------------------------------------STPWindow

--------------------------------------------UIApplication

------------------------------------------------STPAppDelegate

这样比较清晰,大家也会直观的看到nextResponder的查找过程。

4.2.2使用示例

接下来我们说正事了,假定我们现在有一个View是hitTestView,命名为STImageView,现在我们想让这个image处理一些事情,比如所有的图片点下之后加一个灰色的效果,我们就把事件分发给它。

在UIResponder中,提供以下几个方法,几个方法分别表示点击的不同状态,大家看名字就能明白差不多:

-

(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;- (void)touchesMoved:(NSSet *)touches

withEvent:(UIEvent *)event;- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;- (void)touchesCancelled:(NSSet *)touches

withEvent:(UIEvent *)event;

如果我们想让我们当前的Responder处理事件,我们则需要重写如下的几个方法。我们的需求是手指按下图片的时候加一个灰色的效果,松开的时候灰色消失。关于灰色的实现,我们暂定用一个View贴在ImageView上named

maskView,然后用hidden来控制是否显示(上一篇文章有说过,所有hidden的View默认不接受任何事件)。

我们需要在touchesBegan方法里面self.maskView.hidden = NO;然后在touchesEnded/ Cancelled里面self.maskView.hidden = YES;就可以实现我们的效果了,原理很简单,我们的hitTestView在事件分发的时候去处理事件,仅此而已。这里注意一下:UIImageView的默认是不接受点击事件的,如果想要实现如上所示效果,需要设置userInteractionEnabled=YES;

说到这里,就有人产生了疑问,如果这么实现的话,那如果本身UIImageView还想让下面的View处理事件该怎么办?会不会把所有的事件拦截下来?这里就说到了另一个问题,UIResponder在知道需要处理事件的时候,还是有决定权的,比如我可以决定让整个响应链继续走下去,或者直接中断掉整个响应链。如果中断了响应链,那么所有在链上的nextResponder都不会得知有事件发生,iOS也提供了这个方法,其实很简单:

我们在重写TouchesEvents的时候,如果不想让响应链继续传递,就不调用super对应的实现就可以了,相反,有些时候你只需要做一个小改变,如上所示,但是你不想中断响应链,你就需要调用父类对应的实现。

这里有一点需要注意,一般来说,我们如果想要自己处理一些事件,我们需要重写如上所示的方法,如果我们想自己处理,就不需要调用super。调用super的目的就是为了把事件传递给nextResponder,并且如果我们在touchesBegan中没有调用super,则super不会响应其他的回掉(touchesMoved/touchesEnded),但是我们需要重写所有如上所示的方法来确保我们的一切正常。touchesBegan和touchesEnded/touchesCancelled一定是成对出现的,这点大家可以放心。

有关触摸事件在响应链上的分发,就差不多这么多东西,最重要的是大家可以看那几个touches方法,多做实验,就可以了解的更加深入。

4.2.3其他要点

这里有一些补充,响应链能够处理很多东西,不仅仅是触摸事件。一般来说,如果我们需要一个对象去处理一个非触摸事件(摇一摇,RemoteControlEvents,调用系统的复制、粘贴框等),我们要确保该对象是UIResponder子类,如果我们要接收到事件的话,我们需要做两件事情

1、重写canBecomeFirstResponder,并且返回YES

2、在需要的时候像该对象发送becomeFirstResponder消息。

我们有时候会遇到一些问题,比如我们重写了motionEvents,但是我们不能收到摇一摇的回调,或者我们的UIMenuController老是不弹出,我们就需要检查一下,我们是否满足了如上所示的条件,而且要确保becomeFirstResponder的发送时机正确。

当然,这个补充对于触摸事件无效,触摸事件的第一响应者是根据hitTest确定而来的,有点绕,需要仔细捋捋。

需要注意的是:

如果你自己想自定义一个非TouchEvent的事件,当需要继续传递事件的话,切记不要在实现内直接显示的调用nextResponder的对应方法, 而是直接调用super对应的方法来让这个事件继续分发到响应链。

到目前为止,事件的分发还没有结束,之后会有一篇文章介绍一个很重要的角色,手势。

最后,附上官方的文档

Event Handling

Guide for iOS

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容