The defaults system is a convenient solution for storing small pieces of data, but it has its limitations. The defaults system only supports a handful of data types.
Data
Date
Number
String
Array
Dictionary
The interface of the UserDefaults
class is easy to use, but it lacks type safety. If you want to store and retrieve an enum, for example, you need to jump through a few hoops. Let me show you an example from Cloudy, the application we build in Mastering MVVM With Swift.
In Cloudy's settings view, the user can choose how Cloudy formats temperatures. The user can choose between the Celsius scale and the Fahrenheit scale. To make this easier, the project declares an enum, TemperatureNotation
. The raw values of TemperatureNotation
are of type Int
. Why that is important becomes clear in a moment.
import Foundation
enum TemperatureNotation: Int {
// MARK: - Cases
case celsius
case fahrenheit
}
It isn't possible to store a TemperatureNotation
object in the user's defaults database. To work around this limitation, Cloudy stores the user's setting as an integer. This is possible because the raw values of TemperatureNotation
are of type Int
.
// Define Setting
let setting: TemperatureNotation = .celsius
// Store Setting
UserDefaults.standard.set(setting.rawValue, forKey: "temperatureNotation")
Fetching the user's setting is similar. We ask the defaults system for the value of the temperatureNotation
key and use that value to create a TemperatureNotation
object.
// Fetch Setting
let rawValue = UserDefaults.standard.integer(forKey: "temperatureNotation")
let notation = TemperatureNotation(rawValue: rawValue)
Adding Type Safety with Extensions
To reduce code duplication and avoid string literals, we can take advantage of extensions. We define an extension for the UserDefaults
class. Nested within the extension, we declare a private enum with name Keys
. As the name suggests, the private enum defines the keys used in the user's defaults database.
extension UserDefaults {
private enum Keys {
static let temperatureNotation = "temperatureNotation"
}
}
We declare a computed property, temperatureNotation
, of type TemperatureNotation
and define a getter and a setter.
extension UserDefaults {
private enum Keys {
static let temperatureNotation = "temperatureNotation"
}
// MARK: - Temperature Notation
var temperatureNotation: TemperatureNotation {
get {
}
set {
}
}
}
In the getter of the computed property, we ask the user's defaults database for the integer that is associated with the temperatureNotation
key. The integer(forKey:)
method returns 0
if the given key doesn't exist. We use the value returned by the integer(forKey:)
method to create a TemperatureNotation
object. Because the init(rawValue:)
initializer is failable, we fall back to TemperatureNotation.celsius
if the initialization fails.
extension UserDefaults {
private enum Keys {
static let temperatureNotation = "temperatureNotation"
}
// MARK: - Temperature Notation
var temperatureNotation: TemperatureNotation {
get {
let rawValue = integer(forKey: Keys.temperatureNotation)
return TemperatureNotation(rawValue: rawValue) ?? .celsius
}
set {
}
}
}
The implementation of the setter of the computed property couldn't be easier. We ask the TemperatureNotation
object for its raw value and store the raw value in the user's defaults database.
extension UserDefaults {
private enum Keys {
static let temperatureNotation = "temperatureNotation"
}
// MARK: - Temperature Notation
var temperatureNotation: TemperatureNotation {
get {
let rawValue = integer(forKey: Keys.temperatureNotation)
return TemperatureNotation(rawValue: rawValue) ?? .celsius
}
set {
setValue(newValue.rawValue, forKey: Keys.temperatureNotation)
}
}
}
The computed property provides type-safe access to the value stored in the defaults system. Objects interested in the setting don't need to work with raw values. They can ask the defaults system for a TemperatureNotation
object.
Convenience and Elegance
I use this technique often and you can understand why if you take a look at these examples. Storing or retrieving the setting is concise, readable, and type-safe. The syntax looks and feels great.
// Read Setting
UserDefaults.standard.temperatureNotation
// Write Setting
UserDefaults.standard.temperatureNotation = .fahrenheit
Adding Combine to the Mix
We can take it a step further by reactifying the solution we implemented. For this to work, we need to declare another computed property that makes the temperatureNotation
computed property available in Objective-C. The computed property is required because the TemperatureNotation
enum cannot be represented in Objective-C. We make the raw value available in Objective-C through the rawTemperatureNotation
computed property. Notice that rawTemperatureNotation
is declared privately. There is no need for other objects to access the computed property. By applying the objc
attribute, the compiler makes rawTemperatureNotation
available in Objective-C. That also make the property key-value observing- or KVO-compliant.
extension UserDefaults {
private enum Keys {
static let temperatureNotation = "temperatureNotation"
}
// MARK: - Temperature Notation
var temperatureNotation: TemperatureNotation {
get {
let rawValue = integer(forKey: Keys.temperatureNotation)
return TemperatureNotation(rawValue: rawValue) ?? .celsius
}
set {
setValue(newValue.rawValue, forKey: Keys.temperatureNotation)
}
}
@objc private var rawTemperatureNotation: Int {
get {
integer(forKey: Keys.temperatureNotation)
}
set {
setValue(newValue, forKey: Keys.temperatureNotation)
}
}
}
We define a publisher with name temperatureNotationPublisher
. The publisher is of type AnyPublisher
. Its Output
type is TemperatureNotation
and its Failure
type is Never
. In the body of the computed property, we create a publisher that emits a value when the value of a given key path changes. This only works if the key path is KVO-compliant. The publisher(for:)
method accepts the key path as its first argument and an optional set of options as its second argument.
We apply the compactMap
operator to transform the values emitted by the publisher to TemperatureNotation
objects. We apply the compactMap
operator instead of the map
operator because the initialization of the TemperatureNotation
object can fail. The Output
type of the resulting publisher is TemperatureNotation
. The eraseToAnyPublisher()
method wraps the resulting publisher with a type eraser.
extension UserDefaults {
private enum Keys {
static let temperatureNotation = "temperatureNotation"
}
// MARK: - Temperature Notation
var temperatureNotation: TemperatureNotation {
get {
let rawValue = integer(forKey: Keys.temperatureNotation)
return TemperatureNotation(rawValue: rawValue) ?? .celsius
}
set {
setValue(newValue.rawValue, forKey: Keys.temperatureNotation)
}
}
var temperatureNotationPublisher: AnyPublisher<TemperatureNotation, Never> {
publisher(for: \.rawTemperatureNotation)
.compactMap { rawValue -> TemperatureNotation? in
TemperatureNotation(rawValue: rawValue)
}
.eraseToAnyPublisher()
}
@objc private var rawTemperatureNotation: Int {
get {
integer(forKey: Keys.temperatureNotation)
}
set {
setValue(newValue, forKey: Keys.temperatureNotation)
}
}
}
This solution opens up several possibilities. In this example, we combine temperatureNotationPublisher
with a publisher that emits weather data. We apply the map
operator to format the temperature to match the user's setting.
var temperaturePublisher: AnyPublisher<String?, Never> {
weatherDataPublisher.combineLatest(UserDefaults.standard.temperatureNotationPublisher)
.map { weatherData, temperatureNotation -> String? in
switch temperatureNotation {
case .fahrenheit:
return String(format: "%.1f °F", weatherData.temperature)
case .celsius:
return String(format: "%.1f °C", weatherData.temperature.toCelcius)
}
}
.eraseToAnyPublisher()
}
Let's put the cherry on the cake by moving the formatting to the TemperatureNotation
enum. This is what that looks like. The format(temperature:from:)
method accepts the temperature as its first argument and a TemperatureNotation
object as its second argument. It uses a switch
statement to figure out how the temperature should be formatted.
enum TemperatureNotation: Int {
// MARK: - Cases
case celsius
case fahrenheit
// MARK: - Public API
func format(temperature: Double, from: Self) -> String {
switch (self, from) {
case (.celsius, .celsius):
return String(format: "%.1f °C", temperature)
case (.fahrenheit, .fahrenheit):
return String(format: "%.1f °F", temperature)
case (.celsius, .fahrenheit):
return String(format: "%.1f °C", (temperature - 32.0) * (5.0 / 9.0))
case (.fahrenheit, .celsius):
return String(format: "%.1f °F", (temperature * (9.0 / 5.0) + 32.0))
}
}
}
With the format(temperature:from:)
method in place, we can drastically simplify the implementation of temperaturePublisher
. We use a map
operator and shorthand argument syntax to format the raw values returned by the weather service into values the user understands.
var temperaturePublisher: AnyPublisher<String?, Never> {
weatherDataPublisher.combineLatest(UserDefaults.standard.temperatureNotationPublisher)
.map { $1.format(temperature: $0.temperature, from: .fahrenheit) }
.eraseToAnyPublisher()
}
What's Next?
The Swift programming language provides developers with a wide range of tools. It is up to you, the developer, to use them in creative ways. The solutions we covered in this episode show what is possible. The only limitation is your creativity.