Key-Value Observe in Swift not showing insertions and removals in arrays

Felipe Ferri picture Felipe Ferri · Mar 17, 2015 · Viewed 7.3k times · Source

I created a class which contains an array. I added an observer to that array in a view controller and performed some modifications to that array.

The problem is that when I print the change dictionary returned by the observeValueForKeyPath() method I can only see changes of kind NSKeyValueChangeSetting. In other words, the method tells me the array has changed, provides me with the old and new arrays (containing all elements) but I would like to receive the information of which specific items were added or removed.

Here is some example code.

This is the class whose array will be observed.

private let _observedClass = ObservedClass()

class ObservedClass: NSObject {
    dynamic var animals = [String]()
    dynamic var cars = [String]()

    class var sharedInstance: ObservedClass {
        return _observedClass
    }
}

And this is the code at my view controller.

class ViewController: UIViewController {
    var observedClass = ObservedClass.sharedInstance

    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        observedClass.addObserver(self, forKeyPath: "animals", options: .New | .Old, context: nil)
    }

    deinit {
        observedClass.removeObserver(self, forKeyPath: "animals")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        observedClass.animals.insert("monkey", atIndex: 0)
        observedClass.animals.append("tiger")
        observedClass.animals.append("lion")
        observedClass.animals.removeAtIndex(0)
    }

    override func observeValueForKeyPath(keyPath: String, ofObject object: AnyObject, change: [NSObject : AnyObject], context: UnsafeMutablePointer<Void>) {
        println(change)
    }
}

When I run the above code, I get this result on the console:

[kind: 1, old: (
), new: (
    monkey
)]
[kind: 1, old: (
    monkey
), new: (
    monkey,
    tiger
)]
[kind: 1, old: (
    monkey,
    tiger
), new: (
    monkey,
    tiger,
    lion
)]
[kind: 1, old: (
    monkey,
    tiger,
    lion
), new: (
    tiger,
    lion
)]

Shouldn't, on this example, the change dictionary show each new item as it is added to the array, using the change kind NSKeyValueChangeInsertion?

Answer

Paul Patterson picture Paul Patterson · Apr 2, 2015

According to the Swift guide:

For arrays, copying only takes place when you perform an action that has the potential to modify the length of the array. This includes appending, inserting, or removing items, or using a ranged subscript to replace a range of items in the array.

Take as an example an append operation. Under the hood, when you append to your array, Swift creates a new array in memory, copies the items from the existing animals array into this new array - plus the new item - then assigns this new array to the animals variable. This sleight-of-hand is why you only ever get the kind of 1 (Setting), because in fact each 'edit' actually results in a new array being created.

It's different with NSMutableArray because the behaviour is a bit more intuitive - edits are made to the existing array (there's no behind-the-scenes copying to a new array), so the array that exists after the edit is the same array that existed before it - hence the value stored in the change dictionary with the key NSKeyValueChangeKindKey can be one of .Insertion, .Removal, etc.

Even that isn't the whole story however, because the way you make an KVO compliant changes to an NSMutableArray varies depending on whether you're using Swift or Objective-C. In Objective-C, Apple strongly recommend implementing what they call the optional mutable indexed accessors. These are just methods with a standard signature that make KVO-compliant changes in a very efficient way.

// Mutable Accessors ///////////////////////////////////////////

// This class has a property called <children> of type NSMutableArray

// MUST have this (or other insert accessor)
-(void)insertObject:(NSNumber *)object inChildrenAtIndex:(NSUInteger)index {
    [self.children insertObject:object atIndex:index];
}

// MUST have this (or other remove accessor)
-(void)removeObjectFromChildrenAtIndex:(NSUInteger)index {
    [self.children removeObjectAtIndex:index];
}

// OPTIONAL - but a good idea.
-(void)replaceObjectInChildrenAtIndex:(NSUInteger)index withObject:(id)object {
    [self.children replaceObjectAtIndex:index withObject:object];
}

Swift doesn't appear to have these, so the only way to make KVO-compliant changes is via the mutable proxy object - described in the NSKeyValueCoding page. Here's a very quick example:

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {

    dynamic var children: NSMutableArray = NSMutableArray()


    ////////////////////////////////////

    func applicationDidFinishLaunching(aNotification: NSNotification) {

        addObserver(self,
            forKeyPath: "children",
            options: .New | .Old,
            context: &Update)

        // Get the KVO/KVC compatible array
        var childrenProxy = mutableArrayValueForKey("children")

        childrenProxy.addObject(NSNumber(integer: 20)) // .Insertion

        childrenProxy.addObject(NSNumber(integer: 30)) // .Insertion

        childrenProxy.removeObjectAtIndex(1)  // .Removal
    }
}