The SwiftUI property wrapper AppStorage
is a great way to cut down on boiler plate code needed to manage/access UserDefaults. At the risk of sounding like I’m looking a gift horse in the mouth, I feel compelled to describe a couple of challenges I encountered while using AppStorage
in my current project, the language flash cards app.
AppStorage for enums with associated values
The type I wanted to persist using AppStorage
was a fairly gnarly enum with a variety of associated values. It is the value that persists the users preferences for languages to use for asking and answering. See this blog post for more details on how this enum came to be.
enum LanguageChoice: Equatable, Codable {
case all
case oneToOne(Language, Language)
case oneToAll(Language)
case allToOne(Language)
case symmetricSubset(Set<Language>)
}
Out of the box, AppStorage only works with the most basic of types. (Int, String, Bool, etc.) There are several great resources on how to extend AppStorage
support to basic structs. If you google “AppStorage RawRepresentable” you’ll be away to the races in no time. The tl;dr version is: implement public init?(rawValue: String)
andpublic var rawValue: String
My first inclination was to use JSONDecoder()
in init
and JSONEncoder()
in rawValue
. Unfortunately JSONEncoder()
was calling rawValue
, which was calling JSONEncoder()
etc.
So instead I defined a custom scheme to encode (and decode) the LanguageChoice enum
. This required a fair bit of custom code (see below) and allowed LanguageChoice
to work with @AppStorage
. But I wasn’t out of the woods yet. There was still more work to be done, which I’ll describe in a future post.
Here is how I chose to conform to RawRepresentable
.
public init?(rawValue: String) {
let components = rawValue.components(separatedBy: ":")
guard let first = components.first else {
return nil
}
switch first {
case "all":
self = .all
case "oneToOne":
guard components.indices.contains(2),
let lang1 = Language.init(rawValue: components[1]),
let lang2 = Language.init(rawValue: components[2]) else {
return nil
}
self = .oneToOne(lang1, lang2)
case "oneToAll":
guard components.indices.contains(1),
let lang = Language.init(rawValue: components[1]) else {
return nil
}
self = .oneToAll(lang)
case "allToOne":
guard components.indices.contains(1),
let lang = Language.init(rawValue: components[1]) else {
return nil
}
self = .allToOne(lang)
case "symmetricSubset":
guard components.indices.contains(1) else {
return nil
}
let languageComponents = components[1].components(separatedBy: ",")
let languages: Set<Language> = Set(languageComponents.compactMap { Language(rawValue: $0) } )
self = .symmetricSubset(languages)
default:
return nil
}
}
public var rawValue: String {
let data = try? JSONEncoder().encode(self)
print("data: \(data)")
let result: String
switch self {
case .all:
result = "all"
case .oneToOne(let language, let language2):
result = "oneToOne:\(language.rawValue):\(language2.rawValue)"
case .oneToAll(let language):
result = "oneToAll:\(language.rawValue)"
case .allToOne(let language):
result = "allToOne:\(language.rawValue)"
case .symmetricSubset(let set):
let setRawValue = set.sorted().reduce("") {
$0 + $1.rawValue + ","
}
result = "symmetricSubset:\(setRawValue)"
}
return result
}
Unit tests for this code have been left as an exercise for the student.