iOS - 事件的传递与响应
前言
最近新项目需要用到自定义Tabbar按钮样式,如下图所示的需求,就需要用到hitTest等相关的一些点击事件处理知识点。本文只进行浅浅的探究,记录下在实现过程中涉及到的东西。
![](https://img.haomeiwen.com/i4469396/efa9b59abc75db44.jpeg)
传递链
在 UIKit 中我们使用响应者对象(Responder)接收和处理事件。一个响应者对象一般是 UIResponder
类的实例,它常见的子类包括 UIView
,UIViewController
和 UIApplication
,这意味着几乎所有我们日常使用的控件都是响应者,如 UIButton
,UILabel
等等。
在 UIResponder
及其子类中,我们是通过重写相关触摸(UITouch)的方法来处理和传递事件(UIEvent):
- (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;
当有人触摸屏幕时,我们需要找出最适合对此次触摸事件做出响应的控件,也就是寻找到第一响应者
,最终的响应者追溯祖先路径中的每个类组成的链就是所谓的传递链
。
在触摸发生后,UIApplication
会触发 func sendEvent(_ event: UIEvent) 将一个封装好的 UIEvent 传给 UIWindow
,也就是当前展示的 UIWindow,通常情况接下来会传给当前展示的 UIViewController
,接下来传给 UIViewController 的根视图UIView
,如果UIView还有子View
的话,会循环遍历所有子View,直到找到最适合响应此次触摸事件的响应者(UIResponder实例)。
![](https://img.haomeiwen.com/i4469396/994722ec3a9c80d1.png)
如上图对应的传递链:UIApplication
-->UIWindow
-->UIViewController
-->UIViewController.view
->SuperView
-->View
...
响应链
在找到合适的视图控件后,就会调用视图控件的 touches 方法来作事件的具体处理:
touchesBegin,touchesMoved,touchesEnded
等。如果找到了最合适的响应者,但是如果其没有实现touches方法,就会调用其上一个响应者对象的touches方法,依次往前推。
如果父视图在不具备响应事件的条件下,子视图也不能响应当前事件:
-
view.userInteractionEnabled = NO
; -
view.hidden = YES
; -
view.alpha < 0.05
; -
view 超出 superView 的 bounds
;
事件的响应顺序刚好与事件的传递顺序 相反:View
-->SuperView
-->UIViewController.view
-->UIViewController
-->UIWindow
->UIApplication
-->事件丢失
也就是说如果最终UIApplication还处理不了,则该事件将会被丢弃。
![](https://img.haomeiwen.com/i4469396/cedcd9cfbf41a8a1.png)
hitTest
那么要找出最适合的view来响应触摸事件,我们就需要用到hitTest: withEvent:
方法。
hitTest: withEvent:
是UIView里面的一个方法,该方法的作用在于:在视图的层次结构中寻找一个最适合的view来响应触摸时间
。该方法如果返回nil,即事件有可能被丢弃,否则返回最适合的那个view。
下面我们通过几个例子来帮助我们理解hitTest的追溯过程:
![](https://img.haomeiwen.com/i4469396/a5145d4bfd531266.png)
如上图,把蓝色layer添加到红色layer(RedView实例.layer)上,通过点击蓝色区域和红色区域,看看响应者是谁:
self.blueLayer = [CALayer layer];
self.blueLayer.frame = CGRectMake(0, 0, 100, 100);
self.blueLayer.backgroundColor = [UIColor blueColor].CGColor;
[self.redLayer.layer addSublayer:self.blueLayer];
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
//1.get touchu position relative to main view(获取相对于主视图的触摸位置)
CGPoint point = [[touches anyObject]locationInView:self.view];
//2.get touched layer
CALayer *layer = [self.layerView.layer hitTest:point];
//3.get layer using using hitTest
if(layer == self.blueLayer)
{
NSLog(@"Inside Blue layer");
}else if(layer == self.layerView.layer)
{
NSLog(@"Inside Red layer");
}
}
#import "RedView.h"
@implementation RedView
- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
// 如果交互未打开,或者透明度小于0.05 或者 视图被隐藏
if (self.userInteractionEnabled == NO || self.alpha < 0.05 || self.hidden == YES)
{
return nil;
}
// 如果 touch 的point 在 self 的bounds 内
if ([self pointInside:point withEvent:event])
{
for (UIView *subView in self.subviews)
{
//进行坐标转化
CGPoint coverPoint = [subView convertPoint:point fromView:self];
// 调用子视图的 hitTest 重复上面的步骤。找到了,返回hitTest view ,没找到返回有自身处理
UIView *hitTestView = [subView hitTest:coverPoint withEvent:event];
if (hitTestView)
{
return hitTestView;
}
}
return self;
}
return nil;
}
运行,分别点击蓝色和红色区域,打印结果:
![](https://img.haomeiwen.com/i4469396/5c4cec1df98c7e14.png)
思路讲解:
- 首先在当前视图的
hitTest
方法中调用pointInside
方法判断触摸点是否在当前视图内 - 若pointInside方法返回
NO
,说明触摸点不在当前视图内,则当前视图的hitTest返回nil
,该视图不处理该事件 - 若pointInside方法返回
YES
,说明触摸点在当前视图内,则从最上层子视图开始(即从subviews数组的末尾向前遍历),遍历当前视图的所有子视图,调用子视图的hitTest方法重复步骤1~3
- 直到有子视图的hitTest方法返回非空对象或者全部子视图遍历完毕
- 若第一次有子视图的hitTest方法返回非空对象,则当前视图的hitTest方法就返回此对象,处理结束
- 若所有子视图的hitTest方法都返回nil,则当前视图的hitTest方法返回当前视图本身,最终由
该对象处理
触摸事件
项目例子
我的项目中如前言部分的图片所示,需要自定义一个大按钮的tabbar
,关键代码如下:
-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
if (self.isHidden == NO) {
CGPoint newA = [self convertPoint:point toView:self.middleView];
if ([self.circleBtn pointInside:newA withEvent:event]) {
return _circleBtn;
}
}
return [super hitTest:point withEvent:event];
}
在自定义的UITabBar
子类中,重写hitTest
方法,然后将触摸点的坐标转换到大按钮上,如果pointInside
方法返回YES
,则当前点击区域为大按钮内。
项目中还用到了为一个按钮增大点击范围,比如有时候按钮很小,点击范围小,所以为了提升用户体验感,需要在按钮周围增加一个点击范围来触发。所以在UIButton
的分类中添加增大方法setEnlargeEdge
:
static char topNameKey;
static char rightNameKey;
static char bottomNameKey;
static char leftNameKey;
- (void)setEnlargeEdge:(CGFloat)size
{
objc_setAssociatedObject(self, &topNameKey, [NSNumber numberWithFloat:size], OBJC_ASSOCIATION_COPY_NONATOMIC);
objc_setAssociatedObject(self, &rightNameKey, [NSNumber numberWithFloat:size], OBJC_ASSOCIATION_COPY_NONATOMIC);
objc_setAssociatedObject(self, &bottomNameKey, [NSNumber numberWithFloat:size], OBJC_ASSOCIATION_COPY_NONATOMIC);
objc_setAssociatedObject(self, &leftNameKey, [NSNumber numberWithFloat:size], OBJC_ASSOCIATION_COPY_NONATOMIC);
}
- (CGRect)enlargedRect
{
NSNumber* topEdge = objc_getAssociatedObject(self, &topNameKey);
NSNumber* rightEdge = objc_getAssociatedObject(self, &rightNameKey);
NSNumber* bottomEdge = objc_getAssociatedObject(self, &bottomNameKey);
NSNumber* leftEdge = objc_getAssociatedObject(self, &leftNameKey);
if (topEdge && rightEdge && bottomEdge && leftEdge)
{
return CGRectMake(self.bounds.origin.x - leftEdge.floatValue,
self.bounds.origin.y - topEdge.floatValue,
self.bounds.size.width + leftEdge.floatValue + rightEdge.floatValue,
self.bounds.size.height + topEdge.floatValue + bottomEdge.floatValue);
}
else
{
return self.bounds;
}
}
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent*)event
{
CGRect rect = [self enlargedRect];
if (CGRectEqualToRect(rect, self.bounds))
{
return [super hitTest:point withEvent:event];
}
return CGRectContainsPoint(rect, point) ? self : nil;
}
其中,topNameKey
,rightNameKey
,bottomNameKey
,leftNameKey
为静态变量,外部调用setEnlargeEdge
方法即可。
事件处理就写到这里,不足之处望海涵,也可在评论区与我分享你的观点。洗洗睡了~