自定义手势识别器:多点触控与手势冲突解决
大家好,今天我们来深入探讨一下如何在iOS平台上自定义手势识别器,特别是针对多点触控和手势冲突的场景。在实际开发中,系统自带的手势识别器往往无法满足复杂的需求,例如需要同时识别多个手势,或者需要针对不同的触控点进行不同的处理。因此,自定义手势识别器就显得尤为重要。
一、手势识别器的基础:UIGestureRecognizer
所有的手势识别器都继承自 UIGestureRecognizer。自定义手势识别器的核心在于重写 UIGestureRecognizer 的几个关键方法:
touchesBegan(_:with:): 当一个或多个手指开始触摸屏幕时调用。touchesMoved(_:with:): 当一个或多个手指在屏幕上移动时调用。touchesEnded(_:with:): 当一个或多个手指离开屏幕时调用。touchesCancelled(_:with:): 当触摸事件被系统中断时调用,例如来电。reset(): 重置手势识别器的状态。
这五个方法构成了手势识别器的生命周期,我们通过重写这些方法来判断手势是否符合我们的定义,并更新识别器的状态。
二、自定义单指滑动识别器
让我们从一个简单的例子开始:自定义一个单指滑动识别器。
import UIKit
class SingleFingerPanGestureRecognizer: UIGestureRecognizer {
private var startPoint: CGPoint = .zero
private(set) var translation: CGPoint = .zero
private(set) var velocity: CGPoint = .zero
private var previousPoint: CGPoint = .zero
private var startTime: TimeInterval = 0
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard touches.count == 1 else {
state = .failed
return
}
if state == .possible {
startPoint = touches.first!.location(in: view)
previousPoint = startPoint
startTime = touches.first!.timestamp
state = .began
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard touches.count == 1 else {
state = .failed
return
}
let currentPoint = touches.first!.location(in: view)
translation = CGPoint(x: currentPoint.x - startPoint.x, y: currentPoint.y - startPoint.y)
let currentTime = touches.first!.timestamp
let timeDelta = currentTime - startTime
let distance = sqrt(pow(currentPoint.x - startPoint.x, 2) + pow(currentPoint.y - startPoint.y, 2))
if timeDelta > 0 {
velocity = CGPoint(x: (currentPoint.x - previousPoint.x) / (currentTime - startTime), y: (currentPoint.y - previousPoint.y) / (currentTime - startTime))
}
previousPoint = currentPoint
startTime = currentTime
state = .changed
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard touches.count == 1 else {
state = .failed
return
}
state = .ended
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
state = .cancelled
}
override func reset() {
super.reset()
startPoint = .zero
translation = .zero
velocity = .zero
previousPoint = .zero
startTime = 0
}
}
这个 SingleFingerPanGestureRecognizer 识别单指滑动,并提供 translation 和 velocity 属性,分别表示滑动的位移和速度。
三、多点触控的实现
要实现多点触控,我们需要处理 touches 集合中的多个 UITouch 对象。以下是一个简单的双指捏合缩放手势识别器的例子:
import UIKit
class PinchGestureRecognizer: UIGestureRecognizer {
private var initialDistance: CGFloat = 0
private(set) var scale: CGFloat = 1.0
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard touches.count == 2 else {
state = .failed
return
}
if state == .possible {
let touch1 = touches.first!
let touch2 = touches.dropFirst().first!
initialDistance = distance(between: touch1.location(in: view), and: touch2.location(in: view))
if initialDistance > 0 {
state = .began
} else {
state = .failed
}
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard touches.count == 2 else {
state = .failed
return
}
let touch1 = touches.first!
let touch2 = touches.dropFirst().first!
let currentDistance = distance(between: touch1.location(in: view), and: touch2.location(in: view))
if initialDistance > 0 {
scale = currentDistance / initialDistance
state = .changed
} else {
state = .failed
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
state = .ended
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
state = .cancelled
}
override func reset() {
super.reset()
initialDistance = 0
scale = 1.0
}
private func distance(between point1: CGPoint, and point2: CGPoint) -> CGFloat {
return sqrt(pow(point1.x - point2.x, 2) + pow(point1.y - point2.y, 2))
}
}
在这个 PinchGestureRecognizer 中,我们首先确保 touches 集合中包含两个 UITouch 对象。然后,计算两个触控点之间的距离,并根据初始距离计算缩放比例 scale。
四、手势冲突的解决
手势冲突是指多个手势识别器同时识别同一个触摸序列。例如,一个视图上同时添加了 SingleFingerPanGestureRecognizer 和 PinchGestureRecognizer,当用户进行捏合手势时,SingleFingerPanGestureRecognizer 也可能被触发。
解决手势冲突的关键在于控制 UIGestureRecognizer 的 delegate 属性,并实现 UIGestureRecognizerDelegate 协议。
UIGestureRecognizerDelegate 协议提供了以下方法:
gestureRecognizerShouldBegin(_:): 询问手势识别器是否应该开始识别手势。gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:): 询问手势识别器是否应该与其他手势识别器同时识别手势。gestureRecognizer(_:shouldRequireFailureOf:): 询问手势识别器是否应该在另一个手势识别器失败后才开始识别。gestureRecognizer(_:shouldBeRequiredToFailBy:): 询问手势识别器是否应该让另一个手势识别器在自身失败后才开始识别。
1. 允许同时识别手势
如果希望两个手势识别器可以同时识别手势,可以在 gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) 方法中返回 true。
extension ViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// 允许所有手势同时识别
return true
}
}
2. 阻止手势同时识别
如果希望阻止两个手势识别器同时识别手势,可以在 gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) 方法中返回 false,并根据具体情况进行判断。例如,只允许 PinchGestureRecognizer 和 RotationGestureRecognizer 同时识别:
extension ViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is PinchGestureRecognizer && otherGestureRecognizer is RotationGestureRecognizer {
return true
}
if gestureRecognizer is RotationGestureRecognizer && otherGestureRecognizer is PinchGestureRecognizer {
return true
}
return false
}
}
3. 使用 require(toFail:) 解决手势冲突
require(toFail:) 方法可以指定一个手势识别器必须失败后,另一个手势识别器才能开始识别。例如,如果希望 SingleFingerPanGestureRecognizer 在 PinchGestureRecognizer 失败后才开始识别,可以这样做:
let panGesture = SingleFingerPanGestureRecognizer()
let pinchGesture = PinchGestureRecognizer()
panGesture.require(toFail: pinchGesture)
这样,当用户进行捏合手势时,SingleFingerPanGestureRecognizer 会等待 PinchGestureRecognizer 识别失败后才会开始识别。
4. 手势优先级
手势识别器也有优先级之分,可以通过 cancelsTouchesInView 属性来控制。如果 cancelsTouchesInView 为 true,则当手势识别器识别到手势后,会取消所有触摸事件,阻止其他手势识别器识别手势。
五、实战案例:自定义手势锁
让我们通过一个实战案例来巩固所学知识:自定义一个手势锁。
手势锁要求用户按照预定的轨迹在屏幕上滑动,只有当轨迹与预设轨迹匹配时,才能解锁。
import UIKit
class GestureLockRecognizer: UIGestureRecognizer {
private var pattern: [Int] = [] // 预设的手势轨迹,例如 [1, 2, 3, 6, 9, 8, 7, 4, 1]
private var currentPattern: [Int] = [] // 用户当前绘制的手势轨迹
private var lockSize: Int = 3 // 锁的行列数
private var lockArea: CGRect = .zero // 锁的区域
private var lockNodes: [[CGRect]] = [] // 锁的节点区域
private var nodeRadius: CGFloat = 20 // 节点半径
var successHandler: (() -> Void)?
var failureHandler: (() -> Void)?
override init(target: Any?, action: Selector?) {
super.init(target: target, action: action)
self.lockSize = 3
self.pattern = [1, 2, 3, 6, 9, 8, 7, 4, 1]
}
func setPattern(_ pattern: [Int]) {
self.pattern = pattern
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else {
state = .failed
return
}
if state == .possible {
// 初始化锁的区域和节点区域
lockArea = CGRect(x: view!.bounds.midX - 150, y: view!.bounds.midY - 150, width: 300, height: 300)
let nodeWidth = lockArea.width / CGFloat(lockSize)
let nodeHeight = lockArea.height / CGFloat(lockSize)
lockNodes = []
for row in 0..<lockSize {
var rowNodes: [CGRect] = []
for col in 0..<lockSize {
let x = lockArea.origin.x + CGFloat(col) * nodeWidth
let y = lockArea.origin.y + CGFloat(row) * nodeHeight
let nodeRect = CGRect(x: x, y: y, width: nodeWidth, height: nodeHeight)
rowNodes.append(nodeRect)
}
lockNodes.append(rowNodes)
}
let location = touch.location(in: view)
if let nodeIndex = hitTest(location: location) {
currentPattern.append(nodeIndex)
state = .began
} else {
state = .failed
}
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else {
state = .failed
return
}
let location = touch.location(in: view)
if let nodeIndex = hitTest(location: location) {
if !currentPattern.contains(nodeIndex) {
currentPattern.append(nodeIndex)
state = .changed
}
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
if currentPattern == pattern {
state = .ended
successHandler?()
} else {
state = .failed
failureHandler?()
}
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
state = .cancelled
}
override func reset() {
super.reset()
currentPattern = []
lockNodes = []
}
private func hitTest(location: CGPoint) -> Int? {
for row in 0..<lockSize {
for col in 0..<lockSize {
let nodeRect = lockNodes[row][col]
let center = CGPoint(x: nodeRect.midX, y: nodeRect.midY)
let distance = sqrt(pow(location.x - center.x, 2) + pow(location.y - center.y, 2))
if distance <= nodeRadius {
return row * lockSize + col + 1 // 节点编号从 1 开始
}
}
}
return nil
}
}
在这个 GestureLockRecognizer 中,我们定义了一个 pattern 数组,表示预设的手势轨迹。在 touchesBegan(_:with:) 方法中,我们初始化锁的区域和节点区域,并判断用户是否触摸了第一个节点。在 touchesMoved(_:with:) 方法中,我们判断用户是否滑动到新的节点,并将节点编号添加到 currentPattern 数组中。在 touchesEnded(_:with:) 方法中,我们判断 currentPattern 是否与 pattern 匹配,如果匹配则调用 successHandler,否则调用 failureHandler。
六、一些需要注意的点
- 性能优化: 在
touchesMoved(_:with:)方法中,不要进行复杂的计算,尽量减少 CPU 的消耗。 - 状态管理: 合理管理手势识别器的状态,避免出现状态混乱。
- 用户体验: 提供良好的视觉反馈,例如在用户触摸节点时,高亮显示节点。
- 可配置性: 将手势识别器的参数暴露出来,例如锁的行列数、节点半径等,方便用户进行配置。
- 避免和系统手势冲突: 尽可能避免和系统手势(例如边缘滑动手势)冲突,如果无法避免,需要仔细处理手势的优先级。
七、总结一下
自定义手势识别器是 iOS 开发中一项非常重要的技能,掌握它可以帮助我们实现各种复杂的手势交互。核心在于理解 UIGestureRecognizer 的生命周期,并灵活运用 UIGestureRecognizerDelegate 协议解决手势冲突。通过实战案例,我们可以更好地理解自定义手势识别器的原理和应用。