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 enums:

// 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!

⭑⭑⭑⭑⭑

Explore SwiftUI

Browse all

Explore Swift

Browse all