内容简介:当用户的手指在屏幕上的某一点按下时,屏幕接收到点击信号将点击位置转换成具体坐标,然后本次点击被包装成一个点击事件响应者是可以处理事件的具体对象,一个响应者应当是
当用户的手指在屏幕上的某一点按下时,屏幕接收到点击信号将点击位置转换成具体坐标,然后本次点击被包装成一个点击事件 UIEvent
。最终会存在某个视图响应本次事件进行处理,而为 UIEvent
查找响应视图的过程被称为 响应链查找
,在整个过程中有两个至关重要的类: UIResponder
和 UIView
响应者
响应者是可以处理事件的具体对象,一个响应者应当是 UIResponder
或其子类的实例对象。从设计上来看, UIResponder
主要提供了三类接口:
- 向上查询响应者的接口,体现在
nextResponder
这个唯一的接口 - 用户操作的处理接口,包括
touch
、press
和remote
三类事件的处理 - 是否具备处理
action
的能力,以及为其找到target
的能力
总体来说 UIResponder
为整个事件查找过程提供了处理能力
视图
视图是展示在界面上的可视元素,包括不限于 文本
、 按钮
、 图片
等可见样式。虽然 UIResponder
提供了让对象响应事件的处理能力,但拥有处理事件能力的 responder
是无法被用户观察到的,换句话说,用户也无法点击这些有处理能力的对象,因此 UIView
提供了一个可视化的载体,从接口上来看 UIView
提供了三方面的能力:
responder frame layout
视图树的结构如下,由于 UIView
是 UIResponder
的子类,可以通过 nextResponder
访问到父级视图,但由于 responder
并不全是具备可视载体的对象,通过 nextResponder
向上查找的方式可能会导致无法通过位置计算的方式查找响应者
查找响应者
讲了这么多,也该聊聊查找响应者的过程了。前面说了, responder
决定了对象具有响应处理的能力,而 UIView
才是提供了可视载体和点击坐标关联的能力。换句话说,查找响应者实际上是查找点击坐标落点位置在其可视范围内且其具备处理事件能力的对象,按照官方话来说就是既要 responde
又要 view
的对象。因为需要先找到响应者,才能有进一步的处理,所以直接从后者的接口找起,两个 api
:
/// 检测坐标点是否落在当前视图范围内 - (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event; /// 查找响应处理事件的最终视图 - (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; 复制代码
通过 exchange
掉第一个方法的实现,很轻易的就能得出响应者查找的顺序:
- (BOOL)sl_pointInside: (CGPoint)point withEvent: (UIEvent *)event { BOOL res = [self sl_pointInside: point withEvent: event]; if (res) { NSLog(@"[%@ can answer]", self.class); } else { NSLog(@"non answer in %@", self.class); } return res; } 复制代码
如图创建相同的布局结构,然后点击 BView
,得到的日志:
[UIStatusBarWindow can answer] non answer in UIStatusBar non answer in UIStatusBarForegroundView non answer in UIStatusBarServiceItemView non answer in UIStatusBarDataNetworkItemView non answer in UIStatusBarBatteryItemView non answer in UIStatusBarTimeItemView [UIWindow can answer] [UIView can answer] non answer in CView [AView can answer] [BView can answer] 复制代码
通过日志输出可以看出查找顺序优先级有两条:
window window
通过 pointInside:
确定了点击坐标落在哪些视图的范围后,会继续调用另一个方法来找寻真正的响应者。同样 hook
掉这个方法,从日志来看父级视图会调用子视图,最终以递归的格式输出:
- (UIView *)sl_hitTest: (CGPoint)point withEvent: (UIEvent *)event { UIView *res = [self sl_hitTest: point withEvent: event]; NSLog(@"hit view is: %@ and self is: %@", res.class, self.class); return res; } /// 输出日志 hit view is: (null) and self is: CView hit view is: BView and self is: BView hit view is: BView and self is: AView hit view is: BView and self is: UIView hit view is: BView and self is: UIWindow 复制代码
当确定了是 CView
是最后一个落点视图时,会以 CView
为起始点,向上寻找事件的响应者,这个查找过程由一个个 responder
链接起来,这也是 响应链
名字的来由。另外,基于树结构的视图层级,只需要我们持有根节点,就能遍历整棵树,这也是为什么搜索过程是从 window
开始的
响应者处理
经过 pointInside:
和 hitTest:
两个方法会确定点击位置最上方的可视响应者,但并不代表了这个 responder
会处理本次事件。基于上面的界面 demo
,在 AView
中实现 touches
方法:
@implementation AView - (void)touchesBegan: (NSSet<UITouch *> *)touches withEvent: (UIEvent *)event { NSLog(@"A began"); } - (void)touchesCancelled: (NSSet<UITouch *> *)touches withEvent: (UIEvent *)event { NSLog(@"A canceled"); } - (void)touchesEnded: (NSSet<UITouch *> *)touches withEvent: (UIEvent *)event { NSLog(@"A ended"); } @end 复制代码
前面说过 UIResponder
提供了用户操作的处理接口,但很明显 touches
系列的接口默认是 未实现的
,因此 BView
即便成为了响应链上的最上层节点,依旧无法处理点击事件,而是沿着响应链查找响应者:
void handleTouch(UIResponder *responder, NSSet<UITouch *> *touches UIEvent *event) { if (!responder) { return; } if ([responder respondsToSelector: @selector(touchesBegan:withEvent:)]) { [responder touchesBegan: touches withEvent: event]; } else { handleTouch(responder.nextResponder, touches, event); } } 复制代码
另外一个有趣的地方是手势拦截,除了实现 touches
系列方法让 view
提供响应能力之外,我们还可以主动的在视图上添加手势进行回调:
- (void)viewDidLoad { [super viewDidLoad]; [_a addGestureRecognizer: [[UITapGestureRecognizer alloc] initWithTarget: self action: @selector(clickedA:)]]; } - (void)clickedA: (UITapGestureRecognizer *)tap { NSLog(@"gesture clicked A"); } /// 日志输出 A began gesture clicked A A canceled 复制代码
从日志可以看到手势的处理是在 touchesBegan
之后,并且执行之后会中断原有的 touches
调用链,因此可以确定即便是手势动作,最终依旧由 UIResponder
进行事件分发处理
小结
触屏事件的处理被分成两个阶段: 查找响应者
和 响应者处理
,分别由 UIView
和 UIResponder
提供了功能上的支持。另外由于 pointInside:
和 hitTest:
这两个关键接口是对外暴露的,因此通过 hook
或者 inherit
的方式来修改这两个方法,可以使得视图的可响应范围大于显示范围
应用
最近有个需求需要在 tabbar
的位置上方弹出气泡,且允许用户点击气泡发生交互事件。从视图层来分析, tabbar
被嵌套在多层尺寸等同于菜单栏的 view
当中:
如果要实现从 item
弹起气泡并且可交互,有两个可行的方案:
ViewController tabbar
考虑到如果项目中存在相同的弹出需求可能会导致写了一堆重复代码,所以将 弹出动作
和 触屏判断
给封装起来,通过 hook
的方式来实现弹出气泡自动化触屏检测功能
接口设计
依照最少接口原则,只暴露两个弹出接口:
/*! * @enum SLViewDirection * 弹窗视图所在的方向(dismiss和pop应当保持一致) */ typedef NS_ENUM(NSInteger, SLViewDirection) { SLViewDirectionCenter, SLViewDirectionTop, SLViewDirectionLeft, SLViewDirectionBottom, SLViewDirectionRight }; /*! * @category UIView+SLFreedomPop * 自由弹窗扩展 */ @interface UIView (SLFreedomPop) /*! * @method sl_popView: * 居中弹出view * @param view 要弹出的view */ - (void)sl_popView: (UIView *)view; /*! * @method sl_popView:WithDirection: * 控制弹窗方向 * @param view 要弹出的view * @param direction 弹出方向 */ - (void)sl_popView: (UIView *)view withDirection: (SLViewDirection)direction; @end 复制代码
落点检测
考虑到两个问题:
- 视图可能存在多个超出显示范围的子视图
- 视图存在超出父级视图显示范围的子视图
针对这两个问题,解决方案分别是:
- 用视图作
key
,存储一个extraRect
的列表 - 当弹出视图超出自身范围时,向父级视图调用,确保父级视图能处理响应
最终检测代码如下:
#define SLRectOverflow(subrect, rect) \ subrect.origin.x < 0 || \ subrect.origin.y < 0 || \ CGRectGetMaxX(subrect) > CGRectGetWidth(rect) || \ CGRectGetMaxY(subrect) > CGRectGetHeight(rect) #pragma mark - Private - (BOOL)_sl_pointInsideExtraRects: (CGPoint)point { NSArray *extraRects = [self extraHitRects].allValues; if (extraRects.count == 0) { return NO; } for (NSSet *rects in extraRects) { for (NSString *rectStr in rects) { if (CGRectContainsPoint(CGRectFromString(rectStr), point)) { return YES; } } } return NO; } #pragma mark - Rects - (void)_sl_addExtraRect: (CGRect)extraRect inSubview: (UIView *)subview { CGRect curRect = [subview convertRect: extraRect toView: self]; if (SLRectOverflow(curRect, self.frame)) { [self _sl_expandExtraRects: curRect forKey: [NSValue valueWithBytes: &subview objCType: @encode(typeof(subview))]]; [self.superview _sl_addExtraRect: curRect inSubview: self]; } } #pragma mark - Hook - (BOOL)sl_pointInside: (CGPoint)point withEvent: (UIEvent *)event { BOOL res = [self sl_pointInside: point withEvent: event]; if (!res) { return [self _sl_pointInsideExtraRects: point]; } return res; } - (UIView *)sl_hitTest: (CGPoint)point withEvent: (UIEvent *)event { UIView *res = [self sl_hitTest: point withEvent: event]; if (!res) { if ([self _sl_pointInsideExtraRects: point]) { return self; } } return res; } 复制代码
运行效果
红色视图弹出绿色视图,超出自身和父视图的显示范围,点击有效:
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。