iOS Touches,Events and Gestures

2022-06-24  本文已影响0人  Trigger_o

整理一些老生常谈的问题

有两种方式可以处理触摸事件:
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

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

 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

上一篇下一篇

猜你喜欢

热点阅读