Search code examples
iosswiftsettingsgeneric-programminguserdefaults

How to design a Settings model to generically get + set values for all UserDefaults keys


I’m setting up my Settings class which gets/sets values from UserDefaults. I wish for as much of the code to be generic to minimise effort involved whenever a new setting is introduced (I have many as it is, and I expect many more in the future too), thereby reducing the probability of any human errors/bugs.

I came across this answer to create a wrapper for UserDefaults:

struct UserDefaultsManager {
    static var userDefaults: UserDefaults = .standard
    
    static func set<T>(_ value: T, forKey: String) where T: Encodable {
        if let encoded = try? JSONEncoder().encode(value) {
            userDefaults.set(encoded, forKey: forKey)
        }
    }
    
    static func get<T>(forKey: String) -> T? where T: Decodable {
        guard let data = userDefaults.value(forKey: forKey) as? Data,
            let decodedData = try? JSONDecoder().decode(T.self, from: data)
            else { return nil }
        return decodedData
    }
}

I’ve created an enum to store all the setting keys:

enum SettingKeys: String {
    case TimeFormat = "TimeFormat"
    // and many more
}

And each setting has their own enum:

enum TimeFormat: String, Codable  {
    case ampm = "12"
    case mili = "24"
}

In this simplified Settings class example, you can see that when it’s instantiated I initialise the value of every setting defined. I check if its setting key was found in UserDefaults: if yes, I use the value found, otherwise I set it a default and save it for the first time to UserDefaults.

class Settings {

    var timeFormat: TimeFormat!
    
    init() {
        self.initTimeFormat()
    }

    func initTimeFormat() {
        guard let format: TimeFormat = UserDefaultsManager.get(forKey: SettingKeys.TimeFormat.rawValue) else {
            self.setTimeFormat(to: .ampm)
            return
        }
        
        self.timeFormat = format
    }

    func setTimeFormat(to format: TimeFormat) {
        UserDefaultsManager.set(format, forKey: SettingKeys.TimeFormat.rawValue)
        self.timeFormat = format
    }
}

This is working fine, and pretty straightforward. However, thinking ahead, this will be tedious (and therefore error-prone) to replicate for every setting in this app (and every other I look to do in the future). Is there a way for the init<name of setting>() and set<name of setting>() to be generalised, whereby I pass it a key for a setting (and nothing more), and it handles everything else?

I’ve identified every setting to have these shared elements:

  1. settings key (e.g. SettingsKey.TimeFormat in my example)
  2. unique type (could be AnyObject, String, Int, Bool etc. e.g. enum TimeFormat in my example)
  3. unique property (e.g. timeFormat in my example)
  4. default value (e.g. TimeFormat.ampm in my example)

Thanks as always!

UPDATE:

This may or may not make a difference, but considering all settings will have a default value, I realised they don’t need to be a non-optional optional but can have the default set at initialisation.

That is, change:

var timeFormat: TimeFormat!

func initTimeFormat() {
    guard let format: TimeFormat = UserDefaultsManager.get(forKey: SettingKeys.TimeFormat.rawValue) else {
        self.setTimeFormat(to: .ampm)
        return
    }
    
    self.timeFormat = format
}

To:

var timeFormat: TimeFormat = .ampm

func initTimeFormat() {
    guard let format: TimeFormat = UserDefaultsManager.get(forKey: SettingKeys.TimeFormat.rawValue) else {
        UserDefaultsManager.set(self.timeFormat, forKey: SettingKeys.TimeFormat.rawValue)
        return
    }
    
    self.timeFormat = format
}

The setTimeFormat() function is still needed when its value is changed by the user in-app.


Solution

  • Note that your settings don't have to be stored properties. They can be computed properties:

    var timeFormat: TimeFormat {
        get {
            UserDefaultsManager.get(forKey: SettingKeys.TimeFormat.rawValue) ?? .ampm
        }
        set {
            UserDefaultsManager.set(newValue, forKey: SettingKeys.TimeFormat.rawValue)
        }
    }
    

    You don't have to write as much code this way, when you want to add a new setting. Since you will just be adding a new setting key enum case, and a computed property.

    Further more, this computed property can be wrapped into a property wrapper:

    @propertyWrapper
    struct FromUserDefaults<T: Codable> {
        let key: SettingKeys
        let defaultValue: T
    
        init(key: SettingKeys, defaultValue: T) {
            self.key = key
            self.defaultValue = defaultValue
        }
    
        var wrappedValue: T {
            get {
                UserDefaultsManager.get(forKey: key.rawValue) ?? defaultValue
            }
            set {
                UserDefaultsManager.set(newValue, forKey: key.rawValue)
            }
        }
    }
    

    Usage:

    class Settings {
        @FromUserDefaults(key: .TimeFormat, defaultValue: .ampm)
        var timeFormat: TimeFormat
    }
    

    Now to add a new setting, you just need to write a new enum case for the key, and two more lines of code.