触摸事件概述 事件类型 ┌─────────────────────────────────────────────────────────────────────┐ │ iOS 事件类型总览 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ │ Touch Events │ │ Motion Events │ │ Remote Events │ │ │ │ 触摸事件 │ │ 运动事件 │ │ 远程控制事件 │ │ │ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ │ │ │ • 手指触摸屏幕 │ │ • 摇一摇 │ │ • 耳机线控 │ │ │ │ • 多点触控 │ │ • 加速度计 │ │ • 蓝牙控制 │ │ │ │ • 3D Touch │ │ • 陀螺仪 │ │ • CarPlay │ │ │ │ • Apple Pencil │ │ │ │ │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ │ │ ┌─────────────────┐ │ │ │ Press Events │ iOS 9+ 物理按键事件 │ │ │ 按压事件 │ (Apple TV Remote, 游戏手柄等) │ │ └─────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘UITouch 生命周期 /* ═══════════════════════════════════════════════════════════════════ UITouch 状态流转 ═══════════════════════════════════════════════════════════════════ 手指按下 手指移动 手指抬起 │ │ │ ▼ ▼ ▼ ┌──────┐ 移动中 ┌──────┐ 移动结束 ┌──────┐ │ Began │ ─────────→│ Moved │ ──────────────→│ Ended │ └──────┘ └──────┘ └──────┘ │ │ │ │ │ │ │ 系统中断(如来电) │ │ │ │ │ │ │ ▼ │ │ │ ┌──────────┐ │ │ └───→│Cancelled │←────┘ │ └──────────┘ │ │ │ └────────────────────────────────────┘ 事件结束 */ // MARK: - UITouch 核心属性 extension UITouch { /// 触摸阶段 public enum Phase : Int { case began// 手指触摸屏幕 case moved// 手指在屏幕上移动 case stationary// 手指在屏幕上但没有移动 case ended// 手指离开屏幕 case cancelled// 系统取消触摸(如来电) @available ( iOS13.4 , * ) case regionEntered// 指针进入区域(iPadOS 光标) @available ( iOS13.4 , * ) case regionMoved// 指针在区域内移动 @available ( iOS13.4 , * ) case regionExited// 指针离开区域 } /// 触摸类型 public enum TouchType : Int { case direct// 直接触摸(手指) case indirect// 间接触摸(Apple TV Remote) case pencil// Apple Pencil @available ( iOS13.4 , * ) case indirectPointer// 间接指针(鼠标/触控板) } } // MARK: - UITouch 信息获取示例 class TouchInfoView : UIView { override func touchesBegan ( _ touches: Set < UITouch > , with event: UIEvent ? ) { guard let touch= touches. first else { return } // 基本信息 let location= touch. location ( in : self ) // 在当前视图中的位置 let previousLocation= touch. previousLocation ( in : self ) // 上一次位置 let timestamp= touch. timestamp// 时间戳 let tapCount= touch. tapCount// 点击次数(双击等) let phase= touch. phase// 当前阶段 let type= touch. type// 触摸类型 // 压力信息(3D Touch / Apple Pencil) let force= touch. force// 当前压力 let maximumPossibleForce= touch. maximumPossibleForce// 最大压力 let normalizedForce= force/ maximumPossibleForce// 归一化压力 0~1 // Apple Pencil 专属 if touch. type== . pencil{ let altitudeAngle= touch. altitudeAngle// 倾斜角度(与屏幕平面) let azimuthAngle= touch. azimuthAngle ( in : self ) // 方位角 let azimuthVector= touch. azimuthUnitVector ( in : self ) // 方位向量 } // 触摸半径(估计) let majorRadius= touch. majorRadius// 触摸区域半径 let majorRadiusTolerance= touch. majorRadiusTolerance// 容差 print ( "" " 📍Touch Info : Location : \( location) Phase : \( phase) TapCount : \( tapCount) Force : \( normalizedForce) "" ") } } UIEvent 事件容器 /* ═══════════════════════════════════════════════════════════════════ UIEvent 结构 ═══════════════════════════════════════════════════════════════════ ┌─────────────────────────────────────────────────────────────────┐ │ UIEvent │ ├─────────────────────────────────────────────────────────────────┤ │ type: EventType // 事件类型 │ │ subtype: EventSubtype // 子类型 │ │ timestamp: TimeInterval // 时间戳 │ ├─────────────────────────────────────────────────────────────────┤ │ allTouches: Set<UITouch>? // 所有触摸点 │ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ Touch 1 │ │ Touch 2 │ │ Touch 3 │ ...多点触控 │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ */ // UIEvent 类型 extension UIEvent { public enum EventType : Int { case touches// 触摸事件 case motion// 运动事件 case remoteControl// 远程控制事件 case presses// 按压事件 @available ( iOS13.4 , * ) case scroll// 滚动事件 @available ( iOS13.4 , * ) case hover// 悬停事件 case transform// 变换事件 } } // 获取特定视图上的触摸 extension UIEvent { func touches ( for view: UIView ) - > Set < UITouch > ? { return allTouches? . filter { $0 . view== view} } func touches ( for window: UIWindow ) - > Set < UITouch > ? { return allTouches? . filter { $0 . window== window} } func touches ( for gestureRecognizer: UIGestureRecognizer ) - > Set < UITouch > ? { return allTouches? . filter { touchin touch. gestureRecognizers? . contains ( gestureRecognizer) ? ? false } } } 事件传递全流程图解 完整事件传递链路 ═══════════════════════════════════════════════════════════════════════════ 触摸事件完整传递流程 ═══════════════════════════════════════════════════════════════════════════ ┌──────────────────────────────────────────────────────────────────────┐ │ 硬件层 │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ 触摸屏幕硬件 │ │ │ │ 检测到手指触摸,生成触摸数据 │ │ │ └─────────────────────────────┬───────────────────────────────────┘ │ └────────────────────────────────│────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ IOKit 层 │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ IOHIDEvent(硬件事件) │ │ │ │ 封装触摸数据为 IOHIDEvent 事件 │ │ │ └─────────────────────────────┬───────────────────────────────────┘ │ └────────────────────────────────│────────────────────────────────────┘ │ │ Mach Port 传递 ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ SpringBoard 进程 │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ 接收 IOHIDEvent,判断前台 App,通过 Mach Port 转发给 App 进程 │ │ │ └─────────────────────────────┬───────────────────────────────────┘ │ └────────────────────────────────│────────────────────────────────────┘ │ │ Mach Port 传递 ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ App 进程 │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ Source1(Mach Port) │ │ │ │ RunLoop 的 Source1 接收到 Mach Port 消息 │ │ │ └─────────────────────────────┬───────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ Source0(触发) │ │ │ │ Source1 触发 Source0,将事件封装为 UIEvent │ │ │ └─────────────────────────────┬───────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ UIApplication │ │ │ │ application.sendEvent(event) → 发送到 UIWindow │ │ │ └─────────────────────────────┬───────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ UIWindow │ │ │ │ 执行 Hit-Test 寻找最佳响应视图 │ │ │ └─────────────────────────────┬───────────────────────────────────┘ │ │ │ │ │ ┌─────────────────────┴─────────────────────┐ │ │ │ Hit-Test 过程 │ │ │ │ │ │ │ │ ┌─────────────────────────────────┐ │ │ │ │ │ hitTest:withEvent: │ │ │ │ │ │ pointInside:withEvent: │ │ │ │ │ │ 从后向前遍历子视图 │ │ │ │ │ │ 递归查找最深层可响应视图 │ │ │ │ │ └─────────────────────────────────┘ │ │ │ │ │ │ │ └─────────────────────┬─────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ 找到 Hit-Test View │ │ │ │ (最适合处理触摸的视图) │ │ │ └─────────────────────────────┬───────────────────────────────────┘ │ │ │ │ │ ┌───────────────────────┴───────────────────────┐ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ 手势识别器 │ ←─ 同时接收事件 ─→ │ 触摸方法 │ │ │ │ Gesture │ │ touches... │ │ │ │ Recognizers │ │ 方法 │ │ │ └──────┬───────┘ └──────┬───────┘ │ │ │ │ │ │ │ 手势识别成功 │ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ 响应者链传递 │ │ │ │ HitTestView → SuperView → ... → ViewController → Window │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘ ═══════════════════════════════════════════════════════════════════════════时序图 ═══════════════════════════════════════════════════════════════════════════ 事件传递时序图 ═══════════════════════════════════════════════════════════════════════════ 时间 → │ │ ┌─────────┐ ┌──────────┐ ┌────────┐ ┌────────┐ ┌─────┐ ┌─────────┐ │ │Hardware │ │SpringBoard│ │RunLoop │ │UIApp │ │UIWin│ │HitTestV │ │ └────┬────┘ └─────┬────┘ └───┬────┘ └───┬────┘ └──┬──┘ └────┬────┘ │ │ │ │ │ │ │ │ │ IOHIDEvent │ │ │ │ │ │ │───────────→│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ Mach Msg │ │ │ │ │ │ │─────────→│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ Source1 │ │ │ │ │ │ │─────────→│ │ │ │ │ │ │ │ │ │ │ │ │ │ UIEvent │ │ │ │ │ │ │←─────────│ │ │ │ │ │ │ │ │ │ │ │ │ │ sendEvent │ │ │ │ │ │──────────┼────────→│ │ │ │ │ │ │ │ │ │ │ │ │ │ hitTest │ │ │ │ │ │ │────────→│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ 递归查找 │ │ │ │ │ │ │─────────→│ │ │ │ │ │ │ │ │ │ │ │ │ │ 返回View │ │ │ │ │ │ │←─────────│ │ │ │ │ │ │ │ │ │ │ │ │ HitView │ │ │ │ │ │ │←────────│ │ │ │ │ │ │ │ │ │ │ │ │ 分发 touches... │ │ │ │ │ │ │─────────┼─────────→│ │ │ │ │ │ │ │ ▼ │ │ │ │ │ │ ═══════════════════════════════════════════════════════════════════════════Hit-Test 机制详解 Hit-Test 核心算法 // MARK: - Hit-Test 默认实现(伪代码) /* ═══════════════════════════════════════════════════════════════════ Hit-Test 算法流程 ═══════════════════════════════════════════════════════════════════ hitTest:withEvent: 方法流程: ┌─────────────────────────────────────────────────────────────┐ │ 1. 检查自身是否可以接收事件 │ │ • hidden == false │ │ • userInteractionEnabled == true │ │ • alpha > 0.01 │ └─────────────────────────┬───────────────────────────────────┘ │ ┌───────────────┴───────────────┐ │ 不满足条件 │ 满足条件 ▼ ▼ 返回 nil ┌─────────────────────┐ │ 2. 检查点是否在自身范围内 │ │ pointInside:withEvent │ └───────────┬─────────────┘ │ ┌───────────┴───────────┐ │ 不在范围内 │ 在范围内 ▼ ▼ 返回 nil ┌─────────────────┐ │ 3. 倒序遍历子视图 │ │ (后添加的先遍历) │ └────────┬────────┘ │ ┌────────▼────────┐ │ 4. 递归调用子视图的 │ │ hitTest 方法 │ └────────┬────────┘ │ ┌────────┴────────┐ │ 子视图返回非nil │ 全部返回nil ▼ ▼ 返回该子视图 返回自己 */ extension UIView { /// Hit-Test 默认实现(系统实现的等效代码) openoverride func hitTest ( _ point: CGPoint , with event: UIEvent ? ) - > UIView ? { // 1. 检查是否可以接收事件 guard isUserInteractionEnabled, ! isHidden, alpha> 0.01 else { return nil } // 2. 检查点是否在自身范围内 guard point ( inside: point, with: event) else { return nil } // 3. 倒序遍历子视图(后添加的视图在上层,优先响应) for subviewin subviews. reversed ( ) { // 坐标转换:将点从当前视图坐标系转换到子视图坐标系 let convertedPoint= subview. convert ( point, from: self ) // 4. 递归调用子视图的 hitTest if let hitView= subview. hitTest ( convertedPoint, with: event) { return hitView} } // 5. 没有子视图响应,返回自己 return self } /// 判断点是否在视图范围内(默认实现) openoverride func point ( inside point: CGPoint , with event: UIEvent ? ) - > Bool { return bounds. contains ( point) } } Hit-Test 可视化演示 // MARK: - Hit-Test 过程可视化 /* ═══════════════════════════════════════════════════════════════════ Hit-Test 遍历示例 ═══════════════════════════════════════════════════════════════════ 视图层级: ┌─────────────────────────────────────────────────────────────────┐ │ Window │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ RootView │ │ │ │ ┌─────────────────────┐ ┌─────────────────────────────┐ │ │ │ │ │ ViewA │ │ ViewB │ │ │ │ │ │ (subviews[0]) │ │ (subviews[1]) │ │ │ │ │ │ │ │ ┌───────────┐ ┌─────────┐ │ │ │ │ │ │ │ │ │ ViewB1 │ │ ViewB2 │ │ │ │ │ │ │ │ │ │ [0] │ │ [1] │ │ │ │ │ │ │ │ │ │ │ │ ✕ │ │ │ │ │ │ │ │ │ │ │ │ 触摸点 │ │ │ │ │ │ │ │ │ └───────────┘ └─────────┘ │ │ │ │ │ └─────────────────────┘ └─────────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ Hit-Test 遍历顺序(假设触摸点在 ViewB2 上): ① Window.hitTest │ ├─ 检查 Window: ✓ 可交互 ✓ 点在范围内 │ └─② RootView.hitTest(Window 唯一子视图) │ ├─ 检查 RootView: ✓ 可交互 ✓ 点在范围内 │ ├─ 倒序遍历子视图... │ ├─③ ViewB.hitTest (subviews[1],后添加,先遍历) │ │ │ ├─ 检查 ViewB: ✓ 可交互 ✓ 点在范围内 │ │ │ ├─ 倒序遍历子视图... │ │ │ ├─④ ViewB2.hitTest (subviews[1]) │ │ │ │ │ ├─ 检查 ViewB2: ✓ 可交互 ✓ 点在范围内 │ │ ├─ 无子视图 │ │ └─ 返回 ViewB2 ✓ ←─── 找到目标! │ │ │ └─ 返回 ViewB2(子视图找到结果,不再遍历 ViewB1) │ └─ 返回 ViewB2(子视图找到结果,不再遍历 ViewA) 最终结果:ViewB2 成为 Hit-Test View */ 自定义 Hit-Test 实战 // MARK: - 扩大按钮点击区域 class ExpandedButton : UIButton { /// 扩展的点击区域(负值表示向外扩展) var touchAreaInsets: UIEdgeInsets = UIEdgeInsets ( top: - 10 , left : - 10 , bottom: - 10 , right : - 10 ) override func point ( inside point: CGPoint , with event: UIEvent ? ) - > Bool { // 扩大判定区域 let expandedBounds= bounds. inset ( by: touchAreaInsets) return expandedBounds. contains ( point) } } // 使用示例 let button= ExpandedButton ( ) button. touchAreaInsets= UIEdgeInsets ( top: - 20 , left : - 20 , bottom: - 20 , right : - 20 ) // 四周各扩大20pt // MARK: - 让子视图超出父视图部分也能响应 class OverflowContainerView : UIView { override func hitTest ( _ point: CGPoint , with event: UIEvent ? ) - > UIView ? { // 先检查子视图(即使超出范围) for subviewin subviews. reversed ( ) { let convertedPoint= subview. convert ( point, from: self ) // 不使用 pointInside,直接让子视图判断 if let hitView= subview. hitTest ( convertedPoint, with: event) { return hitView} } // 子视图都没响应,再判断自身 if point ( inside: point, with: event) { return self } return nil } } /* 使用场景: ┌─────────────────────────────┐ │ ContainerView │ │ │ │ ┌─────────────────┐ │ │ │ PopupView ├─┼──┐ ← 弹出视图超出容器 │ │ │ │ │ │ └─────────────────┘ │ │ │ │ │ └─────────────────────────────┘ │ │ 触摸这里也能响应 ─┘ */ // MARK: - 穿透视图(让事件传递到下层) class PassthroughView : UIView { /// 需要穿透的子视图类型 var passthroughViews: [ UIView . Type ] = [ ] override func hitTest ( _ point: CGPoint , with event: UIEvent ? ) - > UIView ? { let hitView= super . hitTest ( point, with: event) // 如果点击到自身或指定类型的视图,返回 nil 让事件穿透 if hitView=== self { return nil } // 检查是否是需要穿透的视图类型 if let hitView= hitView{ for viewTypein passthroughViews{ if type ( of: hitView) == viewType{ return nil } } } return hitView} } /* 使用场景:遮罩层穿透 ┌─────────────────────────────────────┐ │ BottomView(可点击的按钮等) │ │ ┌─────────────────────────────┐ │ │ │ PassthroughView(半透明遮罩) │ │ ← 点击遮罩区域 │ │ ┌───────────────┐ │ │ 穿透到 BottomView │ │ │ 弹窗内容 │ │ │ │ │ │ (可点击) │ │ │ │ │ └───────────────┘ │ │ │ └─────────────────────────────┘ │ └─────────────────────────────────────┘ */ // MARK: - 自定义点击区域形状(圆形按钮) class CircleButton : UIButton { override func point ( inside point: CGPoint , with event: UIEvent ? ) - > Bool { // 计算圆心和半径 let center= CGPoint ( x: bounds. width/ 2 , y: bounds. height/ 2 ) let radius= min ( bounds. width, bounds. height) / 2 // 计算点到圆心的距离 let dx= point. x- center. xlet dy= point. y- center. ylet distance = sqrt ( dx* dx+ dy* dy) // 距离小于半径则在圆内 return distance <= radius} } // MARK: - 多区域响应(一个View内有多个可点击区域) class MultiTapAreaView : UIView { struct TapArea { let rect: CGRect let handler: ( ) - > Void } var tapAreas: [ TapArea ] = [ ] override func touchesEnded ( _ touches: Set < UITouch > , with event: UIEvent ? ) { guard let touch= touches. first else { return } let location= touch. location ( in : self ) // 查找点击的区域 for areain tapAreas{ if area. rect. contains ( location) { area. handler ( ) return } } } } Hit-Test 特殊情况处理 // MARK: - ScrollView 内按钮延迟响应问题 /* 问题:UIScrollView 默认 delaysContentTouches = true 会延迟 150ms 判断是滑动还是点击,导致按钮响应慢 */ class FastResponseScrollView : UIScrollView { override init ( frame: CGRect ) { super . init ( frame: frame) // 关闭延迟,让内容立即响应 delaysContentTouches= false } required init ? ( coder: NSCoder ) { super . init ( coder: coder) delaysContentTouches= false } // 防止 ScrollView 取消按钮的触摸 override func touchesShouldCancel ( in view: UIView ) - > Bool { // 如果是 UIControl(按钮等),不取消触摸 if viewis UIControl { return false } return super . touchesShouldCancel ( in : view) } } // MARK: - TableView Cell 内按钮点击 class CellWithButton : UITableViewCell { let actionButton= UIButton ( ) override init ( style: UITableViewCell . CellStyle , reuseIdentifier: String ? ) { super . init ( style: style, reuseIdentifier: reuseIdentifier) contentView. addSubview ( actionButton) // 重要:按钮添加到 contentView,而不是 cell 本身 // 这样 TableView 的选择和按钮点击可以独立工作 } required init ? ( coder: NSCoder ) { fatalError ( "init(coder:) has not been implemented" ) } override func hitTest ( _ point: CGPoint , with event: UIEvent ? ) - > UIView ? { // 先检查按钮 let buttonPoint= actionButton. convert ( point, from: self ) if actionButton. point ( inside: buttonPoint, with: event) { return actionButton} // 其他区域走默认逻辑 return super . hitTest ( point, with: event) } } // MARK: - 手势冲突解决 class GestureConflictView : UIView { let tapGesture= UITapGestureRecognizer ( ) let innerButton= UIButton ( ) override init ( frame: CGRect ) { super . init ( frame: frame) addGestureRecognizer ( tapGesture) addSubview ( innerButton) // 解决方案1:让手势在按钮区域失效 tapGesture. delegate= self } required init ? ( coder: NSCoder ) { fatalError ( "init(coder:) has not been implemented" ) } } extension GestureConflictView : UIGestureRecognizerDelegate { func gestureRecognizer ( _ gestureRecognizer: UIGestureRecognizer , shouldReceive touch: UITouch ) - > Bool { // 如果触摸到按钮,手势不接收 let location= touch. location ( in : self ) if innerButton. frame. contains ( location) { return false } return true } } 响应者链(Responder Chain) 响应者链结构 ═══════════════════════════════════════════════════════════════════════════ 响应者链结构 ═══════════════════════════════════════════════════════════════════════════ UIResponder 继承体系: ┌──────────────┐ │ UIResponder │ ← 抽象基类 └──────┬───────┘ │ ┌──────────────────────┼──────────────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────────┐ ┌───────────────┐ ┌──────────────────┐ │ UIView │ │UIViewController│ │ UIApplication │ └──────┬──────┘ └───────────────┘ └──────────────────┘ │ ├─────────────────┬─────────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ UIWindow │ │ UIControl │ │UIScrollView │ └─────────────┘ └──────┬──────┘ └─────────────┘ │ ┌────────────┼────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────┐ ┌─────────┐ ┌─────────┐ │UIButton │ │UISlider │ │UISwitch │ └─────────┘ └─────────┘ └─────────┘ 响应者链传递路径示例: ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ ┌─────────┐