iosswiftstoryboardnscodermethod-swizzling

UIColor initWithCoder extract color name


I'm experimenting with adding a form of theming support to iOS by swizzling UIColor constructor methods. I'm having trouble with a scenario coming from a storyboard when a control uses a "named color" (the ones added to the asset catalog manually). Printing the stack trace I can see before it gets to the UIColor(named: ....) constructor which I was using, it calls UIColor initWithCoder. I think if I can swizzle this instead and extract the "name" that was entered in the storyboard, it will solve my issue. (I'm aware this value will be passed in the other constructor, but I need to see if it's possible from the coder constructor instead, long story)

I'm struggling to understand how to get this value out of the NSCoder. I can't find any info on its underlying type of UINibDecoder, debugger doesn't present any useful info, I don't know what keys/types the object uses so I can't call the coder.decode... functions, and I can't find anything online regarding how to print all the keys/types.

Code I have currently is something along the lines of:

extension UIColor {
    private static let originalCoderSelector = #selector(UIColor.init(coder:))
    private static let swizzledCoderSelector = #selector(theme_color(withCoder:))
    
    class func swizzleNamedColorInitToAddTheme() {
        guard let originalCoderMethod = class_getInstanceMethod(self, originalCoderSelector),
              let swizzledCoderMethod = class_getInstanceMethod(self, swizzledCoderSelector) else {
            os_log("Unable to find UIColor methods to swizzle", log: .default, type: .error)
            return
        }
        
        method_exchangeImplementations(originalCoderMethod, swizzledCoderMethod);
    }

    @objc func theme_color(withCoder coder: NSCoder) -> UIColor? {
        print("coder being called")
        
        print("coder test: \( coder.decodeObject(forKey: "backgroundColor") ) ")
        print("coder test: \( coder.decodeObject(forKey: "color") ) ")
        print("coder test: \( coder.decodeObject(forKey: "name") ) ")
        print("coder test: \( coder.decodeObject(forKey: "namedColor") ) ")
        
        print("coder test: \( coder.decodePropertyList(forKey: "backgroundColor") ) ")
        print("coder test: \( coder.decodePropertyList(forKey: "color") ) ")
        print("coder test: \( coder.decodePropertyList(forKey: "name") ) ")
        print("coder test: \( coder.decodePropertyList(forKey: "namedColor") ) ")
        
        return nil
    }
}

Solution

  • Using the technique in this answer, we can get the coding keys by swizzling NSKeyedUnarchiver.decodeObject(forKey:):

    extension NSKeyedUnarchiver {
        private static let originalDecodeObject = #selector(NSKeyedUnarchiver.decodeObject(forKey:))
        private static let swizzledDecodeObject = #selector(swizzledDecodeObject)
    
        @objc
        func swizzledDecodeObject(forKey key: String) -> Any? {
            print("Key:", key)
            return swizzledDecodeObject(forKey: key)
        }
    
        class func swizzle() {
            if let orig = class_getInstanceMethod(self, originalDecodeObject),
               let new = class_getInstanceMethod(self, swizzledDecodeObject) {
                method_exchangeImplementations(orig, new)
            }
        }
    }
    

    Then, you can encode and decode a color to see what keys are decoded:

    let data = try! NSKeyedArchiver.archivedData(withRootObject: UIColor(named: "foo"), requiringSecureCoding: false)
    let decoded = try! NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: data)
    

    On iOS 15.0, this prints for a color I added in the asset catalog:

    Key: root
    Key: UIDynamicModifiedBaseColor
    Key: UIDynamicCatalogName
    Key: UIDynamicCatalogBundleIdentifier
    Key: UIDynamicCatalogBundleLibraryName
    

    From the list and using some common sense, UIDynamicCatalogName seems to be the key that you are looking for.

    Note that this won't work for base colours like systemRed, or system colours like systemBackground. For the latter, you should use the key UISystemColorName (you can find this by encoding and decoding .systembackground). For the former, it is still unclear what key is used to encode it. By encoding and decoding .systemYellow, we see about the same keys as .systembackground, but using UISystemColorName doesn't seem to work. The nib is probably encoded differently...