I am fairly new to SwiftUI and want to use the @AppStorage property wrapper to persist a list of custom class objects in the form of an array. I found a couple of posts here that helped me out creating the following generic extension which I have added to my AppDelegate:
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)
}
}
This is my class representing the object:
class Card: ObservableObject, Identifiable, Codable{
let id : Int
let name : String
let description : String
let legality : [String]
let imageURL : String
let price : String
required init(from decoder: Decoder) throws{
let container = try decoder.container(keyedBy: CardKeys.self)
id = try container.decode(Int.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
description = try container.decode(String.self, forKey: .description)
legality = try container.decode([String].self, forKey: .legality)
imageURL = try container.decode(String.self, forKey: .imageURL)
price = try container.decode(String.self, forKey: .price)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CardKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(description, forKey: .description)
try container.encode(imageURL, forKey: .imageURL)
try container.encode(price, forKey: .price)
}
init(id: Int, name: String, description: String, legality: [String], imageURL: String, price : String) {
self.id = id
self.name = name
self.description = description
self.legality = legality
self.imageURL = imageURL
self.price = price
}
}
enum CardKeys: CodingKey{
case id
case name
case description
case legality
case imageURL
case price
}
I am using a view model class which declares the array as follows:
@Published(wrappedValue: [], "saved_cards") var savedCards: [Card]
The rest of the class simply contains functions that append cards to the array, so I do not believe they would be necessary to highlight here.
My problem is that during the runtime of the application everything seems to work fine - cards appear and are visible in the array however when I try to close my app and reopen it again the array is empty, and it seems like the data was not persisted. It looks like the JSONEncoder/Decoder is not able to serialize/deserialize my class, but I do not understand why.
I would really appreciate suggestions since I do not seem to find a way to solve this issue. I am also using the same approach with a regular Int array which works flawlessly, so it seems like there is a problem with my custom class.
By using try? JSONDecoder().decode(Value.self, from: data)
and not do/try/catch
you're missing the error that's happening in the JSON decoding. The encoder isn't putting in the legality
key, so the decoding is failing to work. In fact, all of your types on Codable
by default, so if you remove all of your custom Codable encode/decode and let the compiler synthesize it for you, it encodes/decodes just fine.
Example with @AppStorage:
struct Card: Identifiable, Codable{
let id : Int
let name : String
let description : String
let legality : [String]
let imageURL : String
let price : String
init(id: Int, name: String, description: String, legality: [String], imageURL: String, price : String) {
self.id = id
self.name = name
self.description = description
self.legality = legality
self.imageURL = imageURL
self.price = price
}
}
enum CardKeys: CodingKey{
case id
case name
case description
case legality
case imageURL
case price
}
extension Array: RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8) else {
return nil
}
do {
let result = try JSONDecoder().decode([Element].self, from: data)
print("Init from result: \(result)")
self = result
} catch {
print("Error: \(error)")
return nil
}
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "[]"
}
print("Returning \(result)")
return result
}
}
struct ContentView : View {
@AppStorage("saved_cards") var savedCards : [Card] = []
var body: some View {
VStack {
Button("add card") {
savedCards.append(Card(id: savedCards.count + 1, name: "\(Date())", description: "", legality: [], imageURL: "", price: ""))
}
List {
ForEach(savedCards, id: \.id) { card in
Text("\(card.name)")
}
}
}
}
}
View model version with @Published (requires same Card, and Array extension from above):
class CardViewModel: ObservableObject {
@Published var savedCards : [Card] = Array<Card>(rawValue: UserDefaults.standard.string(forKey: "saved_cards") ?? "[]") ?? [] {
didSet {
UserDefaults.standard.setValue(savedCards.rawValue, forKey: "saved_cards")
}
}
}
struct ContentView : View {
@StateObject private var viewModel = CardViewModel()
var body: some View {
VStack {
Button("add card") {
viewModel.savedCards.append(Card(id: viewModel.savedCards.count + 1, name: "\(Date())", description: "", legality: [], imageURL: "", price: ""))
}
List {
ForEach(viewModel.savedCards, id: \.id) { card in
Text("\(card.name)")
}
}
}
}
}
Your original @Published implementation relied on a cancellableSet
that didn't seem to exist, so I switched it out for a regular @Published value with an initializer that takes the UserDefaults value and then sets UserDefaults again on didSet