浅谈 iOS 事件的传递和响应过程

5,380 阅读7分钟

问题

  • iOSView 的事件到底是怎么传递响应的?
  • 为什么 父View 关闭了事件响应时,子View 就无法响应事件? 底层原理?
  • 如何扩大 Button 的点击范围 ?
  • 如何让 父View子View 同时响应同一事件?默认情况下只会响应 子View 的事件回调。
  • 为什么 子View 关闭了事件,但其 父View 开启事件的情况下,点击 子View 时,父View 可以正常响应事件?
  • 为什么 子View 是 UIView时,如果没有添加手势,点击子 View时,会由其父View来响应,而 子View 是 UIControl 时,子View 没有添加手势,一样不会由 父View 来响应
  • ...

分析

iOS 的事件可以分为三种

  • Touch Events(触摸事件)
  • Motion Events(运动事件,比如重力感应和摇一摇等)
  • Remote Events(远程事件,比如用耳机上得按键来控制手机)

下面主要讲解 Touch Events(触摸事件) Touch Events事件的整个过程可以分为 传递响应 2 个阶段,

  • 传递: 是当我们触摸屏幕时,为我们找出最适合的 View
  • 响应: 当我们找出最适合的 View 后,此时只是找到了最合适的 View,但未必 此 View 可以响应此事件,所以需要继续找出能响应此事件的 View

传递过程

每当手指接触屏幕,操作系统会把事件传递给当前的 App, 在 UIApplication接收到手指的事件之后,就会去调用`UIWindow的hitTest:withEvent:,看看当前点击的点是不是在window内,如果是则继续依次调用其 subView的hitTest:withEvent:方法,直到找到最后需要的view。调用结束并且hit-test view确定之后,便可以确定最合适的 View。

  • 引用几张图来说明

递归是向界面的根节点UIWindow发送hitTest:withEvent:消息开始的,从这个消息返回的是一个UIView,也就是手指当前位置最前面的那个 hittest view。 当向UIWindow发送hitTest:withEvent:消息时,hitTest:withEvent:里面所做的事,就是判断当前的点击位置是否在window里面,如果在则遍历window的subview然后依次对subview发送hitTest:withEvent:消息(注意这里给subview发送消息是根据当前subview的index顺序,index越大就越先被访问)。如果当前的point没有在view上面,那么这个view的subview也就不会被遍历了。当事件遍历到了view B.1,发现point在view B.1里面,并且view B.1没有subview,那么他就是我们要找的hittest view了,找到之后就会一路返回直到根节点,而view B之后的view A也不会被遍历了。

  • 下面是 - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event 方法的内部实现
 - (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;
    }
}

上面的代码来自这里

响应过程

  • 个人对响应过程的理解如下:

当我们知道最合适的 View 后,事件会 由上向下【子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;

如果有实现那么就由此 View 响应,如果没有实现,那么就会传递给他的下一个响应者【子view -> 父view,控制器view -> 控制器】, 这里我们可以做一个简单的验证,在默认情况下 UIView 是不响应事件的,UIControl 就算没有添加手势一样的会由他来响应, 这里可以使用 runtime查看 UIView 和 UIControl 的方法列表, 或 查看 UIKit 源码 可知, UIView 没有实现如上的 touchesBegan方法,而 UIControl 是实现了如上的相关方法,所以验证了刚才的 UIView 不响应,和 UIControl 的响应。一旦找到最合适响应的View就结束, 在执行响应的绑定的事件,如果没有就抛弃此事件。

我的验证

  • 首先处理添加了手势时,其便可以处理事件。
  • 我们创建一个view A 在 A 中添加一个 view B, 如果我们给 A 加了手势,B没有加手势,
  • 我们在点击 B 时,会响应 A 的事件,非常正常的情况,那么它是怎么判断 B 是否可以处理的呢?
  • 我们现在给 B 加一个手势,那么同样的操作时会触发 B 的手势,现在我们 给 B 增加一个方法,
	@implementation BMSonView
	- (NSArray<UIGestureRecognizer *> *)gestureRecognizers {
	    NSLog(@"%@", self);
	    return @[];
	}

手势返回 @[],此时点击 B 只会触发 A 的事件,由此可以说明在判断 view 是否可以处理事件实现是判断 gestureRecognizers 即是否添加了手势,上面提到了还有判断如下的方法是否实现了,默认情况下 UIView 是没有实现如下的方法的,使用在没有添加手势时他不响应事件。

- (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;

如果我们手动实现了如上的方法时,就算没有给 B 添加手势,点击 B 时, 事件不会响应 A 的方法,会到上面的方法中。从 UIControl 的源码便可清除看到。

所以个人理解:

  • 事件在传递时和上面的 hit 方法有关,一层层向上传递,【窗口---> view】由其相应的 view 中具体的实现来确定谁才是是最合适响应的view

  • 在响应时,又上向下找出第一个能处理的view来处理事件,[view ---> 窗口],在寻找刚过程中 会判断是否增加了手势 和是否实现了如上的 触摸方法。

  • 至于 UIControl Button 的特殊事件相应,个人认为是在其m文件中实现了上面的4个方法,在这4个方法中做了相关的处理,这里可以从 UIControl 代码中在知道一些内容。

  • 所以如果想自己实现 UIControl Button ,首先要想办法处理好上面的4个方法。

  • 图如下

问题解答

  • iOS 中 View 的事件到底是怎么传递和响应的?

如上所描。

  • 为什么 父View 关闭了事件响应时,子View 就无法响应事件?

因为在事件传递的时,先到父view,当父view无法响应事,直接就跳过了遍历其子view,故只要父类关闭了事件,子 view 就已经没有机会响应事件了。

  • 如何扩大 Button 的点击范围?

扩大点击范围,无非就是想本来没有点击 btn 但想让 btn 响应事件,那么可以在 hitTest 方法中做适当的操作,当满足xxx条件时,强行返回 btn 来达到最佳点击范围的效果,相关的实现可以自行 Google ,有一些较优雅而简洁的方式。

  • 如何让 父View 和 子View 同时响应同一事件?

父View 和 子View同时响应同一事件,默认当点击子view时,如果ziview可以处理事件,那么其他父view 是不会响应的,但是在 父view 传到 子view 时我们在 hitTest 方法中是清楚知道的,使用可以在这里做相关的操作便实现了子view 和父view 同时响应事件的效果。

  • 为什么子View 关闭了事件,但其 父View 开启事件的情况下,点击 子View 时,父View 可以响应事件?

子view关闭了事件,事件的传递是 父view 到子view,在 父view时,父view可以响应,那么会继续访问其 子view是否可以响应,如果此时子view不可以响应,那么他会直接返回 父view,所以 子View 关闭了事件 父View 正常执行事件是必然的。

  • 为什么 子View 是 UIView时,如果没有添加手势,点击子 View时,会由其父View来响应,而 子View 是 UIControl 时,子View 没有添加手势,一样不会由 父View 来响应

这个问题可以见上面的寻找可以响应的 view 来解决,UIControl 实现了如上的 4 大方法,而 UIView 没有实现。

  • 这里其实还有许多内容待挖掘,比如:scrollview 的事件响应等。

参考资料

声明