Mastering AppStorage in SwiftUI

fatbobman ( 东坡肘子)
7 min readApr 12, 2023

Preface

In Apple's ecosystem of applications, developers more or less use UserDefaults. Personally, I prefer to save user-customizable configuration information (precision, units, colors, etc.) in UserDefaults. As the configuration information increases, the use of @AppStorage in SwiftUI views becomes more and more common.

In [Health Notes 3], I plan to provide users with more custom options, and the simple calculation is that there will be 40-50 items. In the configuration view, all the UserDefaults content used will be injected into the code.

This article discusses how to use @AppStorage elegantly, efficiently, and safely in SwiftUI, without relying on third-party libraries, to solve the pain points in current @AppStorage usage:

  • Limited supported data types
  • Tedious declaration
  • Easy to have spelling mistakes in the declaration
  • A large number of @AppStorage cannot be uniformly injected

A Chinese version of this post is available here.

Don’t miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to fatbobman’s Swift Weekly and receive weekly insights and valuable content directly to your inbox.

In light of the fact that my blog, Fatbobman’s Blog, now offers all articles in English, starting from April 1, 2024, I will no longer continue updating articles on Medium. You are cordially invited to visit my blog for more content.

Basic guide to @AppStorage

@AppStorage is a property wrapper provided by the SwiftUI framework. The original intention was to create a shortcut method for saving and reading UserDefaults variables in the view. The behavior of @AppStorage in the view is very similar to that of @State. When its value changes, it will cause the view that depends on it to be invalidated and redrawn.

When declaring @AppStorage, you need to specify the key name (Key) and default value saved in UserDefaults.

@AppStorage("username") var name = "fatbobman"

userName is the key name, fatbobman is the default value set for username. If username already has a saved value in UserDefaults, that value will be used.

If no default value is set, the variable will be an optional value type.

@AppStorage("username") var name:String?

By default, UserDefaults.standard is used, but you can also specify other UserDefaults.

public extension UserDefaults {
static let shared = UserDefaults(suiteName: "group.com.fatbobman.examples")!
}

@AppStorage("userName",store:UserDefaults.shared) var name = "fat"

Modifying UserDefaults will directly affect the corresponding @AppStorage.

UserDefaults.standard.set("bob",forKey:"username")

The above code will update all views that depend on @AppStorage("username").

UserDefaults is an efficient and lightweight persistence solution, but it has the following drawbacks:

  • Data insecurity

Its data is relatively easy to extract, so do not save important data related to privacy.

  • Uncertain timing of persistence

For efficiency considerations, the data in UserDefaults is not persisted immediately when it changes, and the system will save the data to the hard disk when it considers it appropriate. Therefore, it is possible that the data cannot be completely synchronized, and in severe cases, there is a possibility of data loss. Try not to save critical data that affects the integrity of the App in it. In case of data loss, the App can still run normally based on the default values.

Although @AppStorage exists as a property wrapper for UserDefaults, @AppStorage does not support all property list data types. Currently, it only supports: Bool, Int, Double, String, URL, Data (UserDefaults supports more types).

Adding support for data types in @AppStorage

In addition to the above types, @AppStorage also supports data types that conform to the RawRepresentable protocol and whose RawValue is Int or String. By adding support for the RawRepresentable protocol, we can read data types that were originally not supported by @AppStorage in UserDefaults.

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 natively 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]

For enum types with RawValue of Int or String, they can be used directly, for example:

enum Options:Int{
case a,b,c,d
}

@AppStorage("option") var option = Options.a

Safe and Convenient Declaration (1)

There are two unpleasant aspects of declaring @AppStorage:

  • Key (string) must be set every time
  • Default value must be set every time

Furthermore, developers find it difficult to enjoy the fast and secure experience brought by code autocompletion and compile-time checking.

A better solution is to declare @AppStorage centrally and inject it into each view by reference. Given SwiftUI's refresh mechanism, we must retain the DynamicProperty feature of @AppStorage even after centralized declaration and separate injection--refreshing the view when the value of UserDefaults changes.

The following code satisfies the above requirements:

enum Configuration{
static let name = AppStorage(wrappedValue: "fatbobman", "name")
static let age = AppStorage(wrappedValue: 12, "age")
}

To use in a view, follow these steps:

let name = Configuration.name
var body:some View{
Text(name.wrappedValue)
TextField("name",text:name.projectedValue)
}

Using name is similar to declaring it directly in the code with @AppStorage. However, the cost is that wrappedValue and projectedValue need to be explicitly annotated.

Is there a way to achieve the same result without annotating wrappedValue and projectedValue? In "Safe and Convenient Declaration (II)", we will try another solution.

Centralized Injection

Before introducing another convenient declaration method, let's talk about the issue of centralized injection.

[Healthy Notes 3] Currently, there are a lot of configuration information, and it would be inconvenient to inject them separately. I need to find a way to declare and inject them collectively.

The method used in "Safe and Convenient Declaration (I)" satisfies the situation of separate injection, but if we want to inject them uniformly, we need other means.

I do not intend to consolidate the configuration data into a struct and save them uniformly through the support of the RawRepresentable protocol. In addition to the performance loss caused by data conversion, another important issue is that if data loss occurs, saving them one by one can still protect most user settings.

In the "Basic Guide", we mentioned that @AppStorage behaves similarly to @State in views; moreover, @AppStorage has a magical trait that is not mentioned in the official documentation, it has the same feature as @Published in ObservableObject--its value change triggers objectWillChange. This feature only occurs on @AppStorage, and @State and @SceneStorage do not have this ability.

Currently, I cannot find the reason for this feature from the documentation or exposed code, so the following code cannot obtain long-term official guarantees

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 has the code become much cleaner, but also, due to only needing to declare once in Defaults, it greatly reduces the difficult-to-diagnose bugs caused by spelling errors in strings.

The declaration method used in Defaults is @AppStorage, while the original construction form of AppStorage is used in Configuration. The purpose of this change is to ensure the normal operation of the view update mechanism.

Safe and Convenient Declaration (2)

The method provided in Centralized Injection has basically solved the inconvenience I encountered when using @AppStorage currently, but we can also try another elegant and interesting way of declaring injection one by one.

First, let's modify the code in Defaults.

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 called 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 use the following code in the view to declare injection one by one:

@Default(\\.name) var name
Text(name)
TextField("name",text:$name)

Inject them one by one without marking wrappedValue and projectedValue. Using keyPath avoids possible string spelling errors.

You can't have your cake and eat it too. The above method is still not perfect because it can lead to over-dependency. Even if you only inject one UserDefaults key-value (such as name) in the view, when the content of other keys in Defaults that have not been injected changes (such as age), the view that depends on name will also be refreshed.

However, since the frequency of configuration data changes is usually low, it will not cause any performance burden on the app.

Conclusion

This article proposes several solutions to solve the pain points of @AppStorage without using third-party libraries. Different implementation methods are used to ensure the view refresh mechanism.

In SwiftUI, even a small aspect can bring a lot of fun.

If you want to achieve a perfect way of injecting one by one (auto-completion, compiler checks, and no over-dependency), you can create your own UserDefaults response code, which is beyond the scope of this article's discussion on @AppStorage.

If you found this article helpful or enjoyed reading it, consider making a donation to support my writing. Your contribution will help me continue creating valuable content for you.
Donate via Patreon, Buy Me aCoffee or PayPal.

Want to Connect?

@fatbobman on Twitter.

--

--