事件层次分析(一)

事件的传递和响应

  • UIResponder和响应链的组成

许多对象都继承自UIResponder,包括UIApplication对象,UIView​Controller对象以及所有的UIView对象,也包括UIWindow对象。
UIResponder苹果官方文档

响应链



响应链的组成是:

  • 如果你这个view的上一级是viewController那么他的nextResponder是viewController,但是如果上一级viewController有自己的view,则是他的nextResponder先是上一级viewController的view,然后才是viewController。
  • 如果你这个view的上一级有superView,那么他的nextResponder是superView,如果到了最顶层是window,那么返回的就是window了,最后是UIApplication,再最后是AppDelegate。

注意:当加了导航条的时候,系统会默认加一些导航的view和导航控制器在里面,所以用nextResponder获取不到你想要的从导航push过来的上一个控制器。

  • Hit-Test找到view的流程

当用户产生一个触摸事件,UIKit会生成一个event object包含了该事件需要处理的信息(时间、位置、状态等),然后把该event object放在app的事件队列里(先进先出),触摸事件它是一个NSSet型的touches。对于motion事件(加速计等),event取决于是哪种motion event。
事件沿着一个特定的路径来传递到可处理事件的对象上来,首先UIApplication从事件队列里拿到一个event进行分发,传给keyWindow,再传给initial object(取决于事件的type)。

  1. touch事件:keywindow传递事件给事件发生的view(通过hitTest方法来递归找到)
  2. motion和远程控制事件

当用户点击屏幕后,事件响应的流程

分析:当用户点击屏幕产生事件后,UIApplication从事件队列中给UIWindow分发一个事件,然后UIWindow遍历它的子视图,其中谁先添加,就先从谁哪里开始找,如果它有子视图就再遍历它的子视图,如果最终这个view的pointInside:withEvent:返回的是NO,就说明触摸点不在这个View上面。然后它就开始遍历它的兄弟view,也就是第二个被添加的view,如果有子视图也会遍历,如此下去,直到找到需要响应的view。如果最终都没有被响应,则事件则会被抛弃(反序遍历)。

影响Hit-Test流程的因素有:alpha、hidden、userInteractionEnabled

pointInside:withEvent:是在Hit-Test中进行调用的,当alpha<0.01,hidden = YES,或者userInteractionEnabled = NO,响应都只会走到Hit-Test方法,不会走到pointInside:withEvent:,也即是事件不会被响应。

hitTest: withEvent:内部实现原理:

if (self.alpha <= 0.01 || self.hidden || !self.userInteractionEnabled) {
    return nil;
}

if ([self pointInside:point withEvent:event]) {
    NSArray *subViews = [[self.subviews reverseObjectEnumerator] allObjects]; // 交换顺序,后添加的先遍历
    for (id view in subViews) {
        CGPoint convertPoint = [self convertPoint:point toView:view]; // 将point to到view上
        if ([view pointInside:convertPoint withEvent:event]) {
            return view;
        }
    }
    return self;
}
return nil;

第二种(两种都可以):

UIView *lastResultView = nil;
if ([self pointInside:point withEvent:event]) {
    lastResultView = self;
    NSArray *sub = [[self.subviews reverseObjectEnumerator] allObjects];  // 交换顺序,后添加的先遍历
    if (sub.count) {
        for (id view in sub) {
            CGPoint convertPoint = [self convertPoint:point toView:view];  // 将point to到view上
            UIView *currentResultView = [view hitTest:convertPoint withEvent:event];
            if (currentResultView) {
                lastResultView = currentResultView;
                break;
            }
            
        }
        return  lastResultView;
    } else {
        return  lastResultView;
    }
}
return nil;

第三种:

if (self.alpha <= 0.01 || !self.userInteractionEnabled || self.hidden) {
    return nil;
}
if ([self pointInside:point withEvent:event]) {  //发生在我的范围内
    //遍历子view  reverseObjectEnumerator 反序   objectEnumerator 顺序
    NSArray *subViews = [[self.subviews reverseObjectEnumerator] allObjects];
    UIView *tmpView;
    for (UIView *subView in subViews) {
        //转换坐标系,然后判断该点是否在bounds范围内
        CGPoint convertedPoint = [self convertPoint:point toView:subView];  //将点的坐标系转换到以自控件为标准
        tmpView = [subView eocHitTest:convertedPoint withEvent:event];
    }
    return tmpView?tmpView:self;  // 如果存在子控件被点击就返回子控件,不然就返回自身
    
} else {
    return nil;
}
应用
  • 扩大button的响应范围
    可以通过重写pointInside方法来实现
