文章目录:
推荐的博客:
前言:
在学习该内容之前,大家先了解一下各类之间的继承关系,如下图:
另外我们需要了解一下什么是UIKit框架,UIKit框架提供一系列的Class(类)来建立和管理iPhone OS应用程序的用户界面(UI)接口、应用程序对象、事件控制、绘图模型、窗口、视图和用于控制触摸屏等的接口。(PS1:可以认为是操纵界面的一个API库)
响应链
当iOS捕获到某个事件时,就会将此事件传递给某个看上去最适合处理该事件的对象,比如触摸事件传递给手指刚刚触摸位置的那个视图(view),如果这个对象无法处理该事件,iOS系统就继续将该事件传递给更深层的对象,直到找到能够对该事件作出响应处理的对象为止。这一连串的对象序列被称作为“响应链”(responder chain),iOS系统就是沿着此响应链,由最外层逐步向内存对象传递该事件,亦即将处理该事件的责任进行传递。 iOS的这种机制,使得事件处理具有协调性和动态性。
下图就是一个最常见的响应链:
响应者
在iOS中,能够响应时间的对象都是UIResponder的子类对象。当事件来到时,系统会将事件传递给合适的响应者,并且将其成为第一响应者。第一响应者为处理的事件,将会在响应者链中进行传递,传递规则由UIResponder的nextResponder决定,可以通过重写该属性来决定传递规则。当一个事件到来时,第一响应者没有接受消息,则顺着响应者链向后传递。
要学习响应者链,我们还得了解一下UIResponder、UIEvent 和 UIControl
UIResponder
我们最熟悉的UIApplication、UIView、UIViewController这几个类是直接继承自UIResponder,UIResponder类是专门用来响应用户的操作处理各种事件(UIEvent)的。
UIResponder提供了用户点击、按压检测(presses)以及手势检测(motion)的回调方法,分别对应用户开始、移动、结束以及取消,其中只有在程序强制退出或者来电时,取消事件才会调用。
我们以最常用到的点击事件为例:
- (void) touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"开始点击!");
}
- (void) touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"发生了移动!");
}
- (void) touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"点击结束!");
}
- (void) touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"点击取消!");
}
我们现在先点击屏幕,然后先不移走手指,持续一段时间之后再移走手指。
然后我们就会发现打印了如下结果:
需要注意的是:
如果两个手指同时触摸一个view,那么view只会调用1次touchesBegin,参数touches里面有两个UITouch对象。
如果两个手指一前一后分开触摸,则view会分别调用2次touchesBegin,每次调用时的touches参数中只包含1个UITouch对象;
UIEvent
是由硬件捕获到的一个表示用户操作设备的对象,事件分为三类:包括触摸事件(Touch Events对应就是UITouch)、运动事件(Motion Events)、远程控制事件(Remote Control Events)。
UIControl
如果说UIResponder 实例对象可以对随机事件进行响应并处理,那么UIEvent 代表一个单一并只含有一种类型的事件,这个类型可以是触摸、远程控制或者按压,对应的子类具体一点可能是设备的摇动(为了处理系统事件,UIResponder 的子类可以通过重写一些对应的方法从而让它们可处理具体的 UIEvent 类型)。
在某种程度上,你可以将 UIEvents 视为通知。虽然 UIEvents 可以被子类化并且 sendEvent 可以被手动调用,但它们并不真正意味着可以这么做,至少不是通过正常方式。由于你无法创建自定义类型,派发自定义事件会出现问题,因为非预期的响应者可能会错误地 “处理” 你的事件。尽管如此,你仍然可以使用它们,除了系统事件,UIResponder 还可以以 Selector 的形式响应任意 “事件”。
虽然 UIResponder 可以完全检测触摸事件,但处理它们并非易事。 那你要如何区分不同类型的触摸事件呢?
这就是 UIControl 擅长的地方,UIControl相当于就是对UIResponder进行了一次封装,已经将手势与View进行了封装绑定,比如我们的UIButton为什么可以检测到双击,单击等等操作,也都是在UIControl里写好的
typedef NS_OPTIONS(NSUInteger, UIControlEvents) {
UIControlEventTouchDown = 1 << 0, // on all touch downs
UIControlEventTouchDownRepeat = 1 << 1, // on multiple touchdowns (tap count > 1)
UIControlEventTouchDragInside = 1 << 2,
UIControlEventTouchDragOutside = 1 << 3,
UIControlEventTouchDragEnter = 1 << 4,
UIControlEventTouchDragExit = 1 << 5,
UIControlEventTouchUpInside = 1 << 6,
UIControlEventTouchUpOutside = 1 << 7,
UIControlEventTouchCancel = 1 << 8,
UIControlEventValueChanged = 1 << 12, // sliders, etc.
UIControlEventPrimaryActionTriggered API_AVAILABLE(ios(9.0)) = 1 << 13, // semantic action: for buttons, etc.
UIControlEventMenuActionTriggered API_AVAILABLE(ios(14.0)) = 1 << 14, // triggered when the menu gesture fires but before the menu presents
UIControlEventEditingDidBegin = 1 << 16, // UITextField
UIControlEventEditingChanged = 1 << 17,
UIControlEventEditingDidEnd = 1 << 18,
UIControlEventEditingDidEndOnExit = 1 << 19, // 'return key' ending editing
UIControlEventAllTouchEvents = 0x00000FFF, // for touch events
UIControlEventAllEditingEvents = 0x000F0000, // for UITextField
UIControlEventApplicationReserved = 0x0F000000, // range available for application use
UIControlEventSystemReserved = 0xF0000000, // range reserved for internal framework use
UIControlEventAllEvents = 0xFFFFFFFF
};
事件的产生、传递和响应过程
UIApplication -> UIWindow -> 递归找到最合适处理的空间 -> 控件调用touches方法 -> 判断是否实现touches方法 -> 没有实现默认会将事件传递给上一个响应者 -> 找到上一个响应者 -> 找不到方法作废
传递过程
- 当触摸事件发生时,压力传为电信号,iOS系统将产生UIEvent对象,记录事件产生的时间和类型。
- 当检测到一个系统事件、例如屏幕上的点击,UIKit内部创建一个UIEvent实例并且记录事件产生的时间和类型,然后系统将事件加入到一个由UIApplication管理的事件队列中。
- UIApplication 会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常会先发送事件给应用程序的主窗口(keyWindow)
- 主窗口会在视图层次结构中找到一个最合适的视图来处理触摸事件
下方两张图很好的表述了触摸事件的传递过程:
- 找到合适的视图控件后,就会调用视图控件的 touches 方法来作事件的具体处理:touchesBegin… touchesMoved…touchesEnded 等。如果找到了最合适的响应者,但是如果其没有实现touches方法,就会调用其上一个响应者对象的touches方法
如果父控件接受不到触摸事件,那么子控件就不可能接收到触摸事件UIView不能接收触摸
- userInteractionEnabled属性为YES,该属性表示允许控件同用户交互
- Hidden属性为NO,空间都看不见,自然不存在触摸
- alpha属性值不为0 ~ 0.01
- 触摸点在这个UIView的范围内
hit-Test
用户的触摸事件首先会由系统截获,进行包装处理等。然后递归去遍历 view 层级,直到找到合适的响应者来处理事件,这个过程也叫做 Hit-Test。
Hit-Testing 先检查触摸对象所在的位置是否在对应任意屏幕上的视图对象的区域范围内。
如果在的话,就开始对此视图对象的子视图对象进行同样的检查。
视图树中最底层那个包含此触摸点位置的视图对象,就是要查找的 hit-test 视图对象。
iOS 一旦确定 hit-test 视图对象,就会把触摸事件传递给它进行处理。
例如我们点击了上图中的View4,那么传递过程就是这样:
- 触摸点在RootView的区域范围内,然后开始检查子视图View1和View2
- 触摸点不在View1的范围而在View2的范围,于是就开始检查View3和View4视图
- 触摸点不在View3的范围而在View4的范围,而View4是视图树中最底层的并包含触摸点的视图对象,所以View4就成为了hit-test视图
hitTest:withEvent:方法处理流程:
- 首先调用当前视图的pointInside:withEvent:方法判断触摸点是否在当前视图内:
若pointInside:withEvent:方法返回NO,说明触摸点不在当前视图内,则当前视图的hitTest:withEvent:返回nil
若pointInside:withEvent:方法返回YES,说明触摸点在当前视图内,则遍历当前视图的所有子视图(subviews),调用子视图的hitTest:withEvent:方法重复前面的步骤,子视图的遍历顺序是从top到bottom,即从subviews数组的末尾向前遍历,直到有子视图的hitTest:withEvent:方法返回非空对象或者全部子视图遍历完毕
UIApplication对象维护着自己的一个响应者栈,当pointInSide: withEvent:返回yes的时候,响应者入栈。传递链中是没有 controller 的,因为 controller 本身不具有大小的概念。但是响应链中是有 controller 的,因为 controller 继承自 UIResponder。所以controller可能是个单独的例外,其不需要pointInside方法就可以自己进入响应者栈
下图很生动地表示了入响应者栈的过程:
- 若第一次有子视图的hitTest:withEvent:方法返回非空对象,则当前视图的hitTest:withEvent:方法就返回此对象,处理结束
若所有子视图的hitTest:withEvent:方法都返回nil,则当前视图的hitTest:withEvent:方法返回当前视图自身(self)
hit-Test的内部实现:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
if (self.hidden || !self.userInteractionEnabled || self.alpha < 0.01 || ![self pointInside:point withEvent:event] || ![self _isAnimatedUserInteractionEnabled]) {
return nil;
} else {
for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
UIView *hitView = [subview hitTest:[subview convertPoint:point fromView:self] withEvent:event];
if (hitView) {
return hitView;
}
}
return self;
}
}
这里遍历子视图时是逆序的,即遍历 subview 时,是从上往下顺序遍历的,即 view.subviews 的 lastObject 到 firstObject 的顺序,找到合适的响应者view,就停止遍历。
传递的大致过程application -> window -> root view -> … -> lowest view
响应过程
响应者链
- 初始视图先进行尝试处理,自身处理不了的情况下,如果view的控制器存在,就传递给控制器处理;如果控制器不存在,则传递给它的父视图
在视图层次结构的最顶层,如果也不能处理收到的事件,则将事件传递给UIWindow对象进行处理 - 如果UIWindow对象也不处理,则将事件传递给UIApplication对象
- 如果UIApplication也不能处理该事件,则将该事件丢弃
- Response Chain,响应链,一般我们称之为响应者链。
- 在我们的 app 中,所有的视图都是按照一定的结构组织起来的,即树状层次结构,每个 view 都有自己的 superView,包括 controller 的 topmost view(即 controller 的 self.view)。
- 当一个 view 被 add 到 superView 上的时候,它的 nextResponder 属性就会被指向它的 superView。
- 当 controller 被初始化的时候,self.view(topmost view) 的 nextResponder 会被指向所在的 controller,而 controller 的 nextResponder 会被指向 self.view的superView。
- 这样,整个 app 就通过 nextResponder 串成了一条链,这就是我们所说的响应者链。
- 所以响应者链是一条虚拟的链,并没有一个对象来专门存储这样的一条链,而是通过 UIResponder 的属性串联起来的。
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
响应过程
如果没有实现touches方法那么一般默认做法是控件将事件顺着响应者链向上传递,将事件交给上一个响应者进行处理。
- 判断当前是否是控制器的 View,如果是控制器的 View,上一级响应者就是控制器
- 如果不是控制器的 View,上一级响应者就是父控件
当有view能够处理触摸事件后,开始响应事件。系统就会调用上面讲过的那四个方法:
- (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;
两个例子:
例一、 在一个方形按钮中点击中间的圆形区域有效,而点击四角无效
核心思想是在pointInside: withEvent:方法中修改对应的区域
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
// 如果控件不允许与用用户交互,那么返回nil
if (!self.userInteractionEnabled || [self isHidden] || self.alpha <= 0.01) {
return nil;
}
//判断当前视图是否在点击范围内
if ([self pointInside:point withEvent:event]) {
//遍历当前对象的子视图(倒序)
__block UIView *hit = nil;
[self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
//坐标转换,把当前坐标系上的点转换成子控件坐标系上的点
CGPoint convertPoint = [self convertPoint:point toView:obj];
//调用子视图的hitTest方法,判断自己的子控件是不是最适合的View
hit = [obj hitTest:convertPoint withEvent:event];
//如果找到了就停止遍历
if (hit) *stop = YES;
}];
//返回当前的视图对象
return hit?hit:self;
}else {
return nil;
}
}
// 该方法判断触摸点是否在控件身上,是则返回YES,否则返回NO,point参数必须是方法调用者的坐标系
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
CGFloat x1 = point.x;
CGFloat y1 = point.y;
CGFloat x2 = self.frame.size.width / 2;
CGFloat y2 = self.frame.size.height / 2;
//判断是否在圆形区域内
double dis = sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
if (dis <= self.frame.size.width / 2) {
return YES;
}
else{
return NO;
}
}
例二、
视图的穿透响应:(一个视图覆盖在另一个视图的上方的时候点击响应下方的视图)
- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event {
for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
CGPoint convertedPoint = [subview convertPoint:point fromView:self];
UIView*hitTestView = [subview hitTest:convertedPoint withEvent:event];
//判断视图是否是你想要穿透的视图类 如果是就不响应该视图
//将上方的那个视图的tag标记为2,将下方想要响应的视图的tag标记为1
if (hitTestView.tag == 2) {
return nil;
}
if ([hitTestView isKindOfClass:[UICollectionReusableView class]]) {
return nil;// 如果两个重叠的视图不在一个父视图
return 你要响应的视图;//如果两个重叠的视图在同一个父视图上
}
if(hitTestView) {
return hitTestView;
}
}
return[super hitTest:point withEvent:event];
}