Apple introduced the @AppStorage and @SceneStorage property wrappers to SwiftUI in iOS 14.0 (and macOS 11.0, tvOS 14.0 and watchOS 7.0). The former makes it easier to save user settings and the latter to save and restore view state.
Out of the box they work with common types like Int
, String
and Bool
, but how do you use them with your own custom types?
@AppStorage and @SceneStorage
The @AppStorage property wrapper gives you app wide global storage and takes care of saving it to UserDefaults
. Use it to save small amounts of data like user settings that you want to persist across app launches:
@AppStorage("com.useyourloaf.facts.showVisited")
private var showVisited = true
The property wrapper takes a String
for the key, which should be unique, and optionally a user default store. By default this uses the standard UserDefaults
equivalent to writing:
@AppStorage("com.useyourloaf.facts.showVisited",
store: UserDefaults.standard)
private var showVisited = true
SwiftUI takes care of reading the value from user defaults when the app launches. When you change the value SwiftUI saves it back to user defaults and updates any views that depend on the property.
The @SceneStorage property wrapper allows you to save view state within a scene. Scene storage is not saved to user defaults, it has is own storage, so the key only needs to be unique within the app. It’s much like using a @State property except that SwiftUI takes care of saving and restoring the value. This makes it great for storing things like list selection state:
@SceneStorage("countrySelection")
private var selection: Int?
Unlike @AppStorage you can only access @SceneStorage in a SwiftUI View
. Apple warns that you lose the data if the user kills the scene on the iPad or closes the window on macOS. You also shouldn’t use it to store sensitive data.
Standard Types
Out of the box app and scene storage works with a limited number of standard types. This includes URL
, Int
, Double
, String
, Bool
, Data
and optional versions of those types.
They also support storing RawRepresentable
types where the raw type is an Int
or a String
. That allows us to use app and scene storage with enums, as long as the raw value is an Int
or String
:
private enum DisplayMode: Int {
case list
case grid
}
@SceneStorage("countryListDisplayMode")
private var displayMode: DisplayMode = .list
What About Custom Types?
Here’s my situation. I have a list of countries that I can filter based on a visited property. It’s also searchable and the user can change the scope to search by country name or capital:
There’s a lot of view state here that I would like to store and restore with the scene. For convenience I collected most of the search state into a single SearchScope
struct that I can then use to generate a predicate that drives a Core Data fetch request:
enum SearchByField: String, CaseIterable {
case name
case capital
}
struct SearchScope: Equatable {
var visitedOnly: Bool = false
var beginsWith: String = ""
var searchBy: SearchByField = .name
var predicate: NSPredicate? { get }
}
I store the search scope in a @State property of the view:
@State private var scope = SearchScope()
What I’d like to do is have this state persist with the scene. Unfortunately I get an error if I change the @State property wrapper to @SceneStorage:
The problem is that the @SceneStorage property wrapper does not have an initializer that takes a SearchScope
as the wrapped value.
RawRepresentable
This great post by Natalia Panferova helped me understand a possible solution. As I mentioned before, the @AppStorage and @SceneStorage property wrappers can work with a RawRepresentable
type as long as the raw value is an Int
or a String
.
One way we can represent my custom SearchScope
type as a String
is by encoding it as JSON. I know how to do that. I can first make my types conform to Codable
:
extension SearchByField: Codable {}
extension SearchScope: Codable {}
For now I’ll rely on the compiler to create the Codable
implementation (we’ll revisit that decision in a while).
Next I need to make SearchScope
conform to raw representable using a String
as the raw value:
extension SearchScope: RawRepresentable {
var rawValue: String { get }
init?(rawValue: String)
}
That requires two things. We need to add a rawValue
property that returns the JSON string for our search scope. We also need an initializer that takes the JSON string, decodes it and initializes the SearchScope
properties.
Let’s start with the rawValue
property. This needs to JSON encode the SearchScope
and convert the resulting Data
to a String
:
var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let string = String(data: data, encoding: .utf8)
else {
return "{}"
}
return string
}
The initializer does the reverse it converts the string to data and then decodes it. This is a failable initializer that returns nil if decoding the string fails:
init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode(SearchScope.self, from: data)
else {
return nil
}
self = result
}
I can now switch my @State property to @SceneStorage and it compiles without error:
@SceneStorage("searchScope")
private var scope = SearchScope()
Unfortunately it doesn’t work. Examining the implementation in the debugger it seems it’s repeatedly calling the rawValue
getter until it finally crashes with an EXC_BAD_ACCESS
running the JSON encoder:
However if I provide my own implementation of Codable
rather than relying on the compiler the problem goes away:
extension SearchScope: Codable {
enum CodingKeys: String, CodingKey {
case visitedOnly
case beginsWith
case searchBy
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
visitedOnly = try values.decode(Bool.self, forKey: .visitedOnly)
beginsWith = try values.decode(String.self, forKey: .beginsWith)
searchBy = try values.decode(SearchByField.self, forKey: .searchBy)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(visitedOnly, forKey: .visitedOnly)
try container.encode(beginsWith, forKey: .beginsWith)
try container.encode(searchBy, forKey: .searchBy)
}
}
A couple of quick tests confirm my type is now raw representable:
let scopeJSON = """
{"beginsWith":"A","searchBy":"name","visitedOnly":true}
"""
SearchScope(rawValue: scopeJSON)
// visitedOnly: true beginsWith: "A" searchBy: .name
SearchScope(visitedOnly: true, beginsWith: "A", searchBy: .name).rawValue
// {"beginsWith":"A","searchBy":"name","visitedOnly":true}
In this example I’m using my custom type with @SceneStorage but it would also work with @AppStorage if necessary.