iOS Touches,Events and Gestures
整理一些老生常谈的问题
有两种方式可以处理触摸事件:
1.在UIView的子类中依据响应链获取事件并处理
2.使用手势识别器来获取事件并处理
一:Hit-testing
当应用接收到一个触摸事件时,UIKit会自动将事件定向到最合适的响应器对象,这个定向的过程就是事件分发,目的是找到第一响应者.
通过hitTest机制找到第一响应者:
hitTest是UIView的方法,它大概长这样.
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard !isHidden && isUserInteractionEnabled && alpha > 0.01 && self.point(inside: point, with: event) else{return nil}
for subview in subviews.reversed() {
if let hitview = subview.hitTest(subview.convert(point, from: self), with: event) {
return hitview
}
}
return self
}
1.没有hidden,开启了UserInteractionEnabled,透明度大于0.01,并且point(inside:with:)返回true,需要满足这些条件
2.point(inside:with:)方法可以重新,可以达到改变事件分发方向的目的
3.subview是倒序遍历的,因为后添加的在上层,在数组的后面,应该更优先响应事件
4.这是一个树的查找节点,当没有子视图满足条件时,就返回这个节点(视图)
当触摸发生时,runloop会监听到事件并组装UIEvent对象,放到application的事件队列中, application的处理方法从队列获取事件,并调用keyWindow的hitTest方法
UIWindow继承自UIView,它也有hitTest方法
class TestWindow : UIWindow{
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
print("hitTest window")
return super.hitTest(point, with: event)
}
}
当点击rootViewController的时候,就会输出
//hitTest window
//hitTest window
为什么会输出2次:
目前查到的说法是系统会校验第一次的查找结果,我猜想可能如果两次结果不一样,事件会被丢弃之类的.
二:UIResponder
事件有几种类型,包括触摸事件、动作事件、远程控制事件和新闻事件。要处理特定类型的事件,响应程序必须重写相应的方法。例如,为了处理触摸事件,需要重写touches系列方法.
当点击发生时,UIKit会组装一个UITouch对象,它的Phase是.began,在找到第一响应者的时候,调用它的touchesBegan方法,将event和touch都传过去
当手指在屏幕上移动,会更新touch的point, 把Phase变成.move,并且触发响应者的touchesMove方法.
当手指离开屏幕,Phase变成.end,触发touchesEnd方法
除此之外,系统也可以自行结束触摸,比如来电话的时候,touchesCancel会被调用.
一个视图只接收与一个事件相关的第一个UITouch对象,即使当前有多个手指在触摸视图。
要接收额外的触摸,需要设置视图的isMultipleTouchEnabled属性为true.
除了处理事件,UIKit响应器还管理转发未处理的事件到应用程序的其他部分。如果一个给定的响应器不处理事件,它将该事件转发到响应器链中的下一个事件。UIKit动态管理响应器链,使用预定义的规则来决定哪个对象应该是下一个接收事件的对象。例如,一个视图将事件转发给它的父视图,一个vc的根视图将事件转发给这个vc。UIResponder的对象都实现了touches系列方法,并且在方法中会调用另一个responder的touches方法,以此传递事件,人人都有机会处理事件.
都有哪些规则:
1.view是viewController的root view,nextResponder是这个viewController.
2.view不是viewController的root view,nextResponder是superview.
3.如果viewController是window的root viewController, nextResponder是window。
4.如果vc1是被vc2 present调起来的,那么vc1的nextResponder是vc2.
5.如果vc1的view是添加在vc2的view上的,那么vc1的next是vc2的view
6.window 的 nextResponder 是 UIApplication 对象,
不过在iOS13以后是UIWindowScene的对象.
7.如果UIApplication也不能处理该事件或消息,则将其丢弃
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("touchesBegan VC1")
print(next?.debugDescription ?? "no des")
super.touchesBegan(touches, with: event)
}
let vc = ViewController.init()
vc.view.frame = view.bounds
addChild(vc)
view.addSubview(vc.view)
点击输出:
touchesBegan VC1
<UIView: 0x7fec37a0b850; frame = (0 0; 390 844); autoresize = W+H; layer = <CALayer: 0x600003276200>>
let vc = ViewController.init()
present(vc, animated: true, completion: nil)
点击输出:
touchesBegan VC1
<TestUIKit.ViewController2: 0x7f7912a1b7a0>
如果什么都不做,响应链是从头传到位的,然后事件被丢弃
通过重写touchs方法,既可以选择中断响应链,也可以选择调用next方法继续传递,当然也可以选择让父类处理.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
/*
do something
*/
// next?.touchesBegan(touches, with: event)
// super.touchesBegan(touches, with: event)
}
三:UIControl
UIView并没有中断响应链,但是其子类UIControl会.
class TestBtn : UIButton{
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
print("hitTest Btn")
return super.hitTest(point, with: event)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("touchesBegan Btn")
super.touchesBegan(touches, with: event)
}
}
在多层View上添加TestBtn, 每层的view和vc的touches方法都没有被调用,但是hitTest是正常调用的,从window到Btn,事件分发照常进行.
TestBtn的touchesBegan被调用.
如果在btn里主动传递响应链,能否成功
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("touchesBegan Btn")
// super.touchesBegan(touches, with: event)
next?.touchesBegan(touches, with: event)
}
答案是可以的,UIControl仅仅是在touches方法上做了些事情,并中断响应链
如果在btn上添加view,保持btn的touches正常运行
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("touchesBegan Btn")
super.touchesBegan(touches, with: event)
}
可以看到view和btn的touches都可以执行,btn再往下的响应链就被中断了
输出:
hitTest window
hitTest Btn
hitTest TopView
touchesBegan topView
touchesBegan Btn
到目前为止都是hit-testing模式,上面的例子中第一响应者是btn上面的view.
四:Become first responder
当UITextField被点击时,UITextField的hitTest和Touches方法都会被调用,
调用UITextField的becomeFirstResponder,不会执行hitTest和Touches,也没有事件被创建,但是可以让testfield直接进入响应事件的状态.类似的还有UITextview, UIMenuController等.
becomeFirstResponder系列方法是UIResponder的,那么其他子类,如UIView,UIControl如何表现.
重写canBecomeFirstResponder
class testBtn : UIButton{
override var canBecomeFirstResponder: Bool{
get{
true
}
}
}
//vc class
@objc func onPressBtn(sender:UIButton, event:UIEvent){
print(event)
}
//viewDidLoad
btn.becomeFirstResponder()
结果没有调用onPressBtn,一样也没有调用btn的hittest和touches
那么键盘是如何弹出的
UITextField的定义是这样的
open class UITextField : UIControl, UITextInput, NSCoding, UIContentSizeCategoryAdjusting {}
那么弹起键盘肯定和UITextInput有关,一路看下去,
public protocol UITextInput : UIKeyInput {}
public protocol UIKeyInput : UITextInputTraits {}
public protocol UITextInputTraits : NSObjectProtocol {}
那么就从UITextInputTraits开始尝试实现,这个协议定义了键盘相关的定制,并且所有的内容都有default,因此直接遵循就行了
然后重写canBecomeFirstResponder和touchesBegan实现主动和被动弹出键盘(这么打算
class TestInputVew : UIView, UITextInputTraits{
override var canBecomeFirstResponder: Bool{
get{
true
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if !isFirstResponder{
becomeFirstResponder()
}
}
}
试了一下发现并没有弹出键盘.
还得实现再往上一层的协议UIKeyInput, 这个协议是处理增加和删除文本的
public protocol UIKeyInput : UITextInputTraits {
var hasText: Bool { get }
func insertText(_ text: String)
func deleteBackward()
}
实现这个协议,暂时不用写什么内容
class TestInputVew : UIView, UIKeyInput{
var text = ""
var hasText: Bool{
get{
!text.isEmpty
}
}
func insertText(_ text: String) {
}
func deleteBackward() {
}
override var canBecomeFirstResponder: Bool{
get{
true
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if !isFirstResponder{
becomeFirstResponder()
}
}
}
再来试一下,键盘就弹出来了,并且insertText和deleteBackward就可以实现自定义的输入交互了.
根据developer documentation中的描述,becomeFirstResponder可以让responder尝试成为第一响应者,如果成功,则返回true,在这之前会先查看canBecomeFirstResponder是否返回true.
成为第一响应者,却和事件分发以及响应链没有关系,是系统预定义的一些事件反应,比如键盘,比如menu控件.
这些事件只能由becomeFirstResponder来触发,可以通过实现相关协议达成触发事件的条件.
五: UIEvents
应用程序可以接收许多不同类型的事件,包括触摸事件、动作事件、远程控制事件和按下事件。运动事件是由UIKit触发的,与Core Motion框架报告的运动事件是分开的。远程控制事件允许响应对象从外部附件或耳机接收命令,以便它可以管理音频和视频,例如,播放视频或跳过到下一个音频轨道。按下事件表示与游戏控制器、AppleTV遥控器或其他有物理按钮的设备的交互.
触摸事件是最常见的,它被传递给最初发生触摸的视图,具体就是前面的事件分发和响应链.
一个UIEvent对象包含一个或多个UITouch
一次事件序列只有一个UIEvent对象,甚至不止一次序列,当触摸结束之后,重新触摸,基本还是之前的那个event对象
从输出可以看到一直是同一个UITouchesEvent对象.
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
if let obj = event{
print(Unmanaged.passRetained(obj))
}
super.touchesMoved(touches, with: event)
}
六:手势识别器和hit-test view之间的选择
手势识别器(Gesture recognizers)是在视图中处理触摸或按压事件的最简单方法。可以将一个或多个手势识别器附加到任何视图。手势识别器封装了处理和解释该视图传入事件所需的所有逻辑,并将它们与已知的模式匹配。当检测到匹配时,手势识别器通知它分配的目标对象,它可以是一个视图控制器,视图本身,或应用程序中的任何其他对象.
默认情况下,手势是和响应链共存的,它相对于获取触摸事件的独立方法,并且可以匹配多种类型,tap,swipe,pan等等,
window调用第一响应者的touches方法和给手势识别器发送消息可能是紧跟着发生的.
let tap = UITapGestureRecognizer.init(target: self, action: #selector(onPressTap(tap:)))
v1.addGestureRecognizer(tap)
@objc func onPressTap(tap:UITapGestureRecognizer){
print("onPressTap")
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("touchesBegan vc1")
if let count = event?.allTouches?.count{
print("vc1 touches count: \(count)")
}
super.touchesBegan(touches, with: event)
}
输出:
touchesBegan vc1
vc1 touches count: 1
onPressTap
- delaysTouchesBegan
delaysTouchesBegan属性默认是false,为true时,会先把触摸事件通知手势识别器,如果手手势识别器不能匹配,window才会调用第一响应者的touches方法.
也就是说hit-test view的响应会延迟约0.15ms.
let tap = UITapGestureRecognizer.init(target: self, action: #selector(onPressTap(tap:)))
tap.numberOfTapsRequired = 2
tap.delaysTouchesBegan = true
v1.addGestureRecognizer(tap)
缓慢点击2次输出
touchesBegan vc1
vc1 touches count: 1
touchesBegan vc1
vc1 touches count: 1
快速点击输出
onPressTap
- cancelsTouchesInView
cancelsTouchesInView默认是true, 当手势识别器匹配成功时,UIKit会调用hit-test view的touchesCancelled方法.此时相当于都响应了事件.
为false时则不会调用cancel
let tap = UITapGestureRecognizer.init(target: self, action: #selector(onPressTap(tap:)))
tap.numberOfTapsRequired = 2
v1.addGestureRecognizer(tap)
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
print("touchesCancelled vc1")
super.touchesCancelled(touches, with: event)
}
双击输出
touchesBegan vc1
onPressTap
touchesCancelled vc1
tap.cancelsTouchesInView = false
此时输出
touchesBegan vc1
onPressTap
- delaysTouchesEnded
delaysTouchesEnded默认为true,手势识别失败时,会延迟大概0.15ms,期间没有接收到别的touch才会调用touchesEnded.
为false时不会延迟,立即调用touchesEnded
let tap = UITapGestureRecognizer.init(target: self, action: #selector(onPressTap(tap:)))
tap.numberOfTapsRequired = 2
v1.addGestureRecognizer(tap)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("touchesBegan vc1")
super.touchesBegan(touches, with: event)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
print("touchesCancelled vc1")
super.touchesCancelled(touches, with: event)
}
点击一下,先输出
touchesBegan vc1
然后延迟0.5秒输出
touchesEnded vc1
tap.delaysTouchesEnded = false
此时几乎不会延迟,紧跟着输出
touchesBegan vc1
touchesEnded vc1
七:CALayer的hitTest方法
CALayer的hitTest类似UIView,只不过没有UIEvent对象,它也会受到isHidden,opacity,isOpaque,和是否点击到范围内的条件限制,遍历子layer,找到树符合条件的最远叶子.
override func hitTest(_ p: CGPoint) -> CALayer? {
guard !isHidden && opacity > 0.01 && !isOpaque && contains(p) else{return nil}
if let sublayers = self.sublayers?.reversed(){
for layer in sublayers{
if let hitLayer = layer.hitTest(layer.convert(p, from: self)){
return hitLayer
}
}
}
return self
}
使用案例
layer1.frame = .init(x: 0, y: 100, width: 100, height: 100)
layer1.backgroundColor = UIColor.white.cgColor
layer.addSublayer(layer1)
layer2.frame = .init(x: 0, y: 0, width: 30, height: 30)
layer2.backgroundColor = UIColor.orange.cgColor
layer1.addSublayer(layer2)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let point = touches.first?.location(in: self) else{return}
if let l = layer.hitTest(point), l == layer1{
print("layer1 touch")
}
if let l = layer.hitTest(point), l == layer2{
print("layer2 touch")
}
}
image.png
点击白色时,只输出layer1 touch
点击橙色时,只输出layer2 touch