22

I would like to know when a set of properties of a Swift object changes. Previously, I had implemented this in Objective-C, but I'm having some difficulty converting it to Swift.

My previous Objective-C code is:

- (void) observeValueForKeyPath:(NSString*)keyPath ofObject:(id)object change:(NSDictionary*)change context:(void*)context {
    if (![change[@"new"] isEqual:change[@"old"]])
        [self edit];
}

My first pass at a Swift solution was:

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if change?[.newKey] != change?[.oldKey] {    // Compiler: "Binary operator '!=' cannot be applied to two 'Any?' operands"
        edit()
    }
}

However, the compiler complains: "Binary operator '!=' cannot be applied to two 'Any?' operands"

My second attempt:

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if let newValue = change?[.newKey] as? NSObject {
        if let oldValue = change?[.oldKey] as? NSObject {
            if !newValue.isEqual(oldValue) {
                edit()
            }
        }
    }
}

But, in thinking about this, I don't think this will work for primitives of swift objects such as Int which (I assume) do not inherit from NSObject and unlike the Objective-C version won't be boxed into NSNumber when placed into the change dictionary.

So, the question is how do I do the seemingly easy task of determining if a value is actually being changed using the KVO in Swift3?

Also, bonus question, how do I make use of the 'of object' variable? It won't let me change the name and of course doesn't like variables with spaces in them.

mfaani
  • 33,269
  • 19
  • 164
  • 293
aepryus
  • 4,715
  • 5
  • 28
  • 41
  • Make sure you also see this [other answer](https://stackoverflow.com/a/25219216/5175709) by Rob – mfaani May 09 '18 at 20:58
  • Note there is a Swift4 bug when you use `.initial`. For a solution see [here](https://stackoverflow.com/questions/45415901/simultaneous-accesses-to-0x1c0a7f0f8-but-modification-requires-exclusive-access/47438532#47438532) – mfaani Sep 23 '18 at 20:55

2 Answers2

36

Below is my original Swift 3 answer, but Swift 4 simplifies the process, eliminating the need for any casting. For example, if you are observing the Int property called bar of the foo object:

class Foo: NSObject {
    @objc dynamic var bar: Int = 42
}

class ViewController: UIViewController {

    let foo = Foo()
    var token: NSKeyValueObservation?

    override func viewDidLoad() {
        super.viewDidLoad()

        token = foo.observe(\.bar, options: [.new, .old]) { [weak self] object, change in
            if change.oldValue != change.newValue {
                self?.edit()
            }
        }
    }

    func edit() { ... }
}

Note, this closure based approach:

  • Gets you out of needing to implement a separate observeValue method;

  • Eliminates the need for specifying a context and checking that context; and

  • The change.newValue and change.oldValue are properly typed, eliminating the need for manual casting. If the property was an optional, you may have to safely unwrap them, but no casting is needed.

The only thing you need to be careful about is making sure your closure doesn't introduce a strong reference cycle (hence the use of [weak self] pattern).


My original Swift 3 answer is below.


You said:

But, in thinking about this, I don't think this will work for primitives of swift objects such as Int which (I assume) do not inherit from NSObject and unlike the Objective-C version won't be boxed into NSNumber when placed into the change dictionary.

Actually, if you look at those values, if the observed property is an Int, it does come through the dictionary as a NSNumber.

So, you can either stay in the NSObject world:

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if let newValue = change?[.newKey] as? NSObject,
        let oldValue = change?[.oldKey] as? NSObject,
        !newValue.isEqual(oldValue) {
            edit()
    }
}

Or use them as NSNumber:

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if let newValue = change?[.newKey] as? NSNumber,
        let oldValue = change?[.oldKey] as? NSNumber,
        newValue.intValue != oldValue.intValue {
            edit()
    }
}

Or, I'd if this was an Int value of some dynamic property of some Swift class, I'd go ahead and cast them as Int:

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if let newValue = change?[.newKey] as? Int, let oldValue = change?[.oldKey] as? Int, newValue != oldValue {
        edit()
    }
}

You asked:

Also, bonus question, how do I make use of the of object variable? It won't let me change the name and of course doesn't like variables with spaces in them.

