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 one 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 to have 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, if you have found a way, please let me know.
There are some smart people working on it.
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 of @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 succinct as an @AppStorage
variable definition, but definitely not too bad.
In a similar fashion 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, however in this article we’ve seen how can can extend SwiftUI to take care of other types as well.
Do you use a different approach? Have you found a way to directly extend @AppStorage
and @SceneStorage
? Please let me know!
Thank you for reading!
⭑⭑⭑⭑⭑