Mastering @AppStorage in SwiftUI

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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.

Swift
@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.

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

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

Swift
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.

Swift
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:

Swift
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:

Swift
@AppStorage("date") var date = Date()

The following code adds support for Array:

Swift
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:

Swift
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:

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

The usage in the view is as follows:

Swift
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 and projectedValue 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.

Swift
class Defaults: ObservableObject {
    @AppStorage("name") public var name = "fatbobman"
    @AppStorage("age") public var age = 12
}

View code:

Swift
@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, while Configuration 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:

Swift
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:

Swift
@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:

Swift
@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.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed