Touch Event Handling、Responder Chain、Gesture Recognizers

这篇文章将对UITouchUIEventUIResponderUIGestureRecognizerResponder Chain之间相互纠葛的关系做一个梳理。

UITouch

先说UITouch,在屏幕上的每一次动作事件都是一次touch,它可以看成是整个触摸事件处理中最小的单位了,UITouch封装了一个touch所携带的基本信息。主要包括:

  • touch发生在的view或者window (view)
  • touch发生在view或者window中的位置 (location)
  • touch的近似半径
  • touch的力度(在一些支持3D Touch的设备上或者Apple Pencil)

还包括touch发生时的时间戳,用户点击屏幕的次数等等,具体的可以查阅苹果官方文档UITouch,就不过多翻译了。

UIEvent

在触摸事件中只有UITouch还是不够的,因为要定义是什么样的触摸事件发生时,可能已经有多个touch参与其中了,也就是说,一个或多个touch构成了一次event。触摸事件根据具体情况可以有点击事件、滑动事件、拖拽事件、双指缩放等。可以通过UIEventallTouches属性来获取所有与该event关联的touch。

当然,touch events只是App能接收到的事件中的一种,UIEvent包括以下几种类型:

1
2
3
4
5
6
enum UIEventType {
case touches //触摸屏幕
case motion //设备的运动,例如摇晃
case remoteControl //例如用耳机控制音乐等
case presses //点击物理按钮
}

关于UIEvent的更详细了解可以查阅苹果官方文档UIEvent

UIResponder

一个或多个touch组成了一个event,那又是谁来响应event呢?UIResponder出场了~。UIResponder是一个抽象类,定义了响应事件的一些接口,例如对于touch events,接口如下:

1
2
3
4
func touchesBegan(Set<UITouch>, with: UIEvent?)
func touchesMoved(Set<UITouch>, with: UIEvent?)
func touchesEnded(Set<UITouch>, with: UIEvent?)
func touchesCancelled(Set<UIToutch>, with: UIEvent?)

如果一个UIResponder子类想要响应touch events,就需要实现上面这些接口。

对于motion events,接口如下:

1
2
3
func motionBegan(UIEventSubtype, with: UIEvent?)
func motionEnded(UIEventSubtype, with: UIEvent?)
func motionCancelled(UIEventSubtype, with: UIEvent?)

同时UIResponder还定义了管理事件响应链的一些接口:

1
2
3
4
5
6
var next: UIResponder?
var isFirstResponder: Bool
var canBecomeFirstResponder: Bool
func becomeFirstResponder()
func canResignFirstResponder: Bool
func resignFirstResponder()

在事件发生时,UIKit会事件派发给first responder,不同类型的事件,first responder也不同。对于touch event来说,first responder就是事件发生在的那个view。在平时开发中出现比较多的地方就是使用UITextFieldUITextView时,调起和消失键盘。我们可以自己定义一个输入控件,让它是first responder,这需要实现canBecomeFirstResponder,让其返回true。否则即使调用了becomeFirstResponder也不会成为first responder。那first responder更多的会用来干什么呢?苹果的文档中有这样一句描述:

1
UIKit dispatches some types of events, such as motion events, to the first responder initially.

UIKit会把设备摇动这样的事件派发给第一响应者。

next responder好理解一些,当前的responder也许并不想或者不能处理某个事件,那这时可以把事件传出去,交给下一个响应者。UIResponder子类需要实现该方法,指明自己合适的下一个响应者。

Response Chain

UIKit中的UIApplicationUIViewControllerUIViewUIWindow都已经继承了UIResponder。在上面讲到了UIResponder子类必须实现nextResponder,那这些类是如何实现的呢?

  • UIView
  • 如果view是一个viewController的root view,它的next responder是viewController。
  • 如果view不是一个viewController的root view,它的next responder是它的superview。
  • UIViewController
  • 如果viewController的view是一个window的root view,它的next responder是该window。
  • 如果viewController是由另一个viewController展示出来的,它的next responder是展示它出来的这个viewController。
  • UIWindow. 它的next responder是application对象。
  • UIApplication. 它的next responder是app delegate,但是这个app delegate要继承UIResponder,还不能是view、viewController或者application自己。

当然,如果一个对象自己能够处理响应事件,它的next responder可以设置为nil的,不将事件传递出去,比如UIButtonUITextFiled等。

明确了这些,一个事件的传递过程,最终会由谁来处理基本就明了了,下面这张图来自苹果的官方文档,描述了整个事件传递的过程:

在重写touchesXXX等方法时,由两种方式可以将事件传递给下一个响应者,一个是调用self.next?.touchesXXX,此时一定能将事件传递出去,一个是调用super.touchesXXX,能不能传递出去,就看你重写的那个控件支不支持将事件传递出去了。

当然,这个响应链是可以被打破的,我们可以重写一个UIKit中的UIResponder对象,实现它的nextResponder。

应用

