Soon after I published this article, Christian Tietze wrote a fair criticism of this idea here.
Once you understand how property wrappers work, you can use this article to apply it to user defaults. The main idea is that property wrappers allow you to store your values differently and even externally. That said, you may or may not want to implement this in a real app. I recommend you read this article first, and then go back to Christian’s to see more downsides of this idea other than the ones I mentioned below.
Last week we talked about Property Wrappers, what they are, and how they work with Swift. In this article, we will build upon that to write a very nice property wrapper for user settings based on NSUserDefaults
.
But Why?
I don’t know about you, but a pattern I see a lot (and I’m guilty of doing this myself) is to just wrap all user defaults in a Singleton. This works, but singletons in general are not a pattern everyone is particularly a fan of. Singletons can grow, and it can be a pain to maintain if they have too many properties. How do you logically separate the properties? Does it even make sense to wrap a singleton around everything related to user defaults?
For this reason, people have devised different ways to deal with user defaults, and I’m going to show you a new one.
The Advantages
I found more pros than cons when it comes to using wrapped properties for use defaults, including:
- It’s more obvious to see what settings are relevant in different view controllers. In this architecture, we will write a property wrapper for User Defaults, and we will use it for all defaults that come to mind. This way, we can create properties that represent said setting, and then you will know what settings are relevant in different view controllers or other areas of your app. For example, suppose you have an app where you can configure a default locale, calendar type, timezone, if the app should be locked with FaceID when entering the background, and a currency type. Then you have a
CalendarViewController
where the user can see his configured locale, timezone, and calendar type. Whether the app should lock and currency types are irrelevant here. By treating them as properties, you can put them at the top of the class and the next developer who maintains the code will know what defaults are relevant for that screen:
class CalendarViewController: UIViewController {
@UserDefault(key: .calendarType) var calendarType: String
@UserDefault(key: .timezone) var timezone: String
@UserDefault(key: .locale) var locale: String
//...
}
- You don’t have to maintain very large singleton files for your settings. Instead you just have to write a property wrapper file and never concern yourself with it again.
The Disadvantages
There is one disadvantage that I was able to find with this method, so if you find a good way to deal with it, let me know, I’m more than happy to hear potential ideas for this.
There is no easy way to write testable code with this. You can pass in a UserDefaults
object to each property, but this is may not be the best idea if you are these wrapped properties in many places.
Property Wrappers for User Defaults
At the end of this tutorial, we will have two different property wrappers for settings, but you will essentially write them once and do small modifications to them when necessary.
The UserDefault
Property Wrapper
This property wrapper will be used to deal with standard data types supported by UserDefaults
. In other words, it will be compatible with String
s, Int
s, Bool
s, Data
, and others that work with UserDefaults by default.
Start by writing this skeleton:
@propertyWrapper
struct UserDefault<T> {
}
We want it to be generic because user defaults can store many different data types. By making it generic, it can support any data type that user defaults supports.
Then, we are going to add a few properties, and an enum:
@propertyWrapper
struct UserDefault<T> {
enum Key: String {
case lockOnExit = "lock_on_exit"
case showImages = "show_images"
}
let userDefaults: UserDefaults
let key: Key
let defaultValue: T
}
The Key
enum will take keys that will be used to retrieve the data from UserDefaults
internally. You can choose to not use this and just pass in the string keys, but I prefer to have an enum because I get autocomplete and it’s harder to make mistakes when dealing with defaults.
As for the properties, we will inject a UserDefaults
object and we will provide one by default when the user does not specify one. The key
property holds the key of the default we want to retrieve. Finally, we define a default value to use when the key we provided does not exist in the underlying user defaults.
Next, implement a simple initializer for the property wrapper. We will force the user to provide a key, but the default value and underlying UserDefaults
object are optional:
init(userDefaults: UserDefaults = UserDefaults.standard,
key: Key,
defaultValue: T) {
self.userDefaults = userDefaults
self.key = key
self.defaultValue = defaultValue
}
Finally, implement the wrappedValue
calculated property. This will do the magic of retrieving and saving data to UserDefaults
:
var wrappedValue: T {
get { return userDefaults.object(forKey: key.rawValue) as? T ?? defaultValue }
set { userDefaults.set(newValue, forKey: key.rawValue) }
}
We now have a fully functional property wrapper for standard UserDefault values. For reference, the full implementation is below:
@propertyWrapper
struct UserDefault<T> {
enum Key: String {
case lockOnExit = "lockOnExit"
case showImages = "show_images"
}
let userDefaults: UserDefaults
let key: Key
let defaultValue: T
init(userDefaults: UserDefaults = UserDefaults.standard,
key: Key,
defaultValue: T) {
self.userDefaults = userDefaults
self.key = key
self.defaultValue = defaultValue
}
var wrappedValue: T {
get { return userDefaults.object(forKey: key.rawValue) as? T ?? defaultValue }
set { userDefaults.set(newValue, forKey: key.rawValue) }
}
}
Using it is very easy:
class ImagesViewController: UIViewController {
@UserDefault(key: .lockOnExit, defaultValue: true) var maxAttempts
@UserDefault(key: .showImages, defaultValue: false) var showImages
}
Now that you have this class, your user defaults are more obvious and it’s easier to know what context they should be used in.
The ComplexUserDefault
Property Wrapper
It’s common to store more complex data in UserDefaults, such as complete JSON structures, or just complete objects. To handle these cases, I created another property wrapper called ComplexUserDefault
which serializes objects into Data
using Codable
and persists them that way. There are many ways you could do this, but I found this one was better and more self contained than the alternatives (like using protocols and extensions).
This property wrapper looks very similar to the previous one, but with a few changes. First, you cannot specify a default value because I found it doesn’t make sense in this case. So this property wrapper can return and store nil values. Then, the wrappedProperty
can return nil and it takes care of the serialization and deserialization of values. Finally, the generic value is constrained to objects that conform to Codable
.
The complete implementation looks like this:
@propertyWrapper
struct ComplexUserDefault<T: Codable> {
enum Key: String {
case userInfo = "user_info"
}
let userDefaults: UserDefaults
let key: Key
init(userDefaults: UserDefaults = UserDefaults.standard,
key: Key) {
self.userDefaults = userDefaults
self.key = key
}
var wrappedValue: T? {
get {
guard let data = userDefaults.data(forKey: key.rawValue) else { return nil }
let object = try? JSONDecoder().decode(T.self, from: data)
return object
}
set {
guard let object = newValue else { return }
let data = try? JSONEncoder().encode(object)
userDefaults.set(data, forKey: key.rawValue)
}
}
}
And to show how it works, let’s create a UserInfo
object which will be stored in the user_info
key:
struct UserInfo: Codable {
let username: String
let email: String
let firstName: String
let lastName: String
}
Using it as a property is the same as any other property wrapper:
class UserProfile {
@ComplexUserDefault(key: .userInfo) var userInfo: UserInfo?
}
And finally, assigning the property is nothing different:
let profile = UserProfile()
profile.userInfo = UserInfo(username: "aibanez",
email: "[email protected]",
firstName: "Andy",
lastName: "Ibanez")
You now have two property wrappers to deal with your settings in a clean and independent way. You don’t have to fight with singletons for your defaults ever again.
Conclusion
Property Wrappers are very powerful, and they can help you kill common patterns in favor of something nicer and more contextually aware. Using them for user defaults has a lot of benefits and it helps you write cleaner code, not to mention it can help new programmers in a project get up to speed with how defaults are stored.