-(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    CGRect btnBounds = self.bounds;
    //扩大点击区域
    btnBounds = CGRectInset(btnBounds, -W(30), -W(30));
    //如果点击的点在新的bounds里,就返回yes
    return CGRectContainsPoint(btnBounds, point);
}
  • 创建一个超出superView的控件,点击超出部分还能响应事件
    做法一:可以重写父控件的pointInside方法,返回YES
    做法二:可以重写父控件的hitTest: withEvent:方法,遍历它的子控件,去掉在父控件上面才遍历的那个判断
-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    if (self.alpha <= 0.01 || self.hidden || !self.userInteractionEnabled) {
        return nil;
    }
//    if ([self pointInside:point withEvent:event]) {
        NSArray *subViews = [[self.subviews reverseObjectEnumerator] allObjects]; // 交换顺序,后添加的先遍历
        for (id view in subViews) {
            CGPoint convertPoint = [self convertPoint:point toView:view]; // 将point to到view上
            if ([view pointInside:convertPoint withEvent:event]) {
                return view;
            }
        }
        return self;
//    }

事件响应的四个方法

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"%s", __func__);
    [super touchesBegan:touches withEvent:event];
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"%s", __func__);
    [super touchesMoved:touches withEvent:event];
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"%s", __func__);
    [super touchesEnded:touches withEvent:event];
}

- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"%s", __func__);
    [super touchesCancelled:touches withEvent:event];
}

当只有调用了父类的方法[super touchesBegan:touches withEvent:event]才能将响应事件传递个下一个响应者,也可以写成[self.nextResponder touchesBegan:touches withEvent:event]

手势

  • 手势事件
    手势把低层次的事件处理变成高层次的响应。需要把手势附加到view上,允许view响应定义好的action。当识别手势后将打断view的touch事件。当识别手势后,发送action消息给target,target是view controller。

  • 手势的状态变化
    非连续性的手势状态变化:当识别后,手势状态从Possible到recognized转变,然后识别完成。
    连续性的手势状态变化:手势识别后,手势从Possible到begin状态,然后由begin到changed状态,如果手势一直发生,再持续性为changed状态,当手指离开后手势变成end状态,手势完成,end状态是recognized状态的别名。
    只要手势不是切换到failed或canceled状态,当手势状态改变的时候,都会发送action message给target。所以非连续性的手势只会发送一次action message,而连续性的会发送多次。



    上图中左边为非连续性,右边为连续性

  • 与其他手势之间的交互
    如果一个view有多个手势,默认情况下是没有次序来说明哪个手势先识别的,所以每一次识别的次序是不一样的。所以可以用UIGestureRecognizeDelegate来处理先后顺序。

  • 手势和touch
    手势是附加在控件上,可以在控件外部来响应,但是touch只能在控件内部来响应。
    默认情况下,当一个touch发生的时候,touch object从UIApplication传递到UIWindow,window首先传递touches给绑定了手势产生的touch的view或superViews,然后再进行touchBegin四个方法来处理(如下图)。


window延迟传送touch objects给view,使得手势能先处理touch,在延迟的时候,如果手势识别出来了,window不会再传递给touch给view,也会取消,打断之前传递给view的touch(如下图)。

手势里面也有响应touch的是个方法,当导入#import <UIKit/UIGestureRecognizerSubclass.h>,即可重写这四个方法

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"RedColorTapGesture touchBegan %ld", self.state);
    [super touchesBegan:touches withEvent:event];
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"RedColorTapGesture touchesMoved %ld", self.state);
    [super touchesMoved:touches withEvent:event];
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"RedColorTapGesture touchesEnded %ld", self.state);
    [super touchesEnded:touches withEvent:event];
}

- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"RedColorTapGesture touchesCancelled %ld", self.state);
    [super touchesCancelled:touches withEvent:event];
}

如果不写touchesBegan方法中的[super touchesBegan:touches withEvent:event],手势将不会被响应。

在一个控件上添加手势时,会先走到手势的touchesBegan方法,再走到控件的touchesBegan方法


