最近在写一个 MacOS 的小应用,其中有一个小需求是:点击输入框时显示几个选项,而失去焦点时就不显示了。具体效果就像滴答清单 MacOS 版中创建新任务的效果差不多。

失去焦点后
获取焦点后

找了很多资料,终于想出了一个解决办法。

Step1 How to get onFocus/onBlur events

参考资料:onFocus/onBlur events (or becomeFirstResponder/resignFirstResponder) on NSTextField Cocoa Swift
这里的解决办法是写一个 NSTextField 的子类,然后重写 mouseDown 方法和 textDidEndEditing 方法,分别是 onFocus 和 onBlur 。

class MSTextField: NSTextField {
    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)

        // Drawing code here.
    }
    
    override func mouseDown(with event: NSEvent) {
        print("I got focused")
        super.mouseDown(with: event)
    }
    
    override func textDidEndEditing(_ notification: Notification) {
        print("I got blurred")
        super.textDidEndEditing(notification)
    }   
}

然后在 storyboard 中关联这个类,在获取焦点和失去焦点时都是有效果的。

但是,光打印不够啊,我需要做一些其他操作,很明显不能够在这个类中实现。

Step2 How to observe the event

这时想到了之前复习时学到的 KVO(Key Value Observing) ,所以我给 MSTextField 添加一个属性叫 hasFocused ,当获取焦点时设为 true ,失去焦点时设为 false

class MSTextField: NSTextField {
    var hasFocused: Bool = false
    
    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)

        // Drawing code here.
    }
    
    override func mouseDown(with event: NSEvent) {
        self.hasFocused = true
        super.mouseDown(with: event)
    }
    
    override func textDidEndEditing(_ notification: Notification) {
        self.hasFocused = false
        super.textDidEndEditing(notification)
    }   
}

同时在我的 ViewController 添加监听的代码

// newSubPriceTextField 是我的这个 TextField
// newSubPriceYearLabel 和 newSubPriceMonthLabel 是随时间出现和消失的元素
override func viewDidLoad() {
        super.viewDidLoad()
        newSubPriceTextField.addObserver(self, forKeyPath: "hasFocused", options: [.new], context: nil)
    }

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if let change = change {
            if change[.newKey] as! Bool == true {
                newSubPriceYearLabel.isHidden = false
                newSubPriceMonthLabel.isHidden = false
            } else {
                newSubPriceYearLabel.isHidden = true
                newSubPriceMonthLabel.isHidden = true
            }
        }
    }

照理说这样就完成 KVO 的实现,但是没有效果啊。这时候想到了一个很重要的知识点:

因为 KVO 是基于 KVC (Key-Value Coding) 以及动态派发技术实现的,而这些东西都是 Objective-C 运行时的概念。另外由于 Swift 为了效率,默认禁用了动态派发,因此想用 Swift 来实现 KVO,我们还需要做额外的工作,那就是将想要观测的对象标记为 dynamic.

也就是在 MSTextField 中需要给 hasFocused 属性增加 dynamic

@objc dynamic var hasFocused: Bool = false

关于 KVO 的知识点本来想写一篇文章专门来介绍的,但是除了应用方面,底层实现和原理都还没有了解,不想照搬别人的文章,所以想要了解 KVO 的同学可以查看 onevcat  喵神写的 [KVO](Swifter - Swift 必备 tips)

在写完这段代码后,尝试运行,好像没什么问题,但是当这个页面销毁时,程序会崩溃,而且只会提示

Thread 1: EXC_BAD_ACCESS (code=1, address=0x3eaddc6cb8a0)

头皮发麻,这怎么办呢?

Step3 Resolve the crash


通过面向 Google 编程找到了调试方法[EXC_BAD_ACCESS(code=1问题的解决办法](EXC_BAD_ACCESS(code=1问题的解决办法 - ShorewB的博客 - CSDN博客)

Edit Scheme->Arguments->Environment variables
增加 NSZombieEnabled ,设置为YES,并勾选上,OK,再次运行,在console就会显示出出错的地方了。

再次运行时就会有比较详细的报错了

**2019-03-08 16:51:48.136833+0800 MySub[5869:405278] *** -[MySub.AddNewSubViewController retain]: message sent to deallocated instance 0x600002c07e80**

看样子大概是说,当 AddNewSubViewController 销毁的时候,向一个 deallocated 的实例发消息了。

突然就联想到了,是监听在 ViewController 销毁时没有跟着移除,所以在 ViewController 销毁时移除监听即可

    override func viewWillDisappear() {
        newSubPriceTextField.removeObserver(self, forKeyPath: "hasFocused")
    }