Search code examples
swiftobjective-cstructkvc

Successfully using KVC with Swift Structs


I'm facing a very niche problem but hope someone can help me. I'm using KVC in my project to set values in a class. This class contains properties including some that are structs. I was able to make these structs @objc compatible using the _ObjectiveCBridgeable protocol (I know it's not ideal but that's the best method I found for this). Now when I try to use setValue(_:forKeyPath:) on the parent class to set a value for one of the properties in the struct, it doesn't work. After some debugging I understand that it's bridging the struct to its Objective-C equivalent class and setting the value on that Objective-C equivalent class, but it's not converting the Objective-C class back to the struct and updating the struct on the parent class.

I think it should be possible as can be seen in https://stackoverflow.com/questions/23216673/how-can-i-animate-the-height-of-a-calayer-with-the-origin-coming-from-the-bottom#:~:text=Then%20for%20the%20animation%2C%20you%20simply%20animate%20the%20bounds%20(or%20even%20better%2C%20you%20can%20animate%20%22bounds.size.height%22%2C%20since%20only%20the%20height%20is%20changing)%3A where it is stated that you can animate you can animate bounds.size.height using its keyPath in a CABasicAnimation

Here's my code (which can run in a Swift playground):

@available(iOS 2.0, *)
@objc public class TestClass: NSObject {
    @objc public var x: Int
    @objc public var y: Int
    @objc public var testStruct = TestStruct(z: 0)
    
    init(x:Int, y: Int) {
        self.x = x
        self.y = y
    }
}

@available(iOS 2.0, *)
public struct TestStruct: _ObjectiveCBridgeable {
    public typealias _ObjectiveCType = NSTestStruct
    
    public var z: Int
    
    public init(z: Int) { self.z = z }
    
    public func _bridgeToObjectiveC() -> NSTestStruct {
        return NSTestStruct(z: z)
    }
    public static func _forceBridgeFromObjectiveC(_ source: NSTestStruct, result: inout TestStruct?) {
        result = TestStruct(z: source.z)
    }
    public static func _unconditionallyBridgeFromObjectiveC(_ source: NSTestStruct?) -> TestStruct {
        return TestStruct(z: source?.z ?? 0)
    }
    public static func _conditionallyBridgeFromObjectiveC(_ source: NSTestStruct, result: inout TestStruct?) -> Bool {
        result = TestStruct(z: source.z)
        return true
    }
}

@available(iOS 2.0, *)
@objc public class NSTestStruct: NSObject {
    @objc public var z: Int // Updates to "12"
    @objc public init(z: Int) {
        self.z = z
    }
}

var testClass = TestClass(x: 0, y: 0)
testClass.setValue(12, forKeyPath: "testStruct.z")
print(testClass.testStruct.z) // Prints "0", expected "12"

Solution

  • As Martin R mentioned, CABasicAnimation has a custom setValue(_:forKeyPath:) which allows you to set nested properties. I coded a custom setValue(_:forKeyPath:) which does the same thing for a general keyPath containing structs who's types are directly convertible to Objective-C (same property names and types). Here's my solution:

    public extension NSObject {
        func setValueRecursively(_ value: Any?, forKeyPath keyPath: String) {
            var currentKeyPath = keyPath
            var currentValue: Any? = value
            while true {
                guard let lastPeriodIndex = currentKeyPath.lastIndex(of: ".") else {
                    self.setValue(currentValue, forKey: currentKeyPath)
                    return
                }
                
                let parentKeyPath = String(currentKeyPath[currentKeyPath.startIndex ..< lastPeriodIndex])
                guard let parentValue = self.value(forKeyPath: parentKeyPath) as? NSObject else { return }
                parentValue.setValue(currentValue, forKey: String(currentKeyPath.suffix(currentKeyPath.count-parentKeyPath.count-1)))
                currentValue = parentValue
                currentKeyPath = parentKeyPath
            }
        }
    }
    

    With the same code as the Question,

    testClass.setValueRecursively(12, forKeyPath: "testStruct.z")
    

    works as expected :)

    Note: This will not work with CGSize, CGRect, and all other properties that are represented as NSValue