影响view的touch方法手势的几个属性
  • delaysTouchesBegan 默认为NO,当我们手势识别之后,直接取消对touch事件的响应


    当设置为YES时,也即是不延迟响应时,就不会识别view的touch方法,只是识别和处理手势的touch方法

  • cancelsTouchesInView 默认为YES,当手势识别后会打断view的touch事件
    当设置为NO后,则是直接结束view的touch事件

  • delaysTouchesEnded 默认为YES,这种情况下发生一个touch时,在手势识别成功后,发送给touchesCancelled消息给hit-test,先打断,延迟结束。手势识别失败时,会延迟大概0.15ms,期间没有接收到别的touch才会发送touchesEnded。如果设置为NO,则不会延迟,即会立即发送touchesEnded以结束当前触摸。

多个手势共存问题

假如一个控件上有两个手势,比如



这时只会响应pan手势,不会响应swipe手势

  • 当加上[panGesture requireGestureRecognizerToFail:swipeGesture]这句代码时,两个手势共存,从左往右滑会响应swipe手势。
  • 也可以通过手势的代理方法来实现
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
    NSLog(@"RedColorTapGesture RecognizerShouldBegin");
    return YES;
}
-(BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
   //这里返回YES,代表跟别的手势共存;如果返回NO,不一定代表不共存,只要别的手势返回YES,也能共存
    NSLog(@"RedColorTapGesture RecognizerSimultaneously");
    return YES;
}
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{   //另外一个手势识别fail的时候,才会识别自己
    NSLog(@"RedColorTapGesture RequireFailure");
    return YES;
}
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer{
    //我被另外一个手势变成Fail
    NSLog(@"RedColorTapGesture shouldBeRequiredToFail");
    return NO;
}

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
{
    NSLog(@"RedColorTapGesture shouldReceiveTouch");
    return YES;
}

button事件

  • 代码主动响应button的事件
    当调用[customBtn sendActionsForControlEvents:UIControlEventTouchDown]时,会自动执行button的点击事件。
  • button的内部实现

在事件的点击事件打断点,看进程可以知道button内部调用了两个方法,所以可以推断它的内部实现:

- (void)sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event
{
    return [super sendAction:action to:self forEvent:event];
}

- (void)btnAction
{
    NSLog(@"八点钟学院");
}

button的UIControlEvents几种状态,都是在button的touch四个方法中进行区分判断的

可以继承自UIControl,自定义一个自己想要的button

@interface EOCButton : UIControl

- (instancetype)initWithFrame:(CGRect)frame title:(NSString *)title image:(UIImage *)image;
#import "EOCButton.h"

@interface EOCButton ()
{
    NSString *_title;
    UIImage *_image;
    UILabel *_titleLabel;
    UIImageView *_imageView;
}

@end
@implementation EOCButton

- (instancetype)initWithFrame:(CGRect)frame title:(NSString *)title image:(UIImage *)image
{
    if (self = [super initWithFrame:frame]) {
        
        _title = title;
        _image = image;
        
        [self craeteTitleLabel];
        [self createImageView];
        
    }
    
    return self;
}

- (void)craeteTitleLabel
{
    _titleLabel = [[UILabel alloc] init];
    _titleLabel.font = [UIFont systemFontOfSize:12.f];
    _titleLabel.text = _title;
    _titleLabel.textColor = [UIColor redColor];
    _titleLabel.textAlignment = NSTextAlignmentCenter;
    [self addSubview:_titleLabel];
}

- (void)createImageView
{
    _imageView = [[UIImageView alloc] init];
    _imageView.image = _image;
    [self addSubview:_imageView];

}

- (void)layoutSubviews
{
    [super layoutSubviews];
    CGFloat labelHeight = 20.f;
    _titleLabel.frame = CGRectMake(0.f, self.eocH-labelHeight, self.eocW, labelHeight);
    _imageView.frame = CGRectMake(0.f, 0.f, self.eocW, self.eocH);
}

使用上和button一样

    EOCButton *btn = [[EOCButton alloc] initWithFrame:CGRectMake(100.f, 100.f, 100.f, 100.f) title:@"八点钟学院" image:[UIImage imageNamed:@"photo"]];
    [btn addTarget:self action:@selector(panAction) forControlEvents:UIControlEventTouchDown];
    [self.view addSubview:btn];

参考资料:
官方文档

推荐阅读更多精彩内容