一些 iOS 基础知识,业务开发中经常用到,面试时也常会被问到,这里总结一下,此篇文章讲解处理屏幕点击事件。

Apple Develop Xcode

概述

点击事件的处理分成两个部分,第一部分是找到包含点击事件的 Responder,第二部分是决定处理点击事件的第一个 Responder。能够处理点击事件的 Responder 都是 UIResponder 的实例。

找到包含点击事件的 Responder

UIKit 通过调用 UIView 的 hitTest(_:with:) 方法来找到包含 point 的最远子孙视图(包含自身),下面是脑补的系统实现逻辑:

func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    if self.isHidden == true
        || self.isUserInteractionEnabled == false
        || self.alpha < 0.01 {
        return nil
    }
    
    // 保证自身包含 point

    guard self.point(inside: point, with: event) else {
        return nil
    }
    
    // 从叠层角度看,从上到下调用子视图的 hitTest(_:with:),

    // 找到第一个非空子孙视图就返回,然后停止

    for subview in self.subviews.reversed() {
        if let hitView = subview.hitTest(point, with: event) {
            return hitView
        }
    }
    
    return nil
}

下面给出 point(inside:with:) 的定义:

func point(inside point: CGPoint, with event: UIEvent?) -> Bool

所以如果要改变找到包含点击事件的 Responder 的流程,可按需重写 hitTest(_:with:) 或 point(inside:with:)。

示例 1:扩大按钮的点击区域

class SearchHistoryExpandButton: UIButton {
    
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        let newArea = CGRect(x: bounds.origin.x - 5,
                             y: bounds.origin.y - 10,
                             width: bounds.size.width + 10,
                             height: bounds.size.height + 20)
        return newArea.contains(point)
    }
    
}

示例 2:点击区域限制在四个角

Square with Corners

class PhotoEditorResizeView: UIView {

    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        if bounds.contains(point) {
            for area in caculateThumbAreas().values { // 四个角的 rect

                if area.contains(point) {
                    return true
                }
            }
        }
        return false
    }

}

决定处理点击事件的第一个 Responder

iOS Responder Chains

决定处理点击事件的第一个 Responder 的流程会顺着上图的实线走,如果 Text Field 不处理这个事件,UIKit 再发给 Text Field 的父 UIView,再给父父 UIView,然后是父父 UIView 的 UIViewController,此 UIViewController 是 UIWindow 的 rootViewController,所以下一步就是此 UIWindow,再下一步就是 UIApplication,如果实现 UIApplicationDelegate 的对象,也是 UIResponder 的实例,那么就会再传递给此 Delegate。

UIButton 即便没有添加 addTarget,也属于处理了点击事件的 Responder:

button.addTarget(self, action: #selector(onTap), for: .touchUpInside)

一个子 UIView 添加了 UITapGestureRecognizer,就算是处理了点击事件的 Responder,不会再传递给父 UIView:

addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onTap)))