preface

In the apple ecosystem, developers use UserDefaults more or less. My personal habit is to save user-defined configuration information (precision, unit, color, etc.) in UserDefaults. As the configuration information increases, @appStorage is used more and more in SwiftUI view.

In health note 3, I plan to open up more customization options to users, with a simple count of 40-50, and inject all UserDefaults into the code in the configuration view.

This article explores how to use @AppStorage in SwiftUI elegantly, efficiently, and securely, without the help of third-party libraries, to solve the current pain points in using @AppStorage:

  • Few data types are supported
  • The statement trival
  • Statements are prone to spelling errors
  • A large number of @AppStorages cannot be injected uniformly

@AppStorage Basic Guide

AppStorage @AppStorage is a properties wrapper provided by the SwiftUI framework. It is designed to create a quick way to save and read UserDefaults variables in views. @AppStorage behaves much like @state in views; changing its value will invalidate and redraw the views it depends on.

@appStorage specifies the name of the Key stored in UserDefaults and the default value.

@AppStorage("username") var name = "fatbobman"
Copy the code

UserName is the key name and fatbobman is the default value set for userName. If userName already has a value in UserDefaults, the saved value is used.

If the default value is not set, the variable’s value type is optional

@AppStorage("username") var name:String?
Copy the code

By default, userdefaults.standard is used, but other UserDefaults can be specified.

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

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

The UserDefaults operation will directly affect the corresponding @appstorage

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

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

UserDefaults is an efficient and lightweight persistence scheme that has the following disadvantages:

  • Data insecurity

    Its data is relatively easy to extract, so don’t keep data that is important to your privacy

  • Persistence timing is uncertain

    For the sake of efficiency, data in UserDefaults is not persisted immediately when it changes, and the system will save the data to disk when it sees fit. Therefore, data may not be fully synchronized, and even data may be completely lost. Try not to save key data that will affect the integrity of App execution. In the case of data loss, the App can still run normally according to the default value

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

Added data types supported by @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 and store unsupported data types in @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
    }
}
Copy the code

Use exactly the same as the directly supported types:

@AppStorage("date") var date = Date(a)Copy the code

The following code adds support for arrays:

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
    }
}
Copy the code
@AppStorage("selections") var selections = [3.4.5]
Copy the code

If RawValue is an Int or a String enumeration, you can use it directly.

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

@AppStorage("option") var option = Options.a
Copy the code

Statement of Safety and Convenience (1)

There are two unpleasant things about the @AppStorage approach:

  • You have to set the Key every time
  • Set defaults every time

It is also difficult for developers to enjoy the fast and safe experience of automatic code completion and compile-time checks.

A better solution is to declare @appStorage centrally and inject it by reference in each view. Given SwiftUI’s refresh mechanism, we need to keep @AppStorage’s DynamicProperty feature in place when 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")}Copy the code

Use it in a view as follows:

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

Name has a similar effect to declaring it directly in the code via @appStorage. The trade-off is that wrappedValue and projectedValue need to be clearly flagged.

Is there a way to achieve this without labeling wrappedValue and projectedValue? In the statement of security and Convenience (2) ** we will try to use another approach.

Concentrated injection

Let’s talk a little bit about centralized injection before introducing another convenient way to make declarations.

[Health Note 3] Currently faces the situation described in the preface. There is a lot of configuration information, and it is troublesome to inject it alone. I needed to find a way to centralize declarations and inject them together.

The methods used in ** security and Convenience declaration (1) ** are fine for individual injections, but we need other methods if we want to unify injections.

My intention is not to summarize configuration data into a structure that is kept uniformly by supporting the RawRepresentable protocol. In addition to the performance penalty associated with data conversion, another important issue is that in the event of data loss, the item-by-item approach protects most user Settings.

In the basic guide, we mentioned that @AppStorage behaves very similar to @State in views; Not only that, but @AppStorage has a magical property that is never mentioned in the official documentation. It has the same property in the ObservableObject as @Published — an objectWillChange is triggered when its value changes. This feature only applies to @AppStorage. Neither @State nor @Scenestorage has this capability.

I can’t find the reason for this feature from the documentation or exposed code, so the code below is not officially guaranteed for the long term

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

View code:

@StateObject var defaults = Defaults(a).
Text(defaults.name)
TextField("name",text:defaults.$name)
Copy the code

Not only is the code much cleaner, but because you only need to declare in Defaults once, it greatly reduces the Bug that is not easy to check due to string spelling errors.

The @appStorage declaration is used in Defaults, and the original construction of AppStorage is used in Configuration. The purpose of the change is to ensure that the view update mechanism works properly.

Statement on Safety and Convenience (2)

The approach provided in central injection has mostly solved the problems I’m currently experiencing with @AppStorage, but there’s another elegant and interesting way to try declaration-by-statement injection.

So let’s first change the Defaults code

public class Defaults: ObservableObject {
    @AppStorage("name") public var name = "fatbobman"
    @AppStorage("age") public var age = 12
    public static let shared = Defaults()}Copy the code

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
            }
        )
    }
}
Copy the code

Now we can declare each injection in the view with the following code:

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

Inject one by one without annotating wrappedValue and projectedValue. By using keyPath, possible misspellings of strings are avoided.

You can’t have your cake and eat it, and this approach isn’t perfect — there can be over-reliance. Even if you inject only one UserDefaults key (such as name) into the view, the views that depend on name will be refreshed if any other uninjected key in the view changes (age changes).

However, since the configuration data usually changes very infrequently, there is no performance burden on the App.

conclusion

This paper proposes several solutions to the pain points of @AppStorage without using third-party libraries. In order to ensure the view refresh mechanism, different implementation methods are adopted.

Even a small part of SwiftUI is a lot of fun to explore.

If you want a perfect item-by-item injection (auto-completion, compiler checking, no over-dependency) you can do this by creating your own UserDefaults response code, which is beyond the scope of this article for @AppStorage.

This article originally appeared on my personal blog, Swift Notepad.