自定义手势识别器(Recognizer):实现多点触控(Multi-touch)与手势冲突解决

自定义手势识别器:多点触控与手势冲突解决

大家好,今天我们来深入探讨一下如何在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 识别单指滑动,并提供 translationvelocity 属性,分别表示滑动的位移和速度。

三、多点触控的实现

要实现多点触控,我们需要处理 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

四、手势冲突的解决

手势冲突是指多个手势识别器同时识别同一个触摸序列。例如,一个视图上同时添加了 SingleFingerPanGestureRecognizerPinchGestureRecognizer,当用户进行捏合手势时,SingleFingerPanGestureRecognizer 也可能被触发。

解决手势冲突的关键在于控制 UIGestureRecognizerdelegate 属性,并实现 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,并根据具体情况进行判断。例如,只允许 PinchGestureRecognizerRotationGestureRecognizer 同时识别:

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:) 方法可以指定一个手势识别器必须失败后,另一个手势识别器才能开始识别。例如,如果希望 SingleFingerPanGestureRecognizerPinchGestureRecognizer 失败后才开始识别,可以这样做:

let panGesture = SingleFingerPanGestureRecognizer()
let pinchGesture = PinchGestureRecognizer()

panGesture.require(toFail: pinchGesture)

这样,当用户进行捏合手势时,SingleFingerPanGestureRecognizer 会等待 PinchGestureRecognizer 识别失败后才会开始识别。

4. 手势优先级

手势识别器也有优先级之分,可以通过 cancelsTouchesInView 属性来控制。如果 cancelsTouchesInViewtrue,则当手势识别器识别到手势后,会取消所有触摸事件,阻止其他手势识别器识别手势。

五、实战案例:自定义手势锁

让我们通过一个实战案例来巩固所学知识:自定义一个手势锁。

手势锁要求用户按照预定的轨迹在屏幕上滑动,只有当轨迹与预设轨迹匹配时,才能解锁。

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 协议解决手势冲突。通过实战案例,我们可以更好地理解自定义手势识别器的原理和应用。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注