美文网首页
iOS Touches,Events and Gestures

iOS Touches,Events and Gestures

作者: Trigger_o | 来源:发表于2022-06-24 12:07 被阅读0次

    整理一些老生常谈的问题

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

    相关文章

      网友评论

          本文标题:iOS Touches,Events and Gestures

          本文链接:https://www.haomeiwen.com/subject/pwpxvrtx.html