Preface
Within the Apple ecosystem’s applications, developers more or less all use UserDefaults to some extent. Personally, I prefer to save user-customizable configuration information (precision, units, colors, etc.) in UserDefaults. As the number of configuration options increases, the use of @AppStorage in SwiftUI views grows as well.
This article discusses how to use @AppStorage elegantly, efficiently, and safely in SwiftUI. Without relying on third-party libraries, we aim to solve the pain points currently experienced with @AppStorage:
- Limited supported data types
- Tedious declarations
- Prone to typos in declarations
- Difficulty in uniformly injecting a large number of @AppStorages
@AppStorage Basic Guide
@AppStorage is a property wrapper provided by the SwiftUI framework, designed to create a shortcut for saving and retrieving UserDefaults variables within views. @AppStorage behaves similarly to @State in views; when its value changes, the dependent view becomes invalid and is redrawn.
When declaring @AppStorage, you need to specify the key name to be saved in UserDefaults and a default value.
@AppStorage("username") var name = "fatbobman"
userName
is the key name, and fatbobman
is the default value set for username
. If username
already has a value in UserDefaults, that value is used.
If you do not set a default value, the variable will be of an optional type.
@AppStorage("username") var name: String?
By default, UserDefaults.standard is used, but you can specify other UserDefaults instances.
public extension UserDefaults {
static let shared = UserDefaults(suiteName: "group.com.fatbobman.examples")!
}
@AppStorage("userName", store: UserDefaults.shared) var name = "fat"
Operations on UserDefaults directly affect the corresponding @AppStorage.
UserDefaults.standard.set("bob", forKey: "username")
The code above will update all views dependent on @AppStorage("username")
.
UserDefaults is an efficient and lightweight persistence solution, but it has the following drawbacks:
-
Data is not secure
Its data can be easily extracted, so do not save important data related to privacy.
-
Persistence timing is uncertain
For efficiency, data in UserDefaults is not immediately persisted upon changes. The system will save data to the disk when it deems appropriate. Therefore, there might be situations where data is not completely synchronized, with the possibility of complete data loss in severe cases. Try not to save critical data that affects the app’s integrity in UserDefaults. In case of data loss, the app can still operate normally based on the default values.
Although @AppStorage exists as a property wrapper for UserDefaults, it does not support all property list
data types. Currently, it only supports: Bool, Int, Double, String, URL, and Data (UserDefaults supports more types).
Extending the Data Types Supported by @AppStorage
In addition to the types mentioned above, @AppStorage also supports data types that conform to the RawRepresentable
protocol with RawValue
as Int
or String
. By adding support for the RawRepresentable
protocol, we can read and store data types not originally supported by @AppStorage.
The following code adds support for the Date
type:
extension Date: RawRepresentable {
public typealias RawValue = String
public init?(rawValue: RawValue) {
guard let data = rawValue.data(using: .utf8),
let date = try? JSONDecoder().decode(Date.self, from: data) else {
return nil
}
self = date
}
public var rawValue: RawValue {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8) else {
return ""
}
return result
}
}
It is used in the same way as directly supported types:
@AppStorage("date") var date = Date()
The following code adds support for Array
:
extension Array: RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode([Element].self, from: data)
else { return nil }
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "[]"
}
return result
}
}
@AppStorage("selections") var selections = [3, 4, 5]
Enumerations with RawValue
as Int
or String
can be used directly, for example:
enum Options: Int {
case a, b, c, d
}
@AppStorage("option") var option = Options.a
Safe and Convenient Declaration (I)
There are two displeasing aspects of declaring @AppStorage:
- A Key (string) must be set every time.
- A default value must be set each time.
Moreover, developers hardly experience the convenience and security of auto-completion and compile-time checks.
A better solution is to centrally declare @AppStorage and inject it by reference into each view. Given SwiftUI’s refresh mechanism, we must retain the DynamicProperty
feature of @AppStorage—refreshing the view when the UserDefaults value changes—even after centralized declaration and individual injection.
The following code meets the above requirements:
enum Configuration {
static let name = AppStorage(wrappedValue: "fatbobman", "name")
static let age = AppStorage(wrappedValue: 12, "age")
}
The usage in the view is as follows:
let name = Configuration.name
var body: some View {
Text(name.wrappedValue)
TextField("name", text: name.projectedValue)
}
name
is similar to a direct declaration with @AppStorage in the code. However, the price to pay is the need to explicitly mark wrappedValue
and projectedValue
.
Is there an implementation scheme that does not mark
wrappedValue
andprojectedValue
yet achieves the above results? We will try another solution in the section on safe and convenient declaration (II).
Central Injection
Before introducing another convenient declaration method, let’s first talk about the problem of central injection.
【Healthy Notes 3】Currently faces the situation described in the preface, with many configuration information contents. If injected separately, it would be quite troublesome. I need to find a way to declare and inject them together.
The method used in the safe and convenient declaration (I) is satisfactory for individual injections. However, if we want to inject them together, we need other means.
I don’t intend to aggregate the configuration data into a structure and save it uniformly through supporting the
RawRepresentable
protocol. In addition to the performance loss caused by data conversion, another important problem is that if data loss occurs, the method of saving each item separately can still protect most user settings.
In the basic guide, we mentioned that @AppStorage behaves very similarly to @State in views; not only that, but @AppStorage also has a magical quality never mentioned in the official documentation, it triggers objectWillChange
in ObservableObject when its value changes, just like @Published. This feature only occurs with @AppStorage; @State and @SceneStorage do not have this capability.
I cannot find the reason for this feature from the documentation or exposed code, so the following code does not receive official long-term assurance
Thanks to netizen hstdt’s feedback, Apple has clearly mentioned @AppStorage’s support for the specific feature in the official documentation (supported from 14.5 and above).
May 2022 update: For the principle of @AppStorage and @Published calling objectWillChange of the class instance that wraps it, please refer to Going Beyond @Published:Empowering Custom Property Wrappers.
class Defaults: ObservableObject {
@AppStorage("name") public var name = "fatbobman"
@AppStorage("age") public var age = 12
}
View code:
@StateObject var defaults = Defaults()
...
Text(defaults.name)
TextField("name", text: defaults.$name)
Not only is the code much neater, but since it only needs to be declared once in Defaults
, it greatly reduces the hard-to-troubleshoot bugs caused by typos in strings.
Defaults
uses the @AppStorage declaration method, whileConfiguration
uses the original AppStorage constructor. The change is to ensure that the view update mechanism works properly.
Safe and Convenient Declaration (II)
The method provided in Central Injection has basically solved the inconvenience I encountered with the current use of @AppStorage. However, we can still try another elegant and interesting way to declare and inject each item individually.
First, modify the Defaults
code:
public class Defaults: ObservableObject {
@AppStorage("name") public var name = "fatbobman"
@AppStorage("age") public var age = 12
public static let shared = Defaults()
}
Create a new property wrapper Default
:
@propertyWrapper
public struct Default<T>: DynamicProperty {
@ObservedObject private var defaults: Defaults
private let keyPath: ReferenceWritableKeyPath<Defaults, T>
public init(_ keyPath: ReferenceWritableKeyPath<Defaults, T>, defaults: Defaults = .shared) {
self.keyPath = keyPath
self.defaults = defaults
}
public var wrappedValue: T {
get { defaults[keyPath: keyPath] }
nonmutating set { defaults[keyPath: keyPath] = newValue }
}
public var projectedValue: Binding<T> {
Binding(
get: { defaults[keyPath: keyPath] },
set: { value in
defaults[keyPath: keyPath] = value
}
)
}
}
Now we can declare and inject individually in the view using the following code:
@Default(\.name) var name
Text(name)
TextField("name", text: $name)
Individual injection without the need to mark wrappedValue
and projectedValue
. The use of keyPath
avoids potential typos in strings.
You can’t have your cake and eat it too; the above method is not entirely perfect—it will result in over-dependence. Even if you only inject one UserDefaults key in the view (such as name
), when other keys in Defaults
that are not injected change (age
changes), the view that depends on name
will also be refreshed.
However, since configuration data typically changes infrequently, it doesn’t impose any significant performance burden on the app.
Conclusion
This article proposed several solutions to address the pain points of @AppStorage without using third-party libraries. To ensure the refresh mechanism of the view, different implementation methods were used.
Even a seemingly insignificant aspect of SwiftUI has many fun aspects worth exploring.