Swift types with @AppStorage and @SceneStorage
@AppStorage and @SceneStorage are two SwiftUI property wrappers that have been introduced this year.
Since both are backed by plists behind the scenes, out of the box, we can use them with the following types: Bool, Int, Double, String, URL, and Data.
What about other types?
RawRepresentable types
Both @AppStorage and @SceneStorage offer two initializers accepting values conforming to the RawRepresentable protocol with an associated type RawValue of either Int or String:
extension AppStorage {
public init(
wrappedValue: Value,
_ key: String,
store: UserDefaults? = nil
) where Value: RawRepresentable, Value.RawValue == Int
public init(
wrappedValue: Value,
_ key: String,
store: UserDefaults? = nil
) where Value: RawRepresentable, Value.RawValue == String
}
extension SceneStorage {
public init(
wrappedValue: Value,
_ key: String,
) where Value: RawRepresentable, Value.RawValue == Int
public init(
wrappedValue: Value,
_ key: String,
) where Value: RawRepresentable, Value.RawValue == String
}
If you would like to dig deeper into
RawRepresentable, I recommend this NSHipster article by Mattt.
These initializers make it easy to store types such as enums:
// RawValue == Int
enum Fruit: Int, Identifiable, CaseIterable {
case banana
case orange
case mango
var id: Int { rawValue }
}
struct ContentView: View {
@AppStorage("fruit") private var fruit: Fruit = .mango
var body: some View {
Picker("My Favorite Fruit", selection: $fruit) {
ForEach(Fruit.allCases, id: \.self) {
Text("\($0)" as String)
}
}.pickerStyle(SegmentedPickerStyle())
}
}
// RawValue == String
enum Fruit: String, Identifiable, CaseIterable {
case banana
case orange
case mango
var id: String { rawValue }
}
struct ContentView: View {
@AppStorage("fruit") private var fruit: Fruit = .mango
var body: some View {
Picker("My Favorite Fruit", selection: $fruit) {
ForEach(Fruit.allCases, id: \.self) {
Text($0.id)
}
}.pickerStyle(SegmentedPickerStyle())
}
}
While this is great, our apps most likely need to store a set of settings/preferences:
we could store each of them separately, but we'd probably rather have an easy way to fetch and store a Codable instance instead. How can we do so?
Codable types
Imagine having a Preference struct with all our app settings:
enum Appearance: String, Codable, CaseIterable, Identifiable {
case dark
case light
case system
var id: String { rawValue }
}
struct Preferences: Codable {
var appearance: Appearance
// TODO: add more settings here
}
How can we use @AppStorage and @SceneStorage with our Codable type?
Unfortunately, so far, this doesn't seem to be possible. Please let me know if you have found a way.
In the meantime, we can take up the challenge and solve it ourselves.
There are many ways to approach this, one of the simplest is probably extending @Published:
private var cancellableSet: Set<AnyCancellable> = []
extension Published where Value: Codable {
init(wrappedValue defaultValue: Value, _ key: String, store: UserDefaults? = nil) {
let _store: UserDefaults = store ?? .standard
if
let data = _store.data(forKey: key),
let value = try? JSONDecoder().decode(Value.self, from: data) {
self.init(initialValue: value)
} else {
self.init(initialValue: defaultValue)
}
projectedValue
.sink { newValue in
let data = try? JSONEncoder().encode(newValue)
_store.set(data, forKey: key)
}
.store(in: &cancellableSet)
}
}
Credits to Victor Kushnerov for the original implementation.
Since @Published doesn't have the same kind of limitations as @AppStorage and @SceneStorage, we can extend it with this new initializer where:
- the first part sets the initial value (either the one currently stored or the passed default value)
- the second part creates an observer that will update
UserDefaultsevery time our@Publishedvalue changes.
Thanks to this new initializer @Published behaves similarly to @AppStorage, but for Codable types (the same approach can be used to bring @AppStorage support to iOS 13).
We can now go back to our ContentView and use our Codable Preference type:
class ContentViewModel: ObservableObject {
@Published("userPreferences") var preferences = Preferences(appearance: .system)
}
struct ContentView: View {
@StateObject var model = ContentViewModel()
var body: some View {
Picker("Appearance", selection: $model.preferences.appearance) {
ForEach(Appearance.allCases, id: \.self) {
Text(verbatim: $0.rawValue)
}
}.pickerStyle(SegmentedPickerStyle())
}
}
It's not as concise as an @AppStorage variable definition, but not too bad.
Similarly, we can take care of @SceneStorage where, instead of UserDefaults, we pass an UISceneSession instance:
private var cancellableSet: Set<AnyCancellable> = []
extension Published where Value: Codable {
init(wrappedValue defaultValue: Value, _ key: String, session: UISceneSession) {
if
let data = session.userInfo?[key] as? Data,
let value = try? JSONDecoder().decode(Value.self, from: data) {
self.init(initialValue: value)
} else {
self.init(initialValue: defaultValue)
}
projectedValue
.sink { newValue in
let data = try? JSONEncoder().encode(newValue)
session.userInfo?[key] = data
}
.store(in: &cancellableSet)
}
}
The final gist can be found here.
Conclusions
@AppStorage and @SceneStorage are two very welcome SwiftUI additions. Unfortunately, they support the same types supported by plists. In this article, we've seen how we can extend SwiftUI to take care of other types as well.
Do you use a different approach? Have you found a way to extend @AppStorage and @SceneStorage directly? Please let me know!
Thank you for reading!