[iOS开发]事件处理与响应者链
Posted Billy Miracle
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[iOS开发]事件处理与响应者链相关的知识,希望对你有一定的参考价值。
响应链
当ios捕获到某个事件时,就会将此事件传递给某个看上去最适合处理该事件的对象,比如触摸事件传递给手指刚刚触摸位置的那个视图(view),如果这个对象无法处理该事件,iOS系统就继续将该事件传递给更深层的对象,直到找到能够对该事件作出响应处理的对象为止。这一连串的对象序列被称作为“响应链”(responder chain),iOS系统就是沿着此响应链,由最外层逐步向内存对象传递该事件,亦即将处理该事件的责任进行传递。 iOS的这种机制,使得事件处理具有协调性和动态性。
响应者
在iOS中,能够响应事件的对象都是UIResponder的子类对象。当事件来到时,系统会将事件传递给合适的响应者,并且将其成为第一响应者。第一响应者未处理的事件,将会在响应者链中进行传递,传递规则由UIResponder的nextResponder决定,可以通过重写该属性来决定传递规则。当一个事件到来时,第一响应者没有接收消息,则顺着响应者链向后传递。
UIResponder、UIEvent 和 UIControl的介绍、联系与区别
UIResponder
@interface UIApplication : UIResponder
@interface UIViewController : UIResponder <NSCoding, UIAppearanceContainer, UITraitEnvironment, UIContentContainer, UIFocusEnvironment>
@interface UIView : UIResponder <NSCoding, UIAppearance, UIAppearanceContainer, UIDynamicItem, UITraitEnvironment, UICoordinateSpace, UIFocusItem, UIFocusItemContainer, CALayerDelegate>
我们所熟悉的UIApplication
、UIView
、UIViewController
这几个类是直接继承自UIResponder
,UIResponder
类是专门用来响应用户的操作处理各种事件(UIEvent
)的。
UIResponder提供了用户点击、按压检测(presses)以及手势检测(motion)的回调方法,分别对应用户开始、移动、结束以及取消,其中只有在程序强制退出或者来电时,取消事件才会调用。
我们可以看一下UIResponder
的声明:
@interface UIResponder : NSObject <UIResponderStandardEditActions>
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
@property(nonatomic, readonly) BOOL canBecomeFirstResponder; // default is NO
- (BOOL)becomeFirstResponder;
@property(nonatomic, readonly) BOOL canResignFirstResponder; // default is YES
- (BOOL)resignFirstResponder;
@property(nonatomic, readonly) BOOL isFirstResponder;
- (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;
- (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches API_AVAILABLE(ios(9.1));
- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0));
- (void)pressesChanged:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0));
- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0));
- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0));
- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event API_AVAILABLE(ios(3.0));
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event API_AVAILABLE(ios(3.0));
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event API_AVAILABLE(ios(3.0));
...
@end
我们可以试着玩一下,touches系列就是手指的点击事件会触发,motion系列就是加速计事件(比如摇一摇手机等)press在官方文档里说是related to the press of a physical button
和物理按键有关,但我自己没试出来。
UIEvent
typedef NS_ENUM(NSInteger, UIEventType)
UIEventTypeTouches,
UIEventTypeMotion,
UIEventTypeRemoteControl,
UIEventTypePresses API_AVAILABLE(ios(9.0)),
UIEventTypeScroll API_AVAILABLE(ios(13.4), tvos(13.4)) API_UNAVAILABLE(watchos) = 10,
UIEventTypeHover API_AVAILABLE(ios(13.4), tvos(13.4)) API_UNAVAILABLE(watchos) = 11,
UIEventTypeTransform API_AVAILABLE(ios(13.4), tvos(13.4)) API_UNAVAILABLE(watchos) = 14,
;
是由硬件捕获到的一个表示用户操作设备的对象,事件主要分为三类:包括触摸事件(Touch Events对应就是UITouch)、运动事件(Motion Events)、远程控制事件(Remote Control Events),通过代码,发现新版本还可以有按压事件、滑动事件、悬停事件、变换事件。官方文档里面没有说明这几个,那我先暂不深究。
UIControl
UIControl
是UIView
的子类,当然也是UIResponder
的子类。UIControl
是诸如UIButton
、UISwitch
、UITextField
等控件的父类,它本身也包含了一些属性和方法,但是不能直接使用UIControl
类,它只是定义了子类都需要使用的方法,我们只会使用它的子类。
如果说UIResponder
实例对象可以对随机事件进行响应并处理,那么UIEvent
代表一个单一并只含有一种类型的事件,这个类型可以是触摸、远程控制或者按压,对应的子类具体一点可能是设备的摇动(为了处理系统事件,UIResponder
的子类可以通过重写一些对应的方法从而让它们可处理具体的 UIEvent
类型)。
在某种程度上,你可以将 UIEvents
视为通知。虽然 UIEvents
可以被子类化并且 sendEvent
可以被手动调用,但它们并不真正意味着可以这么做,至少不是通过正常方式。由于你无法创建自定义类型,派发自定义事件会出现问题,因为非预期的响应者可能会错误地 “处理” 你的事件。尽管如此,你仍然可以使用它们,除了系统事件,UIResponder
还可以以 Selector
的形式响应任意 “事件”。
虽然 UIResponder
可以完全检测触摸事件,但如何区分不同类型的触摸事件呢?
这就是 UIControl
擅长的地方,UIControl
相当于就是对UIResponder
进行了一次封装,已经将手势与View
进行了封装绑定,比如我们的UIButton
为什么可以检测到双击,单击等等操作,也都是在UIControl
里写好的。
typedef NS_OPTIONS(NSUInteger, UIControlState)
UIControlStateNormal = 0,
UIControlStateHighlighted = 1 << 0, // used when UIControl isHighlighted is set
UIControlStateDisabled = 1 << 1,
UIControlStateSelected = 1 << 2, // flag usable by app (see below)
UIControlStateFocused API_AVAILABLE(ios(9.0)) = 1 << 3, // Applicable only when the screen supports focus
UIControlStateApplication = 0x00FF0000, // additional flags available for application use
UIControlStateReserved = 0xFF000000 // flags reserved for internal framework use
;
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
对象,记录事件产生的事件和类型,然后系统将事件加入到一个由UIApplication
管理的事件队列中。 UIApplication
会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常会先发送事件给应用程序的主窗口(keyWindow)- 主窗口会在视图层次结构中找到一个最合适的视图来处理触摸事件
- 找到合适的视图控件后,就会调用视图控件的touches方法来作事件的具体处理:
touchesBegin··· touchesMoved··· touchesEnded···
等 - 这些
touches
方法默认的做法是将事件顺着响应链条向上传递,将事件交给上一个响应者处理
一般事件的传递是从父控件传递到子控件的,如果父控件接受不到触摸事件,那么子控件就不可能接收到触摸事件。
UIView
不能接收触摸事件的三种情况:
- 不接受用户交互:
userInteractionEnabled = NO;
- 隐藏:
hidden= YES;
- 透明:
alpha = 0.0 ~0.01
hit-test
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
先看一下官方文档的解释:
This method traverses the view hierarchy by calling the
pointInside:withEvent:
method of each subview to determine which subview should receive a touch event. IfpointInside:withEvent:
returnsYES
, then the subview’s hierarchy is similarly traversed until the frontmost view containing the specified point is found. If a view does not contain the point, its branch of the view hierarchy is ignored. You rarely need to call this method yourself, but you might override it to hide touch events from subviews.
此方法通过调用每个子视图的pointInside:withEvent:
方法来遍历视图层次结构,以确定哪个子视图应接收触摸事件。如果pointInside:withEvent:
返回YES
,则子视图的层次结构将以类似的方式遍历,直到找到包含指定点的最前面视图。如果视图不包含该点,则忽略其视图层次结构的分支。您很少需要自己调用此方法,但您可以覆盖它以隐藏子视图中的触摸事件。
This method ignores view objects that are hidden, that have disabled user interactions, or have an alpha level less than 0.01. This method does not take the view’s content into account when determining a hit. Thus, a view can still be returned even if the specified point is in a transparent portion of that view’s content.
Points that lie outside the receiver’s bounds are never reported as hits, even if they actually lie within one of the receiver’s subviews. This can occur if the current view’sclipsToBounds
property is set toNO
and the affected subview extends beyond the view’s bounds.
此方法忽略隐藏的视图对象、已禁用用户交互或透明度小于 0.01 的视图对象。此方法在确定匹配时不考虑视图的内容。因此,即使指定的点位于该视图内容的透明部分中,仍可以返回视图。
位于接收方边界之外的点永远不会被报告为命中,即使它们实际上位于接收方的某个子视图中。如果当前视图的clipsToBounds
属性设置为NO
,并且受影响的子视图超出视图的边界,则可能会发生这种情况。
用户的触摸事件首先会由系统截获,进行包装处理等。然后递归遍历所有的view
,进行碰触测试(hitTest
),直到找到可以处理事件的view
。
Hit-Testing
先检查触摸对象所在的位置是否在对应任意屏幕上的视图对象的区域范围内。如果在的话,就开始对此视图对象的子视图对象进行同样的检查。视图树中最底层那个包含此触摸点位置的视图对象,就是要查找的 hit-test
视图对象。iOS 一旦确定 hit-test
视图对象,就会把触摸事件传递给它进行处理。
对于下图这种情况:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
NSLog(@"进入A_View---hitTest withEvent ---");
UIView * view = [super hitTest:point withEvent:event];
NSLog(@"离开A_View--- hitTest withEvent ---hitTestView:%@",view);
return view;
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event
NSLog(@"A_view--- pointInside withEvent ---");
BOOL isInside = [super pointInside:point withEvent:event];
NSLog(@"A_view--- pointInside withEvent --- isInside:%d",isInside);
return isInside;
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
NSLog(@"A_touchesBegan");
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event
NSLog(@"A_touchesMoved");
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event
NSLog(@"A_touchesEnded");
我们在里面都这样重写方法。点击E试一下:
再点一下B试一下:
首先调用当前视图的pointInside:withEvent:
方法判断触摸点是否在当前视图内,若pointInside:withEvent:
方法返回NO,说明触摸点不在当前视图内,则当前视图的hitTest:withEvent:
返回nil
。若pointInside:withEvent:
方法返回YES
,说明触摸点在当前视图内,则遍历当前视图的所有子视图,调用子视图的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:
方法返回当前视图自身。
hitTest
内部实现过程:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01)
return nil;
if ([self pointInside:point withEvent:event])
for (UIView *subview in [self.subviews reverseObjectEnumerator])
CGPoint convertedPoint = [subview convertPoint:point fromView:self];
UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
if (hitTestView)
return hitTestView;
return self;
return nil;
响应过程
响应者
响应者是可以响应事件并处理它们的对象。所有响应者对象都是最终从 UIResponder
(iOS)继承的类的实例。这些类声明了一个用于事件处理的编程接口,并定义了响应者的默认行为。应用程序的可见对象几乎总是响应者(例如,windows、views 和 controls),而应用程序对象(AppDelegate)也是响应者。在 iOS 中,视图控制器(UIViewController 对象)也是响应者对象。
若要接收事件,响应者必须实现适当的事件处理方法,在某些情况下,告诉应用它可以成为第一响应者。
响应者链
响应者链其实就是很多响应者对象(继承自UIResponder
的对象)一起组合起来的链条称之为响应者链条。
如果第一个响应者无法处理事件或 action消息,它会将其转发给一个称为响应者链的链接系列中的 “下一个响应者” (next responder)。响应者链允许响应者对象将处理事件或 action 消息的责任转移到应用程序中的其他对象。如果响应者链中的对象无法处理事件或 action,它会将消息传递给链中的下一个响应者。消息沿着链向上传播,指向更高级别的对象,直到被处理为止。如果未处理,应用程序将丢弃它。
响应者链是一条虚拟的链,并没有一个对象来专门存储这样的一条链,而是通过 UIResponder
的属性串联起来的。
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
响应过程
如果没有实现touches
方法那么一般默认做法是控件将事件顺着响应者链向上传递,将事件交给上一个响应者进行处理。那么如何判断当前响应者的上一个响应者是谁呢?
- 判断当前是否是控制器的 view,如果是控制器的 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;
可以使多个对象共同响应同一个事件。只需要在以上方法重载中调用super
的方法。
大致的过程initial view
-> super view
-> ···->view controller
-> window
-> Application
实际应用
扩大button点击范围
重写pointInside方法
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
CGRect bounds = CGRectMake(-10, -10, self.bounds.size.width + 20, self.bounds.size.height + 20);
return CGRectContainsPoint(bounds, point);
红色:按钮大小
蓝色:扩大点击区域
重写HitTest方法
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
CGRect bounds = CGRectMake(-10, -10, self.bounds.size.width + 20, self.bounds.size.height + 20);
if (CGRectContainsPoint(bounds, point))
return self;
else
return nil;
return self;
点击穿透
视图继承关系示意图:
目的:点击红色与蓝色重叠区域响应蓝色事件。
重写红色按钮的hitTest
方法:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
CGPoint convertedPoint = [self convertPoint:point toView:_lowwer];
if ([_lowwer pointInside:convertedPoint withEvent:event])
return _lowwer;
return self;
以上是关于[iOS开发]事件处理与响应者链的主要内容,如果未能解决你的问题,请参考以下文章