Search code examples
swiftclassswiftuiappstorage

Using @AppStoarge with a custom object array does not persist data


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.


Solution

  • 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