svcho
svcho

Reputation: 43

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.

Upvotes: 4

Views: 4325

Answers (3)

Joseph Levy
Joseph Levy

Reputation: 222

I recently made a wrapper around AppStorage for Codable values. I'm posting it as an answer here because it's so simple, even though this is an old post. I didn't find much beside using RawRepresentable as above which gave me a BAD_EXEC error and seemed cumbersome. Here is the new property wrapper.

import Foundation
import SwiftUI
@propertyWrapper
struct CodableAppStorage<Value: Codable> : DynamicProperty {

    let defaultValue: Value
    private let decoder = JSONDecoder()
    private let encoder = JSONEncoder()
    @AppStorage private var data: Data

    public init(key: String, defaultValue: Value, store: UserDefaults? = nil ) {
        self.defaultValue = defaultValue
        let defaultData = (try? encoder.encode(defaultValue)) ?? Data()
        self._data = AppStorage(wrappedValue: defaultData, key, store: store )
    }

public init(wrappedValue defaultValue: Value, _ key: String, store: UserDefaults? = nil) {
    self.init(key: key, defaultValue: defaultValue, store: store)
    }

    public var wrappedValue: Value {
        get { (try? decoder.decode(Value.self, from: data)) ?? defaultValue }
    
        nonmutating set {
            if let encoded = try? encoder.encode(newValue) {
                data = encoded; debugPrint("\(newValue) saved")
            } else { debugPrint("Could not encode \(newValue)") }
        }
    }

    var projectedValue: Binding<Value> {
        Binding(
            get: { wrappedValue },
            set: { wrappedValue = $0 }
        )
    }

    func removeValue() { data.removeAll() }
}

Use it like @AppStorage

@CodableAppStorage("aCodable") var aCodable = MyCodable()

Upvotes: 3

Ali Momeni
Ali Momeni

Reputation: 490

I think you can make a simple struct from your model like this:

struct Card : Codable  , Identifiable{
    let id : Int
    let name : String
    let description : String
    let legality : [String]
    let imageURL : String
    let price : String

            enum CodingKeys: String, CodingKey {
                case id
                case name
                case description
                case legality
                case imageURL
                case price
            }
        }

then use jsonEncoder to get a json string out of your data like this

// Encode
let card = Card(id: 1, name: "AAA", description: "BBB" ....)

let jsonEncoder = JSONEncoder()
let jsonData = try jsonEncoder.encode(card)
let json = String(data: jsonData, encoding: String.Encoding.utf8)

and then simply persist it using :

UserDefaults.standard.set(json, forKey: "yourData")

when you want to observe and use it in UI u can use :

@AppStorage("yourData") var myArray: String = ""

Upvotes: 0

jnpdx
jnpdx

Reputation: 52397

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

Upvotes: 6

Related Questions