了解这些在实际开发中有什么帮助呢?有一个关于UIScrollView的例子,假如我们在其中放了一些UITextField,在用户输入完成后,想让用户点击一下空白背景就将键盘消失,就可以利用响应链来巧妙的实现了。因为UIScrollView默认是不会把触摸事件传递出去的,我们可以重写touch的那一系列方法将事件传递出去,然后它的nextResponder的touch方法就可以得到响应了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class ScrollView: UIScrollView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if !isDragging {
self.next?.touchesBegan(touches, with: event)
} else {
super.touchesBegan(touches, with: event)
}
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
if !isDragging {
self.next?.touchesMoved(touches, with: event)
} else {
super.touchesMoved(touches, with: event)
}
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
if !isDragging {
self.next?.touchesEnded(touches, with: event)
} else {
super.touchesEnded(touches, with: event)
}
}

override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
if !isDragging {
self.next?.touchesCancelled(touches, with: event)
} else {
super.touchesCancelled(touches, with: event)
}
}
}

如果这个scrollView是放在viewController的view中的,那touch事件是可以传递到viewController中的,于是:

1
2
3
4
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
textfield.resignFirstResponder()
super.touchesBegan(touches, with: event)
}

当然,这个问题的解决方式不止这一种。

寻找发生Event的view

事件的传递过程了解了,那UIKit是怎么来确定事件到底是发生在哪个view上呢?这就用到了如下两个方法,有点像游戏场景里所讲的碰撞检测:

1
2
func point(inside point: CGPoint, with event: UIEvent?) -> Bool
func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?

pointInside用来返回事件发生的点是否在当前的view内,hitTest用来返回事件所在的view。对于同一个view来说,如果它的pointInside返回false,那它的hitTest将返回nil。如果它的pointInside返回true,hitTest会继续去遍历它的子view,它的子view依然会走这个逻辑,直到找到事件最终发生的view,再层层返回。

具体的看一下这张苹果官方文档里的布局:

控件添加的顺序是A->B->C->D->E,点击E后的整个寻找过程是:

A--- pointInside withEvent --- isInside:true
C--- pointInside withEvent --- isInside:true
E--- pointInside withEvent --- isInside:true
E--- hitTest withEvent ---hitTestView:E
C--- hitTest withEvent ---hitTestView:E
A--- hitTest withEvent ---hitTestView:E
  • 一旦在某个分支pointInside返回了true,那其他分支就不去遍历了。
  • 一旦父view的pointInside返回了false,不会再继续遍历它的子view了。
  • 在同一个视图层级,先遍历后添加的视图。

这个过程用代码模拟如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}

if ([self pointInside:point withEvent:event]) {
// 事件发生在该view或其子view上
for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
CGPoint convertedPoint = [subview convertPoint:point fromView:self];
UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
if (hitTestView) {
// 事件发生在子view上
return hitTestView;
}

// 事件发生在自身
return self;
}
}

return nil;
}

应用

有两个比较常见的场景可以利用到上面的这个寻找过程:

  • 扩展UIButton的点击区域,有的时候为了视觉效果UIButton并没有那么大,但是为了能更好的响应用户的点击,需要隐形的扩展它的点击区域。
1
2
3
4
5
6
7
8
9
10
class ExpandedButton: UIButton {

public var padding = CGFloat(0)

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let extendedBounds = bounds.insetBy(dx: -padding, dy: -padding)
return extendedBounds.contains(point)
}

}
  • 渗透点击事件,两个视图叠放在一起,并不是作为子视图嵌套的,这时想要在点击上面的视图时,下面的视图得到相应的响应,就可以重写上面视图的pointInside,让其返回false,下面的视图就可以得到响应了。

UIGestureRecognizer

每个UIResponder对象都可以重写touch event那一套接口,来监听触摸的动作,那为什么还需要UIGestureRecognizer呢?一般我们会处理到的手势有TapSwipePanLongPress等等,要识别出用户究竟正在进行哪种手势操作,需要在touch event的接口中进行各种计算才判断的出来,这显然在使用时非常麻烦,于是就有了系统为我们封装好的各种手势。UIGestureRecognizer还是造福了广大的开发者。

那如果即重写了touch event的接口,又添加了UIGestureRecognizer会怎么样呢?

写了个小测试,自定义一个view,重写了touch event的一套接口,然后再添加一个UITapGestureRecognizer,点击一下,发现touchesCancelled被执行了,如果长按,touchesEnded仍然执行了。找到了官方的这段解释:

window会把touch事件先派发给gesture recognizer,如果gesture recognizer识别不出该手势,view会接收到全部的touch事件。如果能识别出该手势,view的其余touch事件就会被取消。此外,UIGestureRecognizer也不会按照上面所说的事件传递链去传递,加在哪个view上就肯定是那个view去响应。

不能接收触摸事件的情况

对一个view设置下面任意一种情况时,该view不能接收触摸事件了:

  • userInteractionEnabled = NO
  • hidden = YES
  • alpha = 0

从设备接收到用户触摸,到系统将其处理成UIEvent再下发到应用层,这个过程先个挖坑,然后明白了再总结~

本文作者:意林
本文链接:http://shinancao.cn/2017/05/11/iOS-Event-Handling/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!