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 enum
s:
// 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
UserDefaults
every time our@Published
value 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!