Reputation: 43
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
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
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
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