1.响应者
UIResponder,用来响应用户的操作并处理各种事件:
继承自UIResponder
的类对象,如UIView
、UIViewController
、UIApplication
,才能接收和处理事件,它们被称为“响应者对象”。UIResponder.h
中定义了如下处理方法:
1 2 3 4 5 6 7 8 9 10 11 12 13
| - (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*)ev
- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent*)event; - (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent*)event; - (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent*)event;
- (void)remoteControlReceivedWithEvent:(UIEvent*)event;
|
2.响应者链
下面是在自定义视图CustomView
的-hitTest:withEvent:
方法中断点后,随机在界面中点击后得到的堆栈信息:
调用顺序为底部先顶部后,大致经过了main
->UIApplication
->UIWindow
->UIView
。
用户在屏幕上做点击、缩放等操作时会触发触摸事件。UIKit会创建一个UIEvent事件对象并将其添加到事件队列中,后续由 UIApplication 负责事件的分发和传递,直到找到其最佳的响应者。
查找事件响应者的过程就是Hit-Testing
的过程,被检测者需要做两个判断:
具体查找过程如下:
- UIApplication 从事件队列中取出最前面的事件,传递给
主窗口
(keyWindow);
- 主窗口检测:①自己是否能接收事件;②触摸点是否在自己身上;
- 若满足此两个条件,再
倒序
遍历主窗口的子视图数组,对子视图重复以上两个判断;
- 若主窗口的子视图中没有符合条件的,则主窗口成为触摸事件的最佳接收者;
- 若主窗口的子视图
A
符合条件,则倒序遍历A
的子视图,并重复上面的两个判断;
- 若
A
的子视图中没有符合条件的,则A
成为最佳接收视图;
- 若
A
的子视图中有符合条件的X
,则继续遍历X的子视图,直到找到最合适的视图;
不能接收事件的情况
- userInteractionEnabled = NO;
- hidden = YES; (注:父视图隐藏时,子视图也会隐藏,不能接收事件)
- alpha < 0.01;
触摸点范围检测
1
| - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
|
返回YES,则触摸点在当前 view 上。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { if (!self.userInteractionEnabled || self.hidden == YES || self.alpha <= 0.01){ return nil; } if (![self pointInside:point withEvent:event]){ 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; }
|
触摸事件传递给视图A
后,会触发A的hitTest:
方法。
若此方法返回 nil,则A
和其子视图都不是响应者,响应为A
的父视图。
响应者链是从父视图到子视图
,若父视图不能响应事件,则子视图也就不能响应。
找到最合适的视图后会触发视图的-touches
方法,开始向上(从子视图到父视图)传递事件。
2.1.应用-事件的拦截
正因为hitTest:
可以返回响应者视图,所以想让谁成为响应者就重写谁父视图的hitTest:
方法返回指定的子视图;或者重写自己的hitTest:
方法并返回self
。
一般来说,建议在父视图的hitTest:
中返回子视图作为最佳响应者!
案例1-指定响应视图
默认情况下 label 的userInteractionEnabled
为 NO,即 label 不能接收触摸事件,接下来的示例还是以3.2小节中的树形结构为例,要让 label 响应触摸事件。可能你会觉得这个功能有点鸡肋,是的,这里我只是想演示一下如何让指定视图响应触摸或点击事件。
思路:自定义蓝色视图和label,重写蓝色视图的hitTest
方法,当触摸点在其子视图 label 的范围内时,返回此 label,这样触摸事件就转发给 label 并触发 label 的-touch:
系列方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| #import "ASDFBlueView.h"
@implementation ASDFBlueView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{ if (!self.userInteractionEnabled || self.hidden == YES || self.alpha <= 0.01){ return nil; } if (![self pointInside:point withEvent:event]){ 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]; if ([childView isKindOfClass:NSClassFromString(@"ASDFResponsibleLabel")] && [childView pointInside:childP withEvent:event]) { return childView; } UIView *fitView = [childView hitTest:childP withEvent:event]; if (fitView) { return fitView; } } return self; }
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{ NSLog(@"++Touched ASDFBlueView~"); } @end
|
- 自定义的 ASDFResponsibleLabel
1 2 3 4 5 6 7 8
| #import "ASDFBlueView.h"
@implementation ASDFBlueView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{ NSLog(@"++Touched ASDFBlueView~"); } @end
|
1 2 3
| - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{ NSLog(@"++Touch began in ViewController~"); }
|
测试结果:
- 如果只点击橙色或白色区域,则只会触发
ViewController
中的-touch:
方法;
- 如果点击 label 以外的蓝色区域,则只会触发
ASDFBlueView
中的-touch:
方法;
- 如果点击 label,则 label 会响应点击并触发其
-touch:
方法;
延伸:
1、按照上面的思路,如果点击任何地方都只让当前ViewController
响应事件,则重写其所有子视图的-hitTest
方法并都返回nil
即可;
2、如果既想 label 响应事件,又想蓝色视图响应事件,只需在 label 的-touch:
方法中调用[super touch..]
即可。
案例2-抢红包
需求:红包按照某种轨迹飘落,点到飘落中的红包才算抢到。
思路分析:
- 飘落中的红包实则是一个执行了
position
动画的视图;
- 对于静止的红包,判断点击是否命中时只需在响应链将事件传递到红包视图时,判断触摸点是否在视图中即可;
- 但红包视图在执行位移动画,其实际位置在动画过程中并未真正改变,对动画中的红包判断触摸点并不可行;
- 红包视图的图层树中,
presentationLayer
保存了动画时 layer 的位置信息,只需判断触摸点是否在此图层中即可~
步骤1:自定义红包视图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| #import "AnimateClickedView.h" @implementation AnimateClickedView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { CGPoint convertedPoint = [self.layer convertPoint:point toLayer:self.layer.presentationLayer]; if ([self.layer.presentationLayer containsPoint:convertedPoint]) { NSLog(@"++++++恭喜您中奖了!"); return self; }else{ NSLog(@"没点中红包,继续加油哦~"); return nil; } } @end
|
步骤2:将红包视图添加到界面中并执行动画
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| #import "AnimateClickedView.h" @interface ViewController () @property (nonatomic, strong) AnimateClickedView *animateView; @end
@implementation ViewController - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated];
_animateView = [[AnimateClickedView alloc] init]; _animateView.backgroundColor = [UIColor redColor]; float size = 50; uint32_t originX = arc4random() % (int)(self.view.frame.size.width - size); _animateView.frame = CGRectMake(originX, -size, size, size); [self.view addSubview:_animateView]; }
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{ if (aInt != 0) { return; } aInt +=1;
UIBezierPath *path = [UIBezierPath bezierPath]; [path moveToPoint:_animateView.center]; CGFloat height = CGRectGetHeight(self.view.frame); [path addCurveToPoint:CGPointMake(_animateView.frame.origin.x, height) controlPoint1:CGPointMake(0, arc4random() % (int)(height / 4.0)) controlPoint2:CGPointMake(CGRectGetWidth(self.view.frame), height - 200)]; CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"position"]; animation.path = path.CGPath; animation.duration = 5; animation.autoreverses = NO; animation.removedOnCompletion = NO; animation.fillMode = kCAFillModeForwards; [_animateView.layer addAnimation:animation forKey:@"HAPPY_NEW_YEAR_ANIMATION"]; } @end
|
步骤3:点击界面中的红包,查看日志信息~
3.事件的传递
事件的响应者确定后,其内部的-touches
系列方法会自动触发并开始处理事件:
1 2 3 4 5 6
| - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesBegan:touches withEvent:event]; }
|
如果视图实现了touches
系列方法,则事件将由该视图来处理;
如果调用了[super touches….]
,则事件还将传递给其下一个响应者;
下一个响应者触发其touches
系列方法(自己处理事件或者接着向上传递);
如此循环下去就构成了事件的传递链
。
3.1. nextResponder
1
| @property(nonatomic, readonly, nullable) UIResponder *nextResponder;
|
这是UIResponder
中的属性,用来查找下一个响应者。事件从第一个响应者开始传递,直到被某个响应者处理时事件才会停止传递;如果事件传到最后都没有被响应,则该事件就被丢弃。
事件传递的过程:
- 若当前
view
是控制器的self.view
,则控制器就是下一个响应者,事件传递给控制器响应;
- 若当前
view
不是控制器的self.view
,则view
的父视图就是下一个响应者,事件就传递给它的父视图响应;
- 在视图层次结构的最顶级视图,如果也不能处理该事件,则该事件会被传递给
window
对象;
- 若
window
对象也不处理,则事件会传递给UIApplication
对象;
- 若
UIApplication
也不能处理该事件,则将其丢弃,本次点击毫无反应。
小结:事件是从子视图往父视图传递。
#示例:
在ViewController
的self.view
(橙色)中有个白色圆角的视图(UIViewInspectable),圆角视图内有个自定义的蓝色视图(ASDFBlueView),蓝色视图中有个label
,如下:
结构图:
1 2 3 4 5
| ViewController |--self.view |--圆角的视图 |--蓝色视图 |--label
|
下面是根据响应者链查找Label
所属ViewController
的使用方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| - (UIViewController *)findSuperControllerForView:(UIView *)view { UIViewController *resultController;
for (UIView *next = view; next; next = [next superview]) { UIResponder *responder = [next nextResponder]; NSLog(@"++++Class:%@",[responder class]); if ([responder isKindOfClass:[UIViewController class]]) { resultController = (UIViewController*)responder; } } NSLog(@"++++Equal Self?:%@",[resultController isEqual:self] ? @"YES" : @"NO"); return resultController; }
|
输出日志:
1 2 3 4 5 6
| ++++ ++++ ++++ ++++ ++++ ++++
|
日志展示了响应者的查找过程,从子视图一直向父视图和UIWindow
及UIApplication
查找。最终找到label
所属的控制器,即当前ViewController
。
3.2.应用-拖拽视图
自定义一个view,并重写下面方法:
1 2 3 4 5 6 7 8 9 10 11
| - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { UITouch *touch = [touches anyObject]; CGPoint curP = [touch locationInView:self]; CGPoint preP = [touch previousLocationInView:self]; CGFloat offsetX = curP.x - preP.x; CGFloat offsetY = curP.y - preP.y; self.transform = CGAffineTransformTranslate(self.transform, offsetX, offsetY); }
|
4.小结
- 事件的响应者链是从上到下(父视图到子视图,子视图数组内从后往前)。
- 事件的传递链是从下到上(顺着响应者链条向上传递:子视图到父视图)。