UI相关的开发和调试中,经常会涉及到点击、触摸、手势等情况的调试,最开始的开发时,也许会用用touchBegan等方法或者网上找找相关解决方案,并没有系统的了解过相关知识。今天主要是讲一下点击、触摸、手势调试的根源——响应链,顺带讲下手势与其关系,深入理解这些,对我们以后调试这些问题都能思路更清晰。
先从我们日常开发中可能会遇到的问题,我们可以自测下对于响应链的理解是否达标,如果这三个问题都能清晰地知道解决方案,可以忽略下文了:
- 隐藏键盘可以怎样实现?
- resignFirstResponder是什么意思?
- 一个视图A遮挡了视图B,如果让B来响应,而A不响应?
通过本文,对以上三个问题不仅知道解决方案,而且“知其所以然”。
触摸事件UIEvent
首先,我们平常在手机上最简单的一个动作开始:
我们在手机上的一个触摸或点击,在手机系统中是怎么样一个过程:
其实首先就是手机屏幕和底层相关软硬件对这个触摸一个解析过程,将这个触摸解析成event,然后iOS系统将这个event事件传递到相应的界面上,由界面来响应我们的操作,给出对应的反馈,这样一个交互过程。这其中传递的过程就是我们今天的要了解的主角。从图上可以看到,从window—view之间,具体是怎么样一个规律呢? 先从这个触摸或点击开始了解一下:
UITouch与UIEvent
一次触摸将产生一个UITouch:一个手指离开屏幕前的一系列动作,包含时间戳、所在视图、 力度等信息。
UIEvent:多个UITouch组成,也就是多个触摸组成。 一个event指的是第一个手指开始触摸到最后一个手指离开屏幕这段时间所有UITouch的总和。
那么这个UIEvent是如何在系统解析出来后,传递下去呢?哪些对象可以传递UIEvent呢?
响应者Responder
响应者就是可以接收到UIEvent的对象,也是可以最终响应用户的操作的对象。iOS开发中主要与四种对象可以作为responder:
上图的四个类都继承自UIResponder:
- @interface AppDelegate : UIResponder
- @interface UIWindow : UIView
- @interface UIView : UIResponder
- @interface UIViewController : UIResponder
UIResponder作为响应者的基类,主要是定义了一些关于响应的属性和方法,用于子类判断或者执行关于响应的操作:
@interface UIResponder : NSObject <UIResponderStandardEditActions>
#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
#else
- (nullable UIResponder*)nextResponder;
#endif
#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL canBecomeFirstResponder; // default is NO
#else
- (BOOL)canBecomeFirstResponder; // default is NO
#endif
- (BOOL)becomeFirstResponder;
#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL canResignFirstResponder; // default is YES
#else
- (BOOL)canResignFirstResponder; // default is YES
#endif
- (BOOL)resignFirstResponder;
#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL isFirstResponder;
#else
- (BOOL)isFirstResponder;
#endif
// Generally, all responders which do custom touch handling should override all four of these methods.
// Your responder will receive either touchesEnded:withEvent: or touchesCancelled:withEvent: for each
// touch it is handling (those touches it received in touchesBegan:withEvent:).
// *** You must handle cancelled touches to ensure correct behavior in your application. Failure to
// do so is very likely to lead to incorrect behavior or crashes.
- (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 NS_AVAILABLE_IOS(9_1);
- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesChanged:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(4_0);
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender NS_AVAILABLE_IOS(3_0);
// Allows an action to be forwarded to another target. By default checks -canPerformAction:withSender: to either return self, or go up the responder chain.
- (nullable id)targetForAction:(SEL)action withSender:(nullable id)sender NS_AVAILABLE_IOS(7_0);
@property(nullable, nonatomic,readonly) NSUndoManager *undoManager NS_AVAILABLE_IOS(3_0);
这些responder在接受上一个responder传递而来的响应UIEvent,这样依次传递响应的顺序就是一个响应链,如果将响应链顺序反向:就是UIEvent通过hittest遍历的过程链。 责任传递就是处于应该响应顺序上的上一个对象有责任处理这个UIEvent,如果他不处理,就轮到响应链上的下一个来处理。也就是说刚才UIEvent的传递过程和响应链是两个互逆的顺序,但是这个具体哪个能作为响应者,该如何确定呢?
其实系统是通过hittest方法来决定哪个view作为最终响应用户操作的载体。hittest是UIView的一个扩展方法,与其搭配的就是pointInside方法,二者功能可以确定点击或者触摸的点是否在view的范围内,通过hittest的返回值告诉系统当前view是否可以作为响应者。
@interface UIView(UIViewGeometry)
// animatable. do not use frame if view is transformed since it will not correctly reflect the actual location of the view. use bounds + center instead.
@property(nonatomic) CGRect frame;
// use bounds/center and not frame if non-identity transform. if bounds dimension is odd, center may be have fractional part
@property(nonatomic) CGRect bounds; // default bounds is zero origin, frame size. animatable
@property(nonatomic) CGPoint center; // center is center of frame. animatable
@property(nonatomic) CGAffineTransform transform; // default is CGAffineTransformIdentity. animatable
@property(nonatomic) CGFloat contentScaleFactor NS_AVAILABLE_IOS(4_0);
@property(nonatomic,getter=isMultipleTouchEnabled) BOOL multipleTouchEnabled __TVOS_PROHIBITED; // default is NO
@property(nonatomic,getter=isExclusiveTouch) BOOL exclusiveTouch __TVOS_PROHIBITED; // default is NO
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event; // default returns YES if point is in bounds
- (CGPoint)convertPoint:(CGPoint)point toView:(nullable UIView *)view;
- (CGPoint)convertPoint:(CGPoint)point fromView:(nullable UIView *)view;
- (CGRect)convertRect:(CGRect)rect toView:(nullable UIView *)view;
- (CGRect)convertRect:(CGRect)rect fromView:(nullable UIView *)view;
@property(nonatomic) BOOL autoresizesSubviews; // default is YES. if set, subviews are adjusted according to their autoresizingMask if self.bounds changes
@property(nonatomic) UIViewAutoresizing autoresizingMask; // simple resize. default is UIViewAutoresizingNone
- (CGSize)sizeThatFits:(CGSize)size; // return 'best' size to fit given size. does not actually resize view. Default is return existing view size
- (void)sizeToFit; // calls sizeThatFits: with current view bounds and changes bounds size.
@end
——————————hittest————————
理解hittest就是理解响应链的核心点:
iOS系统在判断哪个view来响应用户时 ,就是通过hittest在所有view的“树”上遍历:当前内存中所有view的子类都会执行hittest方法,逐个遍历每个叶子节点,如果叶子节点view上还有叶子,就进一步遍历下去。再啰嗦一句:即使某个view不会作为响应者,并且也不在点击范围内,只要它是当前显示的vc或者view的子视图,就会被httest遍历到,从而获取到是否可以作为响应者的信息。
通过hittest的这个特点,我们便可以通过代码来验证一下,在我们所知的最顶层的view上重写hittest方法,通过断点获取到断点:
我们验证的视图层次如下, vc上放着一个tableview,table上面一个yellowview:
Table *table = [[Table alloc]initWithFrame:self.view.bounds];
table.backgroundColor = [UIColor lightGrayColor];
table.dataSource = self;
table.delegate = self;
[self.view addSubview: table];
YellowView *view = [[YellowView alloc]initWithFrame:CGRectMake(20, 20, 200, 200)];
view.backgroundColor = [UIColor yellowColor];
[table addSubview:view];
我们便可以在yellowview的实现中重写hittest
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
return self;
}
得到堆栈如下:
可以看出:
序号25的栈帧上,主线程的runloop接收到UIEvent,然后经由分发器,首先分发到window上,然后传递给UIView,然后16--13正式遍历这个UIView的所有叶子的过程。在遍历过程中,如果子节点的任何一个view在执行hittest时返回了self(当前子view),就表示找到了响应的视图,这时父类也会通过返回值将这个子view向上返回,一直返回到window,返回到application,也就让系统知道了应该让谁来处理这个触摸。
- 注意:以下三种情况下,view将不会执行hittest:
hidden=YES
userInteractionEnabled=NO
alpha<0.01
响应链
通过上面的hittest遍历后,已经找到了响应者responder,由于其必然继承自UIResponder,我们可以重写UIResponder的touchBegan方法,touchBegan方法中再通过nextResponder属性来遍历地查看响应者链上的每个响应者:
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
[super touchesBegan:touches withEvent:event];
NSLog(@"------------------");
NSLog(@" v ");
NSLog(@" %@", [self class]);
UIResponder * next = [self nextResponder];
while (next != nil)
{
NSLog(@" v ");
NSLog(@" %@", [next class]);
next = [next nextResponder];
}
NSLog(@"------------------");
}
打印log结果如下图:
我们以下图来具体描述下响应者链:
UIView的nextResponder可能是UIView或UIViewViewController两种情况:
- 当UIView的父类还是UIView时,nextResponder就是这个view父类;
- 当UIView的父类还是UIViewViewController时,nextResponder就是这个vc父类;
UIViewViewController的nextResponder是UIWindow,
UIWindow的nextResponder是UIApplication的delegate(appDelegate)。
手势与响应链关系
这里不会讲述手势相关的知识, 只简单减少下手势相关问题调试时可能碰到的问题:当某个view添加了手势后,通过touchesBegan判断当前view是否是响应者时,并没有执行(实际这个view就是响应者),原因如下:
当window接收到UIEvent后,并不会直接将UIEvent传递给链条上的view,因为其添加了手势,这时手势识别器会将处理UIEvent,如果识别出来是手势,会立刻给view调用touchCancel;如果识别不出来是手势,才会调用touchesBegan,让view来处理这个UIEvent。
应用
通过响应链的基本知识,我们可以考虑下一些应用:
1. 找到view所属vc
@implementation UIView (ParentController)
-(UIViewController*)parentController
{
UIResponder *responder = [self nextResponder];
while (responder)
{
if ([responder isKindOfClass:[UIViewController class]])
{
return (UIViewController*)responder;
}
responder = [responder nextResponder];
}
return nil;
}
@end
可以看出这个方法正是利用了,view的nextResponder可能是view,如果是view就继续找nextResponder,直到是vc了,就是它所属的vc。如下左图。
当情况更复杂一点,如下面右边图时:
- 注意: 这时,vc上面还有childVC,这时childVC的nextResponder不是UIWindow,而是其父类vc,一直到vc已经是UIWindow的rootVC,其nextResponder才是UIWindow。
2. 视图被遮挡时的响应
当图A被图B遮挡时,通常B会将点击事件阻断,A收不到。这时如果想要让A能收到,可以有两种方式:
- 利用hittest三种情况不调用的知识,可以设置A的userInteractionEnabled=NO;
- 重写A的hittest方法,使其返回nil;