The of is the external label for this parameter (used when if you were calling this method; in this case, the OS calls this for us, so we don't use this external label short of in the method signature). The object is the internal label (used within the method itself). Swift has had the capability for external and internal labels for parameters for a while, but it's only been truly embraced in the API as of Swift 3.

In terms of when you use this change parameter, you use it if you're observing the properties of more than one object, and if these objects need different handling on the KVO, e.g.:

foo.addObserver(self, forKeyPath: #keyPath(Foo.bar), options: [.new, .old], context: &observerContext)
baz.addObserver(self, forKeyPath: #keyPath(Foo.qux), options: [.new, .old], context: &observerContext)

And then:

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    guard context == &observerContext else {
        super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
        return
    }

    if (object as? Foo) == foo {
        // handle `foo` related notifications here
    }
    if (object as? Baz) == baz {
        // handle `baz` related notifications here
    }
}

As an aside, I'd generally recommend using the context, e.g., have a private var:

private var observerContext = 0

And then add the observer using that context:

foo.addObserver(self, forKeyPath: #keyPath(Foo.bar), options: [.new, .old], context: &observerContext)

And then have my observeValue make sure it was its context, and not one established by its superclass:

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    guard context == &observerContext else {
        super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
        return
    }

    if let newValue = change?[.newKey] as? Int, let oldValue = change?[.oldKey] as? Int, newValue != oldValue {
        edit()
    }
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Interesting, although the values could be anything (not just Int, i.e. String, Date, enum or a custom class, etc.). So, perhaps the NSObject trick will work. Thanks for your answer. – aepryus Oct 19 '16 at 23:48
  • 1
    Yep, `NSObject` technique is fine. I was just showing you options. By the way, you can only do KVO with types that can be represented in Objective-C (e.g. you can't observe `enum`; you'd have to have the `enum` with raw values, and only store the `.rawValue`, not the `enum`, itself). – Rob Oct 19 '16 at 23:55
  • Oh... I understood the concept of the ofObject parameter, but the objective-c operator ofObject was converted to 'of object'. Apparently, swift will ignore one word before the property name in order to allow the developer to give context to the parameter? – aepryus Oct 20 '16 at 00:30
  • 1
    Oh, I thought you were asking about how to use this parameter. When you see `of object`, the `of` is the external argument label (which isn't really applicable here, because the OS call this, not you) and the `object` is the internal label (which is what you reference in your code inside this method). This convention, to use internal and external argument names, has been part of Swift for a while, but Apple has finally truly embraced it in their API redesign of Swift 3. See [Argument labels](https://swift.org/documentation/api-design-guidelines/#argument-labels). – Rob Oct 20 '16 at 00:34
  • I'm reading [here](https://developer.apple.com/documentation/foundation/nskeyvalueobservingoptions/nskeyvalueobservingoptioninitial?language=objc). Trying to figure out what key I should use when I want to capture the `initial` value. It seems that I should do something like `if let change = change?[.oldKey]`. Is that right? Meaning if add observers like `options: [.new,.initial]` ...I can capture them using `.newKey` and `.oldKey` – mfaani May 11 '18 at 19:59
  • I do understand `object` is there for you to make sure you're accessing the correct instance and `change` is there for you to compare its changes...but ultimately **is accessing `object.propertyName` any different from accessing `change[.newKey]`.** to me it seems that they are both returning the most recent value of the observed property – mfaani May 15 '18 at 21:52
  • The `change[.newKey]` is the Swift 3 syntax, and that’s not properly typed, so you have to cast it (which leads to uglier, more error prone code IMHO). In Swift 4, though, you can use `change.newValue`, which _is_ properly typed. And if I don’t need the old value, I think omitting `options` altogether and using `object.property` is more concise and reduces syntactic noise in our code. And @Honey, if you have more questions, I’d suggest you post your own question rather than comments. – Rob May 16 '18 at 04:17
0

My original 'second attempt' contains a bug that will fail to detect when a value changes to or from nil. The first attempt can actually be fixed once one realizes that all values of 'change' are NSObject:

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    let oldValue: NSObject? = change?[.oldKey] as? NSObject
    let newValue: NSObject? = change?[.newKey] as? NSObject
    if newValue != oldValue {
        edit()
    }
}

or a more concise version if you prefer:

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if change?[.newKey] as? NSObject != change?[.oldKey] as? NSObject {
        edit()
    }
}
aepryus
  • 4,715
  • 5
  • 28
  • 41