Save custom Codable types in AppStorage or SceneStorage
Since iOS 14 we have two property wrappers for persisting state in SwiftUI: AppStorage and SceneStorage. AppStorage
is usually used to persist some user specific settings and SceneStorage
is designed for state restoration.
It's very easy to use these properties when we want to persist value types that they support out of the box, those are Bool
, Int
, Double
, String
, URL
and Data
. For example, we can save the user's chosen city in a weather app using AppStorage
.
struct ContentView: View {
@AppStorage("city") var city = ""
var body: some View {
TextField("Enter your city", text: $city)
}
}
But we need to do a bit more work if we want to save a type that is not supported by AppStorage
or SceneStorage
by default. They both have initializers that accept a RawRepresentable where RawValue
is of type Int
or String
. So we would need to add conformance to RawRepresentable
to our custom type.
It's important to note though that we shouldn't be using AppStorage
and SceneStorage
as a replacement for a database, they are designed to save small pieces of data. But sometimes an app setting or a view state are represented by a custom type and this would be the use case described in this article.
As an example, we will look at a recipe app where the users can pin up to 3 recipes to the top of the list. You can get the full code for the example project from GitHub.
The pinned recipes in our case are a user setting that should be persisted for the whole app, not per scene, so we will be using AppStorage
. But the approach with RawRepresentable
conformance can be used for SceneStorage
as well.
Our custom type to be saved in AppStorage
is an array of UUID
s which is Codable
by default. You might need to add Codable conformance to your type manually.
typealias PinnedRecipes = [UUID]
Now we need to add RawRepresentable conformance to our custom Codable
type. Remember, that AppStorage
only supports RawRepresentable
where the RawValue
's associatedtype
is of type Int
or String
. So the rawValue
property has to return an Int
or a String
.
extension PinnedRecipes: RawRepresentable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode(PinnedRecipes.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
}
}
Once our type conforms to RawRepresentable
we can use it with AppStorage
or SceneStorage
.
struct ContentView: View {
@AppStorage("pinnedRecipes") var pinnedRecipes = PinnedRecipes()
var body: some View {
List {
Section(header: Text("Pinned Recipes")) {
ForEach(pinnedRecipes, id: \.self) { id in
...
}
}
}
}
}
You can use this approach to save a custom struct, a dictionary or an enum with associated values.
You an get the full code for this article on GitHub.