肇鑫的技术博客

业精于勤,荒于嬉

NSPressGestureRecognizer在模态时失效问题的解决

最近在使用NSPressGestureRecognizer处理长按的时候发现了问题。如果弹出的视图控制器,是采用的show方式,则一切正常。但如果是使用modal的方式,则无法识别长按。

代码如下:

import Cocoa

class VC: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view = NSView(frame: NSRect(x: 100, y: 100, width: 100, height: 100))
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.systemBrown.cgColor
        let longPress = NSPressGestureRecognizer(target: self, action: #selector(longPress(_:)))
        view.addGestureRecognizer(longPress)
        self.view.addSubview(view)
    }

    @objc func longPress(_ sender:Any) {
        print("long")
    }
}

分析

我自己弄了半天,没能找到解决方案,于是跑到SO上问。NSPressGestureRecognizer doesn't work in modal ViewController。一觉睡醒,发现已经有人回答了。

Interesting. What seems to be happening is that the recognizer state never changes from possible to "began" in the modal example.

class Recognizer: NSPressGestureRecognizer {

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)
        self.state = .began
    }

}

解答的代码虽然不完整,但是至少提供了一个方向。按照解答的思路,我重新实现了NSPressGestureRecognizer

模拟的目标是实现在show方式下同样的状态改变。即在正常长按的情况下,依次实现possible、began、end。使用一个Timer,在满足长按时间的情况下,发送began,并且在用户抬起鼠标且有began的情况下,发送end。

import Cocoa

class VC: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view = NSView(frame: NSRect(x: 100, y: 100, width: 100, height: 100))
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.systemBrown.cgColor
        let longPress = MyPressGestureRecognizer(target: self, action: #selector(longPress(_:)))
        view.addGestureRecognizer(longPress)

        let click = NSClickGestureRecognizer(target: self, action: #selector(click(_:)))
        view.addGestureRecognizer(click)

        self.view.addSubview(view)
    }

    @objc func click(_ sender:Any) {
        print("click")
    }

    @objc func longPress(_ sender:Any) {
        guard let gesture = sender as? NSGestureRecognizer else { return }

        switch gesture.state {
        case .ended:
            print("long")
        default:
            print(gesture.state)
        }
    }
}

class MyPressGestureRecognizer: NSPressGestureRecognizer {
    private weak var timer:Timer? = nil
    private var hasBegan = false
    private var hasCancelled = false

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)

        timer = Timer.scheduledTimer(withTimeInterval: minimumPressDuration, repeats: false) { (timer) in
            defer {
                timer.invalidate()
            }

            DispatchQueue.main.async {
                self.state = .began
                self.hasBegan = true
            }
        }
    }

    override func mouseUp(with event: NSEvent) {
        if hasBegan {
            self.state = .ended
            self.hasBegan = false
        }

        super.mouseUp(with: event)
    }

    override func reset() {
        timer?.invalidate()
        super.reset()
    }
}

extension NSGestureRecognizer.State:CustomStringConvertible {
    public var description:String {
        switch self {
        case .possible:
            return "possible"
        case .began:
            return "began"
        case .changed:
            return "changed"
        case .ended:
            return "ended"
        case .cancelled:
            return "cancelled"
        case .failed:
            return "failed"
        @unknown default:
            return "default"
        }
    }
}

运行之后,发现我实现的代码,和苹果原本的NSPressGestureRecognizer,效果完全一样。也就是说,我的代码同样有模态方式下,无法识别长按的问题。

再次思考,我发现问题出现Timer上,在模态运行的视图控制器,Timer不会执行。我猜,苹果大概也是代码中使用了Timer,才会有同样的问题。

解决

最终的方案是不使用Timer,增加一个是否取消的参数进行判断。代码如下:

import Cocoa

class VC: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view = NSView(frame: NSRect(x: 100, y: 100, width: 100, height: 100))
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.systemBrown.cgColor
        let longPress = MyPressGestureRecognizer(target: self, action: #selector(longPress(_:)))
        view.addGestureRecognizer(longPress)

        let click = NSClickGestureRecognizer(target: self, action: #selector(click(_:)))
        view.addGestureRecognizer(click)

        self.view.addSubview(view)
    }

    @objc func click(_ sender:Any) {
        print("click")
    }

    @objc func longPress(_ sender:Any) {
        guard let gesture = sender as? NSGestureRecognizer else { return }

        switch gesture.state {
        case .ended:
            print("long")
        default:
            print(gesture.state)
        }
    }
}

class MyPressGestureRecognizer: NSPressGestureRecognizer {
    private var hasBegan = false
    private var hasCancelled = false

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)

        hasCancelled = false

        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(minimumPressDuration * 1000))) {
            if !self.hasCancelled {
                self.state = .began
                self.hasBegan = true
            }
        }
    }

    override func mouseUp(with event: NSEvent) {
        if hasBegan {
            self.state = .ended
            self.hasBegan = false
        } else {
            self.hasCancelled = true
        }

        super.mouseUp(with: event)
    }
}

extension NSGestureRecognizer.State:CustomStringConvertible {
    public var description:String {
        switch self {
        case .possible:
            return "possible"
        case .began:
            return "began"
        case .changed:
            return "changed"
        case .ended:
            return "ended"
        case .cancelled:
            return "cancelled"
        case .failed:
            return "failed"
        @unknown default:
            return "default"
        }
    }
}

总结

  1. Timer在模态时会失效。我们在使用时需要小心。
  2. 上面的代码只处理了鼠标左键,如果想处理其它按键的长按,也可以用同样的方式进行处理。

参考

NSPressGestureRecognizer doesn't work in modal ViewController