Search code examples
nsuserdefaultscodableswift5swift-dictionarynsarchiving

How to encode/decode a dictionary with Codable values for storage in UserDefaults?


I am trying to store a dictionary of company names (string) mapped to Company objects (from a struct Company) in iOS UserDefaults. I have created the Company struct and made it conform to Codable. I have one example a friend helped me with in my project where we created a class Account and stored it in UserDefaults by making a Defaults struct (will include example code). I have read in the swift docs that dictionaries conform to Codable and in order to stay Codable, must contain Codable objects. That is why I made struct Company conform to Codable.

I have created a struct for Company that conforms to Codable. I have tried using model code to create a new struct CompanyDefaults to handle the getting and setting of the Company dictionary from/to UserDefaults. I feel I have some beginner misconceptions about what needs to happen and about how it should be implemented (with good design in mind).

The dictionary I wish to store looks like [String:Company] where company name will be String and a Company object for Company

I used conform to Codable as I did some research and it seemed like a newer method for completing similar tasks.

Company struct

struct Company: Codable {
    var name:String?
    var initials:String? = nil
    var logoURL:URL? = nil
    var brandColor:String? = nil // Change to UIColor

    enum CodingKeys:String, CodingKey {
        case name = "name"
        case initials = "initials"
        case logoURL = "logoURL"
        case brandColor = "brandColor"
    }

    init(name:String?, initials:String?, logoURL:URL?, brandColor:String?) {
        self.name = name
        self.initials = initials
        self.logoURL = logoURL
        self.brandColor = brandColor
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        name = try values.decode(String.self, forKey: .name)
        initials = try values.decode(String.self, forKey: .initials)
        logoURL = try values.decode(URL.self, forKey: .logoURL)
        brandColor = try values.decode(String.self, forKey:   .brandColor)

    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        try container.encode(initials, forKey: .initials)
        try container.encode(logoURL, forKey: .logoURL)
        try container.encode(brandColor, forKey: .brandColor)
    }

}

Defaults struct to control storage

struct CompanyDefaults {

    static private let companiesKey = "companiesKey"

    static var companies: [String:Company] = {

        guard let data = UserDefaults.standard.data(forKey: companiesKey) else { return [:] }
        let companies = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? [String : Company] ?? [:]
        return companies!
        }() {
        didSet {
            guard let data = try? NSKeyedArchiver.archivedData(withRootObject: companies, requiringSecureCoding: false) else {
                return
            }

            UserDefaults.standard.set(data, forKey: companiesKey)
        }
    }
}

I should be able to reference the stored dictionary throughout my code like CompanyDefaults.companies.count

For reference, a friend helped me do a similar task for an array of Account classes stored in user defaults. The code that works perfectly for that is below. The reason I tried a different way is that I had a different data structure (dictionary) and made the decision to use structs.

class Account: NSObject, NSCoding {

    let service: String
    var username: String
    var password: String

    func encode(with aCoder: NSCoder) {
        aCoder.encode(service)
        aCoder.encode(username)
        aCoder.encode(password)
    }

    required init?(coder aDecoder: NSCoder) {
        guard let service = aDecoder.decodeObject() as? String,
        var username = aDecoder.decodeObject() as? String,
        var password = aDecoder.decodeObject() as? String else {
            return nil
        }

        self.service = service
        self.username = username
        self.password = password

    }

    init(service: String, username: String, password: String) {
        self.service = service
        self.username = username
        self.password = password
    }

}
struct Defaults {

    static private let accountsKey = "accountsKey"

    static var accounts: [Account] = {

        guard let data = UserDefaults.standard.data(forKey: accountsKey) else { return [] }

        let accounts = NSKeyedUnarchiver.unarchiveObject(with: data) as? [Account] ?? []
        return accounts

        }() {
        didSet {
            guard let data = try? NSKeyedArchiver.archivedData(withRootObject: accounts, requiringSecureCoding: false) else {
                return
            }
            UserDefaults.standard.set(data, forKey: accountsKey)
        }
    }

}

Solution

  • You are mixing up NSCoding and Codable. The former requires a subclass of NSObject, the latter can encode the structs and classes directly with JSONEncoder or ProperListEncoder without any Keyedarchiver which also belongs to NSCoding.

    Your struct can be reduced to

    struct Company: Codable {
        var name : String
        var initials : String
        var logoURL : URL?
        var brandColor : String?
    }
    

    That's all, the CodingKeys and the other methods are synthesized. I would at least declare name and initials as non-optional.

    To read and save the data is pretty straightforward. The corresponding CompanyDefaults struct is

    struct CompanyDefaults {
    
        static private let companiesKey = "companiesKey"
    
        static var companies: [String:Company] = {
    
            guard let data = UserDefaults.standard.data(forKey: companiesKey) else { return [:] }
            return try? JSONDecoder.decode([String:Company].self, from: data) ?? [:]
            }() {
            didSet {
                guard let data = try? JSONEncoder().encode(companies) else { return }
                UserDefaults.standard.set(data, forKey: companiesKey)
            }
        }
    }