4.3 手势识别:感知风的触摸


4.3 手势识别:感知风的触摸

本节摘要:按钮只覆盖"点一下"这一种触摸,长按、拖动、捏合、轻扫全靠手势识别器(UIGestureRecognizer 家族)。本节讲触摸如何沿视图树命中测试找到目标、识别器如何把裸触摸流翻译成语义手势,四类常用识别器的接入代码,识别器之间"同时识别"与"互斥等待"的协作规则,以及与滚动视图抢手势的正确姿势——给浇水进度环装上拖动调值。

触摸的两层语义

先看一段没有识别器的世界:控制器可以重写 touchesBegan 这类方法接收裸触摸流——屏幕坐标、时间戳、手指编号。要判断"这是一次长按还是一次拖动",你得自己攒时间阈值、算位移量,几百行状态机换一个手势。手势识别器就是这套状态机的官方封装:把触摸流翻译成"这是点击 / 这是捏合"的语义事件,你只管订阅结果。

// 四类常用识别器,每行一个语义 let tap = UITapGestureRecognizer(target: self, action: #selector(tapped)) // 单击 tap.numberOfTapsRequired = 2 // 改成双击 let longPress = UILongPressGestureRecognizer(target: self, action: #selector(pressed(_:))) let pan = UIPanGestureRecognizer(target: self, action: #selector(panned(_:))) // 拖动 let pinch = UIPinchGestureRecognizer(target: self, action: #selector(pinched(_:))) // 捏合缩放 // 装到哪个视图,哪个视图的触摸就先喂给识别器 progressRing.addGestureRecognizer(tap) progressRing.addGestureRecognizer(pan)

识别器的事件不是"发生即通知",而是带状态的:

@objc private func pressed(_ g: UILongPressGestureRecognizer) { switch g.state { case .began: print("长按开始,弹出养护菜单") // 按住 0.5 秒时触发一次 case .changed: print("手指微移,菜单跟随") // 持续更新(拖动手势的主力状态) case .ended: print("长按结束,确认选择") // 手指抬起 case .cancelled: print("来电打断,收尾清理") // 系统打断(电话、通知横幅) default: break } } @objc private func panned(_ g: UIPanGestureRecognizer) { let offset = g.translation(in: progressRing) // 相对起点的累计位移 print("拖动量 x=\(Int(offset.x)) y=\(Int(offset.y))") // 输出示例:拖动量 x=42 y=-7 if g.state == .ended { g.setTranslation(.zero, in: progressRing) } // 用完归零,下次重新累计 }

.began → .changed → .ended/.cancelled 这条状态链是识别器的通用语法:began 里起步、changed 里跟手、ended 里定稿、cancelled 里收尾——对照 3.3 的生命周期回调,同样是"系统在固定时机喊你"。

命中测试:触摸怎么找到目标

手指落下那一刻,系统要回答"点的是谁"。答案由视图树自上而下递归得出(hit testing):从窗口开始,按后加入的子视图优先的顺序倒序遍历,找到包含触摸点且可交互的最深视图,然后触摸沿响应链(responder chain)向上传递机会——子视图不处理,父视图接手,一路到控制器、到窗口。

// 验证命中测试:自定义一个"只认圆形区域"的进度环视图 final class ProgressRingView: UIView { override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { let d = hypot(point.x - bounds.midX, point.y - bounds.midY) // 到圆心距离 return d <= min(bounds.width, bounds.height) / 2 // 圆外不算命中 } } // 效果:贴着圆环四角矩形角落的点击被跳过,直接落到下层视图—— // 默认实现是"整个 frame 都算",重写后命中形状与视觉形状一致

这条机制解释了很多"灵异":给父视图加了背景色后,命中范围从文字扩大到整块背景(3.2 的按钮被盖事故正是它的反面);透明区域默认也能接收触摸,isUserInteractionEnabled = false 才是真正的关闸。

案例:给进度环装拖动调值的完整过程

背景:浇水进度环(4.5 节会画它)要支持手指拖动改值:从环顶顺时针转一圈对应 0 到 1。操作:加拖动识别器,把笛卡尔坐标翻译成角度。

extension ProgressRingView { func installPan() { let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) addGestureRecognizer(pan) } @objc private func handlePan(_ g: UIPanGestureRecognizer) { let loc = g.location(in: self) // 手指当前点(本视图坐标系) let dx = loc.x - bounds.midX let dy = loc.y - bounds.midY var angle = atan2(dy, dx) + .pi / 2 // 以正上方为零点 if angle < 0 { angle += .pi * 2 } // 归一到 0 到 2π progress = angle / (.pi * 2) // 0...1 环值 if g.state == .ended { print("松手,进度定格在 \(Int(progress * 100))%") // 输出示例:松手,进度定格在 62% } setNeedsDisplay() // 请求重画(4.5 详讲) } }

结果:顺时针拖动,环随手指填充;松手打印定格值。解读:location(in:) 给的是本视图坐标(3.2 的 bounds 坐标系),atan2 把向量转角度,三个数学调用完成"手势到值"的翻译——手势代码的难度一半在状态管理,一半在坐标换算。变式:想让进度只增不减,拖到某段时用 .failed 状态;想加"归零双击",双击识别器里把 progress 置零再重画。

共存与冲突:识别器的社交规则

多个识别器挤在一个视图上,系统默认规则是互斥等待:一个识别器认领了触摸,其他全部失败。要让捏合与旋转同时生效、或让进度环拖动与表格滚动共存,得显式开口子:

// 场景一:让两个识别器同时工作(默认禁止)——实现委托并放行 extension ProgressRingView: UIGestureRecognizerDelegate { func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { return true // 全放行;精细做法是按类型选择性放行 } } // 场景二:识别器 vs 滚动视图——拖环时别让表格跟着滚 // 给拖动识别器加依赖:先让滚动视图的识别器"迟疑" tablePan.require(toFail: ringPan) // 反向:滚动等环识别失败才接管 // 更常用的正向写法:在进度环内部拖动时,让表格暂时不许滚 override func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool { if g is UIPanGestureRecognizer { let v = g.velocity(in: self) // 位移速度 return abs(v.x) > abs(v.y) // 横向意图才接管,纵向让给滚动 } return true }

速度判方向是社区沉淀出的经典解:横向拖动给控件、纵向留给列表,一行速度比较挡掉九成"手势打架"投诉。

💡 关键直觉:手势系统的默认答案是"不"——不同时、先等待、超时失败。所有"跟手"的流畅体验,背后都是显式打开的口子:同时识别放行、失败依赖声明、按意图分流。见到手势卡顿先查这三处。

本节要点回顾

  • 识别器是状态机:began/changed/ended/cancelled 四态,位移用 translation 且用完归零;
  • 命中测试倒序深搜,命中形状可通过重写 point-inside 与视觉形状对齐;
  • 四类常用:点击(可设次数)、长按、拖动、捏合,装在哪个视图就管哪片触摸;
  • 默认互斥,同时工作需委托放行,与滚动抢手势靠失败依赖或速度分流;
  • cancelled 不是错误,是系统打招呼"我来打断,你收尾"。

界面能感知触摸了,还差让变化"被看见"的美——下一节动画。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U