SceneStorage For Custom Types

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:

List of countries with a filter bar set to All, search field containing Ba and a scope bar set to 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:

No exact matched in call to initializer

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:

Thread 1: EXC_BAD_ACCESS (code=2) encode is underlined in red

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.

